OAuth 2.0 + PKCE:给 SPA / mobile 做 third-party 登录

起因

应用要"用 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:

  1. client 生成随机 code_verifier(高熵 string)
  2. SHA256 hash 得到 code_challenge
  3. authorize 请求带 code_challenge
  4. callback 拿到 code 后换 token 时带原 code_verifier
  5. 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:

  1. 应用问 server 拿 device_code + user_code(如 "ABC-123")
  2. 用户开浏览器去 https://example.com/device 输 user_code
  3. 应用 poll /token endpoint 等用户授权完成

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 / 后端中转。

踩过的坑

  1. redirect_uri 没 register:authorize 返回 invalid redirect。
    到 IdP console 加 callback URL(每环境一个:dev/staging/prod)。

  2. state 验缺失:CSRF 风险。库一般帮做,但自己写时容易漏。

  3. token 存 localStorage:XSS 后即失。BFF 或者 httpOnly cookie。

  4. scope 过宽:要 email 却 request 整个 drive 访问 → 用户怕 +
    不授权。最小 scope。

  5. PKCE verifier 没存好:刷新页 / 跨 tab → callback 找不到
    verifier。sessionStorage 同 tab 内 OK;BFF 模式用 server session。

精确评价 共 0 人评价
可复现性
可复现 · 0 不可复现 · 0
文风
文风流畅 · 0 文风晦涩 · 0
立场
支持 · 0 反对 · 0

登录后即可对本帖作出评价。

评论区 0 条 · 所有人可在此交流

登录后参与评论。

还没有评论,来说两句。