正在加载,请稍候…

OAuth 2.0 与 OpenID Connect:完整实现指南

掌握 OAuth 2.0 和 OIDC 实现安全认证。学习授权码流程、PKCE、刷新令牌、JWT 验证及安全实现模式。

OAuth 2.0 与 OpenID Connect:完整实现指南

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()
}

OAuth 2.0 与 OpenID Connect:完整实现指南插图

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)
    })
  })
}

OAuth 2.0 与 OpenID Connect:完整实现指南插图

刷新令牌轮换

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,
    }
  }
}

OAuth 2.0 与 OpenID Connect:完整实现指南插图

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