Appearance
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 和撤销授权的测试入口。
流程说明
GET /auth生成一次性state和 PKCE verifier,并把 challenge 发送到认证站点。- 认证站点回调本地服务,服务端校验并消费
state。 - 服务端携带
client_secret与 verifier 兑换 Access Token 和 Refresh Token。 - 服务端调用
/auth/tokeninfo核对店铺,并打印shop.id。 POST /refresh轮换整组 Token;POST /revoke撤销 Token Family。
示例把待处理授权和 Token 保存在进程内存中,只适合本地调试,不适合多实例生产部署。