site logo

Marico's space

React/Next.js 应用中的 Google OAuth 2.0 PKCE 流程——无需后端和客户端密钥

前端技术 2026-05-05 17:37:29 3

说实话,我第一次在自己的项目里接入 Google OAuth 的时候,被文档绕得有点晕。Google 的文档确实很全,但信息密度太大,很多关键细节埋得很深,比如为什么非得用 PKCE、access_type=offline 和 prompt=consent 这两个参数到底起什么作用。网上很多教程要么只讲一半,要么直接跳过了 refresh_token 这个环节,导致 token 过期后应用直接挂掉。

这篇文章是从作者实际项目 OvertimeIQ 中提炼出来的,整个流程走下来不依赖任何 OAuth 库,对于想纯手写认证逻辑的同学来说很有参考价值。我翻译的时候尽量保留了代码的原样,只是在注释和说明文字上做了本地化处理,方便大家理解。


如果你尝试过给 SPA 或者 Next.js 应用添加 Google OAuth,肯定遇到过同样的困扰:Google 的文档内容详尽但信息量太大,大部分教程都不完整,最关键的部分——比如为什么要用 PKCE,或者 access_type=offline 到底是干什么用的——都被埋在了犄角旮旯里。

这就是我为 OvertimeIQ 构建认证层时最希望能看到的一篇教程。

读完这篇文章你会搞明白:

  • 为什么需要 PKCE,以及为什么它是 SPA 的正确选择
  • 如何从零开始实现完整的令牌交换(不依赖任何 OAuth 库)
  • 如何获取 refresh_token(大多数教程在这里悄悄跳过了)
  • 如何在会话期间静默刷新令牌,不打断用户
  • 如何将 Google 的 id_token 传给 Supabase,建立第二个独立的认证会话

为什么选 PKCE,而不是其他方案

隐式授权流程(最早的 OAuth 方案,针对 SPA)会把 access_token 直接通过 URL 片段(fragment)返回。这有个已知问题:浏览器历史记录、Referer 请求头、中间服务器都能看到这个 token。OAuth 安全最佳实践(BCP)已经废弃了这个方案,别用它。

不带 PKCE 的授权码流程需要一个 client_secret(客户端密钥)——一个必须保密的值。在服务端应用里这没问题,但在 SPA 里,没有地方能存这个密钥。它会打包进你的 JavaScript 代码包,任何用户都能看到。这个「密钥」就形同虚设了。

PKCE(Proof Key for Code Exchange,代码交换证明密钥)是专门为这种情况设计的。它不用静态密钥,而是为每次授权请求生成一个全新的随机值,从中派生出挑战值,在交换 code 时再证明你持有原始值。没有什么静态的东西可以泄露。


PKCE 流程详解

整个流程是这样的:

浏览器                                    Google
  |                                           |
  |-- (1) 生成 code_verifier -----------> |
  |-- (2) 生成 code_challenge               |
  |-- (3) 重定向到 Google 授权 URL ------> |
  |                   <-- (4) 用户授权 --|
  |<-- (5) 携带 code 重定向 --------------- |
  |-- (6) 用 code + verifier 交换 ---------> |
  |<-- (7) 收到令牌 ------------------- |

code_verifier 是你在浏览器里生成的一个随机字符串,存储在 sessionStorage 里。code_challenge 是 base64url(SHA256(verifier))——一个哈希值,你可以发给 Google 而不需要暴露原始值。在第 6 步交换 code 时,你发送原始的 verifier,Google 会哈希它并验证是否和第 3 步发送的值匹配。被拦截的授权码如果没有 verifier 就毫无用处。


代码实现

第一步:生成 code_verifier 和 code_challenge

// lib/auth.js

function generateRandomString(length) {
  const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'
  const array = new Uint8Array(length)
  crypto.getRandomValues(array)
  return Array.from(array).map(byte => chars[byte % chars.length]).join('')
}

export function generateVerifier() {
  return generateRandomString(64) // 必须在 43-128 个字符之间
}

export async function generateChallenge(verifier) {
  const encoder = new TextEncoder()
  const data = encoder.encode(verifier)
  const digest = await crypto.subtle.digest('SHA-256', data)

  // base64url 编码(和普通 base64 不同——没有 +, /, 或 = 字符)
  return btoa(String.fromCharCode(...new Uint8Array(digest)))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '')
}

第二步:构建授权 URL

export async function buildAuthURL() {
  const verifier = generateVerifier()
  const challenge = await generateChallenge(verifier)

  // 把 verifier 存起来——必须撑过重定向
  sessionStorage.setItem('pkce_verifier', verifier)

  const params = new URLSearchParams({
    client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID,
    redirect_uri: `${window.location.origin}/auth/callback`,
    response_type: 'code',
    scope: 'openid email profile https://www.googleapis.com/auth/drive.file',
    code_challenge: challenge,
    code_challenge_method: 'S256',
    access_type: 'offline',    // ← 想要 refresh_token 就必须设置
    prompt: 'consent',         // ← 也必须设置才能真正拿到 refresh_token(详见下文)
    login_hint: invitedEmail,  // ← 可选:预填充邮箱字段
  })

  return `https://accounts.google.com/o/oauth2/v2/auth?${params}`
}

关键点:access_type=offline 和 prompt=consent

这是大多数教程翻车的地方。access_type=offline 告诉 Google 你想要一个 refresh_token。但 Google 不会在用户已经授权过你的应用的情况下颁发新的 refresh_token——它会缓存授权。prompt=consent 强制每次都显示授权页面,保证每次登录都能拿到新的 refresh_token。

如果漏掉任何一个参数,你只会得到一个 access_token(有效期约 1 小时),其他什么都拿不到。Token 过期时你的应用就废了。

代价是:用户每次登录都会看到 Google 授权页面,即使他们之前已经用过了。在大多数使用场景下这是可以接受的。就 OvertimeIQ 而言,Drive 访问是产品核心功能,让用户重新确认授权反而感觉合理而不是烦人。

第三步:处理回调

用户授权后,Google 会重定向到 /auth/callback?code=AUTHORIZATION_CODE。你需要在服务端把这个 code 换成令牌。即使是主要走客户端的应用,这一步也要在服务端做——code 交换会暴露你的 client ID 和 verifier,你应该在服务端响应里拿到令牌,而不是通过 URL 片段。

在 Next.js 应用里:

// app/auth/callback/route.js

import { NextResponse } from 'next/server'

export async function GET(request) {
  const { searchParams } = new URL(request.url)
  const code = searchParams.get('code')
  const error = searchParams.get('error')

  if (error || !code) {
    return NextResponse.redirect(new URL('/auth-error', request.url))
  }

  // 从 cookie 里读取 verifier(重定向步骤设置的)
  // 下面会讲怎么从浏览器传到服务端
  const verifier = request.cookies.get('pkce_verifier')?.value

  if (!verifier) {
    return NextResponse.redirect(new URL('/auth-error', request.url))
  }

  const tokens = await exchangeCode(code, verifier, request)

  // 清理 verifier cookie
  const response = NextResponse.redirect(new URL('/log', request.url))
  response.cookies.delete('pkce_verifier')

  // 把 access_token 存在会话 cookie 里(短期有效)
  response.cookies.set('g_access_token', tokens.access_token, {
    httpOnly: true,
    secure: true,
    maxAge: 3600
  })

  // refresh_token 和 id_token 需要进一步处理——见下文
  return response
}

async function exchangeCode(code, verifier, request) {
  const response = await fetch('https://oauth2.googleapis.com/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      code,
      client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID,
      client_secret: process.env.GOOGLE_CLIENT_SECRET, // 仅在服务端使用
      redirect_uri: `${new URL(request.url).origin}/auth/callback`,
      grant_type: 'authorization_code',
      code_verifier: verifier,
    })
  })

  if (!response.ok) throw new Error('Token exchange failed')
  return response.json()
  // 返回值: { access_token, refresh_token, id_token, expires_in, token_type }
}

怎么把 verifier 从浏览器传到服务端: verifier 是在浏览器生成的,但回调发生在服务端。最干净的做法是在重定向前把 verifier 存进 cookie:

// 在登录按钮的处理函数里
export async function startSignIn() {
  const verifier = generateVerifier()
  const challenge = await generateChallenge(verifier)

  // 设置成 cookie,这样服务端回调能读到
  document.cookie = `pkce_verifier=${verifier}; path=/; secure; samesite=lax; max-age=300`

  const authUrl = await buildAuthURL(verifier, challenge)
  window.location.href = authUrl
}

三种令牌及其处理方式

成功交换后,你会拿到三个令牌:

access_token——用它来调用所有 API(Google Drive、用户信息等)。有效期约 1 小时。存到 sessionStorage 或内存里——千万别存 localStorage(它短期有效,而且 localStorage 会跨浏览器重启保留)。在 SSR 应用里,用 httpOnly cookie 是最干净的选择。

refresh_token——用它来在当前 token 过期时获取新的 access_token。这是长期有效的(直到用户撤销授权)。在 OvertimeIQ 里,我把它存在用户 Google Drive 上的 SQLite 数据库里——它和用户其他数据一样私密,而且永远不会离开用户的设备和 Drive 存储空间。

id_token——一个包含用户 Google 身份信息的 JWT(sub、email、name、picture)。用它来引导 Supabase 会话(见下文),然后就可以丢弃了。不需要持久化。


把 id_token 传给 Supabase

如果你和 OvertimeIQ 一样同时用 Supabase 和 Google OAuth,可以用 Google 的 id_token 直接创建 Supabase 会话,不需要再跑一遍 OAuth 流程:

import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
)

// 令牌交换之后——把 id_token 传给 Supabase
const { data, error } = await supabase.auth.signInWithIdToken({
  provider: 'google',
  token: googleIdToken,
})

这会为用户创建一个 Supabase 会话。从这里开始,你有两个独立的认证生命周期:

  • Google OAuth 管理 Drive 访问(通过 access_token + refresh_token)
  • Supabase Auth 管理应用身份(用户资料、订阅状态、邀请状态)

两者在初始引导之后就不再需要通信了。如果 Supabase 会话过期,用户重新走一遍 Google 登录流程,两个认证都会刷新。


静默刷新令牌

Access token 的有效期是一小时。你需要透明地刷新它,不打断用户。

// lib/auth.js

export async function refreshAccessToken() {
  // 从 SQLite 设置里读取 refresh_token
  const refreshToken = db.getOne(
    'SELECT google_refresh_token FROM settings WHERE id = 1'
  )?.google_refresh_token

  if (!refreshToken) {
    // 没有 refresh_token——用户需要重新登录
    redirectToSignIn()
    return null
  }

  const response = await fetch('https://oauth2.googleapis.com/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      refresh_token: refreshToken,
      client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID,
      grant_type: 'refresh_token',
    })
  })

  if (!response.ok) {
    // Refresh token 被撤销了——显示重新连接提示,不让应用崩溃
    showReconnectBanner()
    return null
  }

  const { access_token } = await response.json()

  // 存到内存(或 sessionStorage)里,供 Drive 调用使用
  setAccessToken(access_token)

  return access_token
}

可以设置一个定时器,在登录后约 55 分钟时运行(在 1 小时过期前 5 分钟),也可以拦截任何 Drive API 的 401 响应作为立即刷新的信号:

async function driveApiCall(url, options) {
  let accessToken = getAccessToken()

  let response = await fetch(url, {
    ...options,
    headers: { ...options.headers, Authorization: `Bearer ${accessToken}` }
  })

  if (response.status === 401) {
    // Token 过期了——尝试刷新
    accessToken = await refreshAccessToken()
    if (!accessToken) return null // 刷新失败,用户需要重新连接

    // 用新 token 重试原始请求
    response = await fetch(url, {
      ...options,
      headers: { ...options.headers, Authorization: `Bearer ${accessToken}` }
    })
  }

  return response
}

如果刷新失败了(用户撤销了授权、网络离线等),显示一个常驻的「重新连接 Drive」提示条。应用应该保持完全可用——所有数据都在 localStorage/SQLite 里,不需要 Drive 的功能照常运行。这个提示条给用户提供了一条恢复路径,而不会打断他们的工作流程。


login_hint 技巧

如果你提前知道用户应该用哪个邮箱登录——比如一个邀请系统,你已经往特定地址发了邮件——你可以预填充 Google 授权页面:

const params = new URLSearchParams({
  // ... 其他参数
  login_hint: 'invited-user@gmail.com'
})

这不会锁定用户只能用它——Google 仍然允许他们选择其他账号——但会把正确的账号放在第一位。对于邀请制的应用来说,这能大大减少用户用错 Google 账号登录、然后发现邀请用不了的尴尬情况。


常见错误

缺少 access_type=offline:你能拿到 access_token,但没有 refresh_token。一小时后应用就挂了。

缺少 prompt=consent:第一次登录能拿到 refresh_token,但后续登录就不行了(Google 会缓存授权)。如果用户退出再登录,就会失去静默刷新令牌的能力。

把 access_token 存到 localStorage:它有效期很短,在跨会话持久化方面没什么安全收益。sessionStorage 或内存就够了。如果你需要它能跨页面刷新存活,那就存 refresh_token,然后在加载时换取新的 access_token。

在客户端验证 id_token:不解开验证也能解码 JWT(base64 解码 payload),但如果不验证签名就不能用它来做访问控制。用 Supabase 的 signInWithIdToken 或 Google 的 tokeninfo 端点来验证。

在前端代码里使用 client_secret:PKCE 流程不需要 client_secret。如果你把它加到客户端代码里,要么你用的是错误的流程,要么你在暴露不该暴露的东西。


Scope 的选择

我用的是 https://www.googleapis.com/auth/drive.file——这个 scope 只授权访问这个特定应用创建的文件。它无法读取或修改用户 Drive 里的任何其他文件。

对于大多数个人数据应用,这是正确的选择。最小权限原则、更容易向用户解释(「这个应用只能访问它自己创建的文件」),相比更宽泛的 scope 来说也更容易通过 Google 的应用审核。

替代方案是 https://www.googleapis.com/auth/drive——会获得完整的 Drive 访问权限。除非你确实需要,否则别用它。大多数应用都不需要。


这是 OvertimeIQ 系列文章的一部分——OvertimeIQ 是一个个人加班追踪工具,你的数据存在自己的 Google Drive 上。这个系列的前一篇文章详细介绍了浏览器端 SQLite 设置和 Google Drive 同步。第一篇文章则涵盖了整体架构。

原文链接:https://dev.to/google/。