小应开发者平台 Logo小应开发者平台

账号快捷登录

更新于 2026-05-25

Express.js 案例

一个最小 Express.js 参考实现,覆盖登录跳转、回调处理、id_token 验签和本地会话。

本文给出一个最小 Express.js 参考实现。示例用于说明小应账号快捷登录(OIDC)的接入结构,不包含生产级 session、数据库和错误页面。

相关文档:

安装依赖

使用项目当前已有的包管理器即可。以下任选其一:

npm install express jose
npm install -D @types/express typescript
pnpm add express jose
pnpm add -D @types/express typescript
yarn add express jose
yarn add -D @types/express typescript

环境变量

XIAOYING_OIDC_ISSUER=https://api.xiaoying.life/oidc
XIAOYING_OIDC_CLIENT_ID=xyc_...
XIAOYING_OIDC_CLIENT_SECRET=xys_...
THIRD_PARTY_ORIGIN=https://third.example

本例的回调地址为:

${THIRD_PARTY_ORIGIN}/auth/xiaoying/callback

该地址必须已经由小应官方技术支持人员登记。

最小代码

import crypto from "node:crypto"
import express from "express"
import { createRemoteJWKSet, jwtVerify } from "jose"
 
type Discovery = {
  issuer: string
  authorization_endpoint: string
  token_endpoint: string
  jwks_uri: string
  userinfo_endpoint: string
}
 
type Attempt = {
  nonce: string
  codeVerifier: string
  expiresAt: number
}
 
const issuer = process.env.XIAOYING_OIDC_ISSUER ?? "https://api.xiaoying.life/oidc"
const clientId = requiredEnv("XIAOYING_OIDC_CLIENT_ID")
const clientSecret = requiredEnv("XIAOYING_OIDC_CLIENT_SECRET")
const origin = requiredEnv("THIRD_PARTY_ORIGIN").replace(/\/+$/, "")
const redirectUri = `${origin}/auth/xiaoying/callback`
 
const attempts = new Map<string, Attempt>()
const localSessions = new Map<string, unknown>()
 
function requiredEnv(key: string) {
  const value = process.env[key]
  if (!value) throw new Error(`${key} is required`)
  return value
}
 
function randomBase64Url(bytes = 32) {
  return crypto.randomBytes(bytes).toString("base64url")
}
 
function pkceChallenge(verifier: string) {
  return crypto.createHash("sha256").update(verifier).digest("base64url")
}
 
function parseCookies(header: string | undefined) {
  const cookies = new Map<string, string>()
  for (const part of (header ?? "").split(";")) {
    const [name, ...value] = part.trim().split("=")
    if (name) cookies.set(name, decodeURIComponent(value.join("=")))
  }
  return cookies
}
 
async function getDiscovery(): Promise<Discovery> {
  const response = await fetch(`${issuer}/.well-known/openid-configuration`, {
    cache: "no-store",
  })
  if (!response.ok) {
    throw new Error(`discovery failed: ${response.status}`)
  }
  const discovery = await response.json() as Discovery
  if (discovery.issuer !== issuer) {
    throw new Error("issuer mismatch")
  }
  return discovery
}
 
async function exchangeCodeForToken(input: {
  discovery: Discovery
  code: string
  codeVerifier: string
}) {
  const body = new URLSearchParams({
    grant_type: "authorization_code",
    code: input.code,
    redirect_uri: redirectUri,
    code_verifier: input.codeVerifier,
  })
  const basic = Buffer
    .from(`${clientId}:${clientSecret}`, "utf8")
    .toString("base64")
 
  const response = await fetch(input.discovery.token_endpoint, {
    method: "POST",
    headers: {
      authorization: `Basic ${basic}`,
      "content-type": "application/x-www-form-urlencoded",
    },
    body,
  })
  const json = await response.json() as {
    access_token?: string
    id_token?: string
    token_type?: string
  }
  if (!response.ok || !json.id_token || !json.access_token) {
    throw new Error(`token exchange failed: ${response.status}`)
  }
  return json
}
 
async function verifyIdToken(input: {
  discovery: Discovery
  idToken: string
  nonce: string
}) {
  const jwks = createRemoteJWKSet(new URL(input.discovery.jwks_uri))
  const { payload } = await jwtVerify(input.idToken, jwks, {
    issuer,
    audience: clientId,
    algorithms: ["EdDSA"],
  })
  if (payload.nonce !== input.nonce) {
    throw new Error("nonce mismatch")
  }
  if (typeof payload.sub !== "string" || !payload.sub) {
    throw new Error("sub missing")
  }
  return payload
}
 
async function fetchUserinfo(input: {
  discovery: Discovery
  accessToken: string
  expectedSub: string
}) {
  const response = await fetch(input.discovery.userinfo_endpoint, {
    headers: {
      authorization: `Bearer ${input.accessToken}`,
    },
  })
  if (!response.ok) return null
  const userinfo = await response.json() as { sub?: string }
  if (userinfo.sub !== input.expectedSub) {
    throw new Error("userinfo sub mismatch")
  }
  return userinfo
}
 
const app = express()
 
app.get("/", (req, res) => {
  const sid = parseCookies(req.headers.cookie).get("sid")
  const session = sid ? localSessions.get(sid) : undefined
  res.type("html").send(`
    <h1>第三方网站</h1>
    <p>${session ? "已登录" : "未登录"}</p>
    <p><a href="/login/xiaoying">使用小应登录</a></p>
    <pre>${JSON.stringify(session ?? null, null, 2)}</pre>
  `)
})
 
app.get("/login/xiaoying", async (_req, res) => {
  const discovery = await getDiscovery()
  const state = randomBase64Url(32)
  const nonce = randomBase64Url(32)
  const codeVerifier = randomBase64Url(64)
  attempts.set(state, {
    nonce,
    codeVerifier,
    expiresAt: Date.now() + 10 * 60 * 1000,
  })
 
  const url = new URL(discovery.authorization_endpoint)
  url.searchParams.set("response_type", "code")
  url.searchParams.set("client_id", clientId)
  url.searchParams.set("redirect_uri", redirectUri)
  url.searchParams.set("scope", "openid profile")
  url.searchParams.set("state", state)
  url.searchParams.set("nonce", nonce)
  url.searchParams.set("code_challenge", pkceChallenge(codeVerifier))
  url.searchParams.set("code_challenge_method", "S256")
  res.redirect(url.toString())
})
 
app.get("/auth/xiaoying/callback", async (req, res) => {
  const state = typeof req.query.state === "string" ? req.query.state : ""
  const attempt = attempts.get(state)
  attempts.delete(state)
  if (!attempt || attempt.expiresAt < Date.now()) {
    res.status(400).send("invalid_state")
    return
  }
 
  if (typeof req.query.error === "string") {
    res.status(400).send(`xiaoying_login_error:${req.query.error}`)
    return
  }
  if (typeof req.query.code !== "string") {
    res.status(400).send("missing_code")
    return
  }
 
  const discovery = await getDiscovery()
  const tokenSet = await exchangeCodeForToken({
    discovery,
    code: req.query.code,
    codeVerifier: attempt.codeVerifier,
  })
  const claims = await verifyIdToken({
    discovery,
    idToken: tokenSet.id_token!,
    nonce: attempt.nonce,
  })
  const userinfo = await fetchUserinfo({
    discovery,
    accessToken: tokenSet.access_token!,
    expectedSub: claims.sub as string,
  })
 
  const externalIdentity = {
    provider: "xiaoying",
    issuer: claims.iss,
    subject: claims.sub,
  }
  const sid = randomBase64Url(32)
  localSessions.set(sid, {
    externalIdentity,
    userinfo,
  })
  res.cookie("sid", sid, {
    httpOnly: true,
    sameSite: "lax",
    path: "/",
  })
  res.redirect("/")
})
 
app.listen(3000, () => {
  console.log(`listening on ${origin}`)
  console.log(`redirect_uri: ${redirectUri}`)
})

生产化改造点

  • 用 Redis、数据库或加密 HttpOnly Cookie 保存登录 attempt,不要使用进程内存。
  • issuer + subject 建唯一约束。
  • client_secret 放入服务端密钥系统。
  • 对错误页、取消登录、超时登录进行明确提示。
  • 控制日志和错误上报,避免记录 authorization code、id_token、小应账号快捷登录 access_tokenclient_secret