起因
应用要"用 Google / GitHub 账号登录"。
OAuth 2.0 有多种 flow:
- Authorization Code(有 backend,推荐)
- Implicit(弃用)
- Resource Owner Password(弃用)
- Client Credentials(machine-to-machine)
SPA / mobile 之前用 implicit flow(token 直接放 URL),现在标准是
Authorization Code + PKCE。
PKCE 是什么
Authorization Code flow 需要 client secret 验证 client 身份。
SPA / mobile 不能藏 secret(公共 client)。
PKCE (Proof Key for Code Exchange) 用临时 challenge 替代 secret:
- client 生成随机
code_verifier(高熵 string) - SHA256 hash 得到
code_challenge - authorize 请求带
code_challenge - callback 拿到 code 后换 token 时带原
code_verifier - server 验 hash(verifier) == challenge → 确认是同一 client
防止"中间人截获 authorization code 后冒充换 token"。
flow 详细
1. client 生成 verifier + challenge
const codeVerifier = generateRandomString(64);
const codeChallenge = base64UrlEncode(await sha256(codeVerifier));
// 存到 sessionStorage(callback 时取)
sessionStorage.setItem('pkce_verifier', codeVerifier);
2. 跳转 authorize URL
const params = new URLSearchParams({
client_id: 'my-client-id',
redirect_uri: 'https://app.example.com/callback',
response_type: 'code',
scope: 'openid profile email',
state: randomState, // 防 CSRF
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
window.location = `https://auth.example.com/authorize?${params}`;
用户在 IdP 登录 → 同意授权 → IdP redirect 回:
https://app.example.com/callback?code=ABC123&state=...
3. callback 换 token
async function callback() {
const code = new URLSearchParams(location.search).get('code');
const verifier = sessionStorage.getItem('pkce_verifier');
const res = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
client_id: 'my-client-id',
redirect_uri: 'https://app.example.com/callback',
code_verifier: verifier,
}),
});
const tokens = await res.json();
// { access_token, refresh_token, id_token, expires_in }
sessionStorage.removeItem('pkce_verifier');
storeTokens(tokens);
}
server 验 hash(verifier) == challenge → 发 token。
token 存哪
SPA 痛点:access_token 存哪?
- localStorage:XSS 一漏全暴露
- sessionStorage:同 localStorage + tab close 丢
- memory (JS variable):刷新丢
- httpOnly cookie:最安全但需要 same-origin 或者 cross-origin
配置 + BFF backend
主流推荐:BFF (Backend-for-Frontend) 模式
[SPA] ↔ same-origin httpOnly cookie ↔ [BFF Node/Python]
↓ access_token
[Resource API]
- BFF 持 token,SPA 仅 session cookie
- XSS 拿不到 token
- CSRF 用 sameSite=strict cookie 防
复杂但最安全。如果纯前端:access_token in memory + refresh 在
httpOnly cookie。
refresh token
access_token 短(15 min - 1 h),过期用 refresh_token 换新。
PKCE flow 也能给 refresh_token:scope=offline_access。
refresh_token rotation(每次用过失效)防 leak 后无限续。
现代 OAuth 服务器(Auth0 / Okta / Keycloak)默认开。
state 防 CSRF
// 跳 authorize 之前
const state = generateRandom();
sessionStorage.setItem('oauth_state', state);
// 加到 URL params
// callback 验
const returnedState = new URLSearchParams(location.search).get('state');
const expectedState = sessionStorage.getItem('oauth_state');
if (returnedState !== expectedState) throw 'CSRF';
不验 state → 攻击者可让用户登入攻击者账号(account takeover)。
必须验。
scope
OAuth scope 控制 token 能干啥:
scope=openid profile email # OIDC 标准
scope=read:user user:email # GitHub
scope=https://www.googleapis.com/auth/drive.readonly # Google API
最小权限:只要 email,不要 read:user。
OIDC vs OAuth
- OAuth 2.0:授权("app 能代你访问 API")
- OpenID Connect (OIDC):在 OAuth 上面加身份层("who is the user")
OIDC 加 id_token(JWT containing 用户信息)+ userinfo endpoint。
login 场景用 OIDC(need user identity),不仅是 API 授权。
scope 加 openid 启用 OIDC。
Library
不要手写 OAuth,用库:
- JS:
oidc-client-ts,@auth0/auth0-spa-js - Python:
authlib,httpx-auth - Go:
golang.org/x/oauth2
PKCE flow 几行代码:
import { UserManager } from 'oidc-client-ts';
const userManager = new UserManager({
authority: 'https://auth.example.com',
client_id: 'my-client',
redirect_uri: 'https://app.example.com/callback',
response_type: 'code',
scope: 'openid profile email',
// PKCE 自动
});
await userManager.signinRedirect(); // 跳 authorize
const user = await userManager.signinRedirectCallback(); // callback 处理
device code flow
CLI / TV / IoT 没浏览器,用 device flow:
- 应用问 server 拿
device_code+user_code(如 "ABC-123") - 用户开浏览器去
https://example.com/device输 user_code - 应用 poll
/tokenendpoint 等用户授权完成
GitHub CLI gh auth login 就是 device flow。
实战 case:第三方"用 GitHub 登录"
# backend (FastAPI + authlib)
from authlib.integrations.starlette_client import OAuth
oauth = OAuth()
oauth.register(
'github',
client_id=GITHUB_CLIENT_ID,
client_secret=GITHUB_CLIENT_SECRET,
access_token_url='https://github.com/login/oauth/access_token',
authorize_url='https://github.com/login/oauth/authorize',
api_base_url='https://api.github.com/',
client_kwargs={'scope': 'user:email'},
)
@app.get('/auth/github')
async def login(request):
redirect = request.url_for('callback')
return await oauth.github.authorize_redirect(request, redirect)
@app.get('/auth/github/callback')
async def callback(request):
token = await oauth.github.authorize_access_token(request)
user = await oauth.github.get('user', token=token)
user_data = user.json()
# create / find local user + issue session
...
backend 持 client_secret(GitHub OAuth app 经典 confidential client),
不需要 PKCE。
如果纯 SPA 直接调 GitHub → PKCE。但 GitHub OAuth app 不支持 PKCE,
要用 GitHub App + device flow / 后端中转。
踩过的坑
-
redirect_uri 没 register:authorize 返回 invalid redirect。
到 IdP console 加 callback URL(每环境一个:dev/staging/prod)。 -
state 验缺失:CSRF 风险。库一般帮做,但自己写时容易漏。
-
token 存 localStorage:XSS 后即失。BFF 或者 httpOnly cookie。
-
scope 过宽:要 email 却 request 整个 drive 访问 → 用户怕 +
不授权。最小 scope。 -
PKCE verifier 没存好:刷新页 / 跨 tab → callback 找不到
verifier。sessionStorage 同 tab 内 OK;BFF 模式用 server session。
登录后参与评论。