
说实话,我第一次在自己的项目里接入 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 构建认证层时最希望能看到的一篇教程。
读完这篇文章你会搞明白:
隐式授权流程(最早的 OAuth 方案,针对 SPA)会把 access_token 直接通过 URL 片段(fragment)返回。这有个已知问题:浏览器历史记录、Referer 请求头、中间服务器都能看到这个 token。OAuth 安全最佳实践(BCP)已经废弃了这个方案,别用它。
不带 PKCE 的授权码流程需要一个 client_secret(客户端密钥)——一个必须保密的值。在服务端应用里这没问题,但在 SPA 里,没有地方能存这个密钥。它会打包进你的 JavaScript 代码包,任何用户都能看到。这个「密钥」就形同虚设了。
PKCE(Proof Key for Code Exchange,代码交换证明密钥)是专门为这种情况设计的。它不用静态密钥,而是为每次授权请求生成一个全新的随机值,从中派生出挑战值,在交换 code 时再证明你持有原始值。没有什么静态的东西可以泄露。
整个流程是这样的:
浏览器 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 就毫无用处。
// 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, '')
}
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 会话(见下文),然后就可以丢弃了。不需要持久化。
如果你和 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 会话。从这里开始,你有两个独立的认证生命周期:
两者在初始引导之后就不再需要通信了。如果 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 的功能照常运行。这个提示条给用户提供了一条恢复路径,而不会打断他们的工作流程。
如果你提前知道用户应该用哪个邮箱登录——比如一个邀请系统,你已经往特定地址发了邮件——你可以预填充 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。如果你把它加到客户端代码里,要么你用的是错误的流程,要么你在暴露不该暴露的东西。
我用的是 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/。