本文给出一个最小 Express.js 参考实现。示例用于说明小应账号快捷登录(OIDC)的接入结构,不包含生产级 session、数据库和错误页面。
相关文档:
安装依赖
使用项目当前已有的包管理器即可。以下任选其一:
npm install express jose
npm install -D @types/express typescriptpnpm add express jose
pnpm add -D @types/express typescriptyarn 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_token或client_secret。