
OAuth 2.0 与 OpenID Connect
带 PKCE 的 OAuth 2.0 授权码流程
import crypto from 'crypto'
// 步骤 1:生成 PKCE 码验证器和挑战值
function generatePKCE() {
const verifier = crypto.randomBytes(32).toString('base64url')
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url')
return { verifier, challenge }
}
// 步骤 2:构建授权 URL
function buildAuthURL(config, codeChallenge, state) {
const params = new URLSearchParams({
response_type: 'code',
client_id: config.clientId,
redirect_uri: config.redirectUri,
scope: 'openid profile email',
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
})
return `${config.authorizationEndpoint}?${params}`
}
// 步骤 3:用授权码交换令牌
async function exchangeCode(code, codeVerifier, config) {
const resp = await fetch(config.tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: config.clientId,
redirect_uri: config.redirectUri,
code,
code_verifier: codeVerifier,
}),
})
return resp.json()
}

JWT 验证
import jwt from 'jsonwebtoken'
import jwksClient from 'jwks-rsa'
const client = jwksClient({
jwksUri: 'https://auth.example.com/.well-known/jwks.json',
cache: true,
cacheMaxEntries: 5,
cacheMaxAge: 600000, // 10 分钟
})
function getKey(header, callback) {
client.getSigningKey(header.kid, (err, key) => {
if (err) return callback(err)
callback(null, key.getPublicKey())
})
}
function validateToken(token, options = {}) {
return new Promise((resolve, reject) => {
jwt.verify(token, getKey, {
algorithms: ['RS256'],
issuer: 'https://auth.example.com',
audience: process.env.CLIENT_ID,
...options,
}, (err, decoded) => {
if (err) reject(err)
else resolve(decoded)
})
})
}

刷新令牌轮换
class TokenManager {
constructor(config) {
this.config = config
this.tokens = null
}
async getAccessToken() {
if (!this.tokens) throw new Error('Not authenticated')
if (this.isExpired()) {
await this.refresh()
}
return this.tokens.access_token
}
isExpired() {
return Date.now() >= this.tokens.expires_at - 60000 // 1 分钟缓冲
}
async refresh() {
const resp = await fetch(this.config.tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
client_id: this.config.clientId,
refresh_token: this.tokens.refresh_token,
}),
})
if (!resp.ok) {
this.tokens = null // 强制重新登录
throw new Error('Token refresh failed')
}
const data = await resp.json()
this.tokens = {
...data,
expires_at: Date.now() + data.expires_in * 1000,
}
}
}

Express.js OAuth 中间件
import express from 'express'
import session from 'express-session'
import { Issuer, generators } from 'openid-client'
const app = express()
app.use(session({ secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false }))
let client
async function setupOIDC() {
const issuer = await Issuer.discover('https://accounts.google.com')
client = new issuer.Client({
client_id: process.env.GOOGLE_CLIENT_ID,
client_secret: process.env.GOOGLE_CLIENT_SECRET,
redirect_uris: ['http://localhost:3000/callback'],
response_types: ['code'],
})
}
app.get('/login', (req, res) => {
const state = generators.state()
const nonce = generators.nonce()
const codeVerifier = generators.codeVerifier()
const codeChallenge = generators.codeChallenge(codeVerifier)
req.session.auth = { state, nonce, codeVerifier }
const authUrl = client.authorizationUrl({
scope: 'openid email profile',
state, nonce, code_challenge: codeChallenge,
code_challenge_method: 'S256',
})
res.redirect(authUrl)
})
app.get('/callback', async (req, res) => {
const params = client.callbackParams(req)
const { state, nonce, codeVerifier } = req.session.auth
const tokenSet = await client.callback(
'http://localhost:3000/callback',
params, { state, nonce, code_verifier: codeVerifier }
)
const userinfo = await client.userinfo(tokenSet.access_token)
req.session.user = userinfo
res.redirect('/dashboard')
})
// 受保护路由中间件
function requireAuth(req, res, next) {
if (!req.session.user) return res.redirect('/login')
next()
}
app.get('/dashboard', requireAuth, (req, res) => {
res.json({ user: req.session.user })
})
安全最佳实践
| 关注点 |
缓解措施 |
| CSRF |
State 参数 + SameSite Cookie |
| 令牌窃取 |
短生命周期访问令牌(15 分钟) |
| 刷新令牌重用 |
令牌轮换 + 检测重用 |
| 重定向 URI |
精确匹配验证 |
| State 验证 |
加密随机 state |