Skip to content

Authorization Code + PKCE 示例

这是一个可直接运行的零依赖 Node.js 服务,演示 Authorization Code + PKCE、Token 轮换和授权撤销。完整运行说明见下方,示例凭证仅用于测试环境。

Client Secret 仅限服务端

不要把 CLIENT_SECRET 放入浏览器代码、公开日志或前端构建产物。生产环境应由密钥管理服务注入。

环境变量

将下面的内容保存为 .env,或从源码目录复制 .env.example

dotenv
APP_ID=sgm_app_B8jPisfsYiswEnH6wEs0kjj7
CLIENT_SECRET=sgm_secret_HsoPFvw0bgEfodQTd-5tsTGVluPOwZpslhIh9Bs_vQ0
AUTH_ORIGIN=https://auth.test.sugumart.com
OPENAPI_ORIGIN=https://openapi.test.sugumart.com
REDIRECT_URI=http://localhost:4000/api/stores/sugumart/callback
PORT=4000

服务代码

js
import { createHash, randomBytes } from 'node:crypto'
import { createServer } from 'node:http'

const config = {
  appId: requiredEnv('APP_ID'),
  clientSecret: requiredEnv('CLIENT_SECRET'),
  authOrigin: requiredOrigin('AUTH_ORIGIN'),
  openapiOrigin: requiredOrigin('OPENAPI_ORIGIN'),
  redirectUri: requiredRedirectUri('REDIRECT_URI'),
  port: Number(process.env.PORT ?? 4000),
}

if (!Number.isInteger(config.port) || config.port < 1 || config.port > 65535) {
  throw new Error('PORT must be an integer between 1 and 65535')
}

const pendingAuthorizations = new Map()
const stateTtlMs = 5 * 60 * 1000
let credentials = null
const credentialActions = '<form method="post" action="/refresh"><button type="submit">测试刷新</button></form><form method="post" action="/revoke"><button type="submit">撤销授权</button></form>'

function requiredEnv(name) {
  const value = process.env[name]?.trim()
  if (!value) throw new Error(`${name} is required`)
  return value
}

function requiredOrigin(name) {
  const url = new URL(requiredEnv(name))
  if (url.protocol !== 'https:' || url.username || url.password || url.hash || url.search) {
    throw new Error(`${name} must be an HTTPS origin`)
  }
  return url.origin
}

function requiredRedirectUri(name) {
  const url = new URL(requiredEnv(name))
  if (url.protocol !== 'http:' && url.protocol !== 'https:') {
    throw new Error(`${name} must use HTTP or HTTPS`)
  }
  return url.toString()
}

function html(title, body) {
  return `<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>${title}</title><body><main><h1>${title}</h1>${body}</main></body></html>`
}

function sendHtml(response, status, title, body) {
  response.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' })
  response.end(html(title, body))
}

function sendRedirect(response, location) {
  response.writeHead(302, { location, 'cache-control': 'no-store' })
  response.end()
}

function createAuthorizationUrl() {
  const state = randomBytes(32).toString('base64url')
  const verifier = randomBytes(64).toString('base64url')
  const challenge = createHash('sha256').update(verifier).digest('base64url')
  pendingAuthorizations.set(state, { verifier, expiresAt: Date.now() + stateTtlMs })

  const url = new URL('/register', config.authOrigin)
  url.search = new URLSearchParams({
    redirect_uri: config.redirectUri,
    state,
    app_id: config.appId,
    code_challenge: challenge,
    code_challenge_method: 'S256',
  }).toString()
  return url
}

async function handleCallback(requestUrl, response) {
  const code = requestUrl.searchParams.get('code')
  const state = requestUrl.searchParams.get('state')
  const pending = state ? pendingAuthorizations.get(state) : undefined

  if (!code || !state || !pending || pending.expiresAt <= Date.now()) {
    if (state) pendingAuthorizations.delete(state)
    sendHtml(response, 400, '认证失败', '<p>授权码或 state 无效,请重新发起绑定。</p>')
    return
  }

  pendingAuthorizations.delete(state)
  const tokenResponse = await fetch(new URL('/api/auth/token', config.authOrigin), {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({
      grant_type: 'authorization_code',
      code,
      redirect_uri: config.redirectUri,
      client_id: config.appId,
      client_secret: config.clientSecret,
      code_verifier: pending.verifier,
    }),
  })
  const tokenResult = await tokenResponse.json()

  if (!tokenResponse.ok || typeof tokenResult?.access_token !== 'string' || typeof tokenResult?.refresh_token !== 'string' || typeof tokenResult?.shop_id !== 'string') {
    console.error('[auth] Token exchange failed:', tokenResponse.status, tokenResult?.errorCode ?? 'unknown_error')
    sendHtml(response, 502, '认证失败', '<p>授权码兑换失败,请查看服务端日志后重试。</p>')
    return
  }

  const tokenInfoResponse = await fetch(new URL('/auth/tokeninfo', config.openapiOrigin), {
    headers: { authorization: `Bearer ${tokenResult.access_token}` },
  })
  const tokenInfoResult = await tokenInfoResponse.json()
  const shopUuid = tokenInfoResult?.data?.shop?.id
  if (!tokenInfoResponse.ok || typeof shopUuid !== 'string' || shopUuid !== tokenResult.shop_id) {
    console.error('[auth] Token info failed:', tokenInfoResponse.status, tokenInfoResult?.code ?? tokenInfoResult?.message ?? 'invalid_shop')
    sendHtml(response, 502, '认证失败', '<p>Token 已兑换,但读取店铺信息失败,请查看服务端日志。</p>')
    return
  }

  console.log(`[auth] Shop UUID: ${shopUuid}`)
  credentials = { accessToken: tokenResult.access_token, refreshToken: tokenResult.refresh_token, shopUuid }
  sendHtml(response, 200, '认证成功', `<p>已绑定 Sugumart 店铺。</p><p>Shop UUID: <code>${shopUuid}</code></p>${credentialActions}`)
}

async function handleRefresh(response) {
  if (!credentials) {
    sendHtml(response, 400, '尚未认证', '<p>请先完成店铺绑定。</p>')
    return
  }
  const tokenResponse = await fetch(new URL('/api/auth/token', config.authOrigin), {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ grant_type: 'refresh_token', refresh_token: credentials.refreshToken, client_id: config.appId, client_secret: config.clientSecret }),
  })
  const tokenResult = await tokenResponse.json()
  if (!tokenResponse.ok || typeof tokenResult?.access_token !== 'string' || typeof tokenResult?.refresh_token !== 'string' || tokenResult?.shop_id !== credentials.shopUuid) {
    console.error('[auth] Token refresh failed:', tokenResponse.status, tokenResult?.errorCode ?? 'unknown_error')
    credentials = null
    sendHtml(response, 401, '刷新失败', '<p>授权已失效,请重新绑定。</p>')
    return
  }
  credentials = { accessToken: tokenResult.access_token, refreshToken: tokenResult.refresh_token, shopUuid: credentials.shopUuid }
  console.log(`[auth] Refreshed Shop UUID: ${credentials.shopUuid}`)
  sendHtml(response, 200, '刷新成功', `<p>已原子替换 Access Token 和 Refresh Token。</p><p>Shop UUID: <code>${credentials.shopUuid}</code></p>${credentialActions}`)
}

async function handleRevoke(response) {
  if (!credentials) {
    sendHtml(response, 200, '无需撤销', '<p>当前没有本地授权凭证。</p>')
    return
  }
  const revokeResponse = await fetch(new URL('/api/auth/revoke', config.authOrigin), {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ token: credentials.refreshToken, client_id: config.appId, client_secret: config.clientSecret }),
  })
  if (!revokeResponse.ok) {
    sendHtml(response, 502, '撤销失败', '<p>远端授权未确认撤销,本地凭证暂时保留。</p>')
    return
  }
  credentials = null
  console.log('[auth] Token family revoked')
  sendHtml(response, 200, '授权已撤销', '<p>远端 Token Family 已撤销,本地凭证已清除。</p>')
}

const server = createServer(async (request, response) => {
  try {
    const requestUrl = new URL(request.url ?? '/', `http://${request.headers.host ?? `localhost:${config.port}`}`)
    if (request.method === 'GET' && requestUrl.pathname === '/') {
      sendHtml(response, 200, 'Sugumart OpenAPI Auth 示例', `<p><a href="/auth">绑定 Sugumart 店铺</a></p>${credentials ? credentialActions : ''}`)
      return
    }
    if (request.method === 'GET' && requestUrl.pathname === '/auth') {
      sendRedirect(response, createAuthorizationUrl().toString())
      return
    }
    if (request.method === 'GET' && requestUrl.pathname === '/api/stores/sugumart/callback') {
      await handleCallback(requestUrl, response)
      return
    }
    if (request.method === 'POST' && requestUrl.pathname === '/refresh') {
      await handleRefresh(response)
      return
    }
    if (request.method === 'POST' && requestUrl.pathname === '/revoke') {
      await handleRevoke(response)
      return
    }
    sendHtml(response, 404, 'Not Found', '<p>页面不存在。</p>')
  } catch (error) {
    console.error('[server] Request failed:', error instanceof Error ? error.message : 'unknown_error')
    sendHtml(response, 500, '服务错误', '<p>请求处理失败,请查看服务端日志。</p>')
  }
})

server.listen(config.port, '0.0.0.0', () => {
  console.log(`[server] Listening on http://localhost:${config.port}`)
  console.log(`[server] Callback: ${config.redirectUri}`)
  console.log(`[server] OpenAPI origin: ${config.openapiOrigin}`)
})

运行方式

先在开放应用中登记回调地址 http://localhost:4000/api/stores/sugumart/callback,再运行:

bash
cp .env.example .env
pnpm start

浏览器访问 http://localhost:4000。授权成功后,终端会打印绑定的 Sugumart Shop UUID,页面也会提供刷新 Token 和撤销授权的测试入口。

流程说明

  1. GET /auth 生成一次性 state 和 PKCE verifier,并把 challenge 发送到认证站点。
  2. 认证站点回调本地服务,服务端校验并消费 state
  3. 服务端携带 client_secret 与 verifier 兑换 Access Token 和 Refresh Token。
  4. 服务端调用 /auth/tokeninfo 核对店铺,并打印 shop.id
  5. POST /refresh 轮换整组 Token;POST /revoke 撤销 Token Family。

示例把待处理授权和 Token 保存在进程内存中,只适合本地调试,不适合多实例生产部署。