账号快捷登录
更新于 2026-05-25
Next.js TypeScript 案例
使用 Next.js App Router、TypeScript、Prisma 和 PostgreSQL 的生产化接入骨架。
本文给出一个尽量精简的 Next.js App Router + TypeScript + Prisma + PostgreSQL 接入骨架。默认方案只建议新增一类长期数据:
- 外部身份绑定:用于保存
provider + issuer + subject到第三方本地用户的映射。
小应账号快捷登录的临时 attempt 只保存 state、nonce、code_verifier,有效期很短。除非第三方部署在 Vercel 等 serverless 平台、不是 standalone 进程,或者是多实例部署,否则默认使用内存 Map 即可。
本地用户表、业务账号创建、session 创建和退出登录通常已经存在于第三方系统中,本文用函数占位,不要求照搬新的账号体系。
相关文档:
技术栈
推荐使用:
- Next.js App Router
- TypeScript
- Prisma
- PostgreSQL
jose
使用项目当前已有的包管理器即可。以下任选其一:
npm install @prisma/client jose
npm install -D prismapnpm add @prisma/client jose
pnpm add -D prismayarn add @prisma/client jose
yarn add -D prisma环境变量
DATABASE_URL=postgresql://user:password@localhost:5432/third_party_app
NEXT_PUBLIC_APP_ORIGIN=https://third.example
XIAOYING_OIDC_ISSUER=https://api.xiaoying.life/oidc
XIAOYING_OIDC_CLIENT_ID=xyc_...
XIAOYING_OIDC_CLIENT_SECRET=xys_...其中回调地址固定为:
${NEXT_PUBLIC_APP_ORIGIN}/api/auth/xiaoying/callback该地址必须已经由小应官方技术支持人员登记。
方案选择
方案 A:内存 attempt,默认推荐
适用条件:
- Next.js 以 standalone Node.js 服务运行。
- 单实例部署。
- 登录发起和回调会命中同一个进程。
- 可以接受进程重启后未完成的登录请求失效。
这种方案最少落库,只需要保存长期外部身份绑定。
方案 B:共享 attempt,仅特殊部署使用
如果第三方明确使用 Vercel 等 serverless 平台、不是 standalone 进程,或者是多实例部署,那么登录发起和回调可能不在同一个进程内。此时内存 Map 不可靠,应把 attempt store 替换为数据库或其他共享短期存储。
本文默认代码使用方案 A,并在后面给出方案 B 的替换点。
Prisma 最小模型
如果第三方系统已有用户表,不需要按本文创建新的 User 表。只需要增加外部身份绑定表,并让 localUserId 指向第三方自己的用户 ID。
model ExternalIdentity {
id String @id @default(cuid())
provider String
issuer String
subject String
localUserId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([provider, issuer, subject])
@@index([localUserId])
}运行迁移时也使用项目当前包管理器:
npx prisma migrate dev --name add_xiaoying_oidcpnpm exec prisma migrate dev --name add_xiaoying_oidcyarn prisma migrate dev --name add_xiaoying_oidcPrisma Client
src/lib/prisma.ts:
import { PrismaClient } from "@prisma/client"
const globalForPrisma = globalThis as unknown as {
prisma?: PrismaClient
}
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma
}内存 attempt store
src/lib/xiaoying-oidc-attempts.ts:
type OidcLoginAttempt = {
nonce: string
codeVerifier: string
redirectTo: string
expiresAt: number
}
const attempts = new Map<string, OidcLoginAttempt>()
function pruneExpiredAttempts() {
const now = Date.now()
for (const [state, attempt] of attempts) {
if (attempt.expiresAt <= now) {
attempts.delete(state)
}
}
}
export async function saveOidcLoginAttempt(state: string, attempt: OidcLoginAttempt) {
pruneExpiredAttempts()
attempts.set(state, attempt)
}
export async function consumeOidcLoginAttempt(state: string) {
const attempt = attempts.get(state)
attempts.delete(state)
if (!attempt || attempt.expiresAt <= Date.now()) {
return null
}
return attempt
}登录工具函数
src/lib/xiaoying-oidc.ts:
import crypto from "node:crypto"
import { createRemoteJWKSet, jwtVerify } from "jose"
export type XiaoYingDiscovery = {
issuer: string
authorization_endpoint: string
token_endpoint: string
jwks_uri: string
userinfo_endpoint: string
}
export type XiaoYingUserinfo = {
sub: string
nickname?: string | null
picture?: string | null
}
export const issuer = requiredEnv("XIAOYING_OIDC_ISSUER")
export const clientId = requiredEnv("XIAOYING_OIDC_CLIENT_ID")
const clientSecret = requiredEnv("XIAOYING_OIDC_CLIENT_SECRET")
const appOrigin = requiredEnv("NEXT_PUBLIC_APP_ORIGIN").replace(/\/+$/, "")
export const redirectUri = `${appOrigin}/api/auth/xiaoying/callback`
let discoveryCache: XiaoYingDiscovery | undefined
function requiredEnv(key: string) {
const value = process.env[key]
if (!value) throw new Error(`${key} is required`)
return value
}
export function randomBase64Url(bytes = 32) {
return crypto.randomBytes(bytes).toString("base64url")
}
export function pkceChallenge(verifier: string) {
return crypto.createHash("sha256").update(verifier).digest("base64url")
}
export async function getDiscovery() {
if (discoveryCache) return discoveryCache
const response = await fetch(`${issuer}/.well-known/openid-configuration`, {
cache: "no-store",
})
if (!response.ok) {
throw new Error(`xiaoying_discovery_failed:${response.status}`)
}
const discovery = await response.json() as XiaoYingDiscovery
if (discovery.issuer !== issuer) {
throw new Error("xiaoying_issuer_mismatch")
}
discoveryCache = discovery
return discovery
}
export async function exchangeCodeForToken(input: {
code: string
codeVerifier: string
discovery: XiaoYingDiscovery
}) {
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()
if (!response.ok) {
throw new Error(`xiaoying_token_failed:${response.status}:${JSON.stringify(json)}`)
}
return json as {
access_token: string
id_token: string
token_type: "Bearer"
expires_in?: number
scope?: string
}
}
export async function verifyXiaoYingIdToken(input: {
idToken: string
nonce: string
discovery: XiaoYingDiscovery
}) {
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("xiaoying_nonce_mismatch")
}
if (typeof payload.sub !== "string" || payload.sub.length === 0) {
throw new Error("xiaoying_sub_missing")
}
return payload as typeof payload & {
iss: string
sub: string
}
}
export async function fetchXiaoYingUserinfo(input: {
accessToken: string
expectedSub: string
discovery: XiaoYingDiscovery
}) {
const response = await fetch(input.discovery.userinfo_endpoint, {
headers: {
authorization: `Bearer ${input.accessToken}`,
},
})
if (!response.ok) return null
const userinfo = await response.json() as XiaoYingUserinfo
if (userinfo.sub !== input.expectedSub) {
throw new Error("xiaoying_userinfo_sub_mismatch")
}
return userinfo
}发起登录
src/app/api/auth/xiaoying/start/route.ts:
import { NextRequest, NextResponse } from "next/server"
import { saveOidcLoginAttempt } from "@/lib/xiaoying-oidc-attempts"
import {
clientId,
getDiscovery,
pkceChallenge,
randomBase64Url,
redirectUri,
} from "@/lib/xiaoying-oidc"
export async function GET(req: NextRequest) {
const discovery = await getDiscovery()
const state = randomBase64Url(32)
const nonce = randomBase64Url(32)
const codeVerifier = randomBase64Url(64)
const redirectTo = req.nextUrl.searchParams.get("redirectTo") ?? "/"
await saveOidcLoginAttempt(state, {
nonce,
codeVerifier,
redirectTo,
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")
return NextResponse.redirect(url)
}页面中可以直接链接到:
<a href="/api/auth/xiaoying/start">使用小应登录</a>第三方页面应在小应 App WebView 内展示并触发该入口。
处理回调
src/app/api/auth/xiaoying/callback/route.ts:
import { NextRequest, NextResponse } from "next/server"
import { prisma } from "@/lib/prisma"
import { consumeOidcLoginAttempt } from "@/lib/xiaoying-oidc-attempts"
import {
exchangeCodeForToken,
fetchXiaoYingUserinfo,
getDiscovery,
verifyXiaoYingIdToken,
} from "@/lib/xiaoying-oidc"
type LocalUser = {
id: string
}
async function createLocalUserFromXiaoYing(input: {
nickname?: string | null
picture?: string | null
}): Promise<LocalUser> {
// 接入方在这里创建自己的本地用户。
// 如果系统已有账号体系,请替换为现有用户创建逻辑。
throw new Error("createLocalUserFromXiaoYing is not implemented")
}
async function updateLocalUserFromXiaoYing(input: {
localUserId: string
nickname?: string | null
picture?: string | null
}): Promise<LocalUser> {
// 接入方在这里更新自己的本地用户展示资料。
// 如果不希望同步昵称或头像,可以只返回已有用户。
throw new Error("updateLocalUserFromXiaoYing is not implemented")
}
async function createLocalSessionResponse(req: NextRequest, input: {
localUserId: string
redirectTo: string
}) {
// 接入方在这里复用自己的登录态创建方式。
// 例如写入已有 session cookie,或者调用现有认证库。
return NextResponse.redirect(new URL(input.redirectTo, req.url))
}
export async function GET(req: NextRequest) {
const state = req.nextUrl.searchParams.get("state") ?? ""
const code = req.nextUrl.searchParams.get("code")
const error = req.nextUrl.searchParams.get("error")
const attempt = await consumeOidcLoginAttempt(state)
if (!attempt) {
return NextResponse.redirect(new URL("/login?error=invalid_state", req.url))
}
if (error) {
return NextResponse.redirect(new URL(`/login?error=${encodeURIComponent(error)}`, req.url))
}
if (!code) {
return NextResponse.redirect(new URL("/login?error=missing_code", req.url))
}
const discovery = await getDiscovery()
const tokenSet = await exchangeCodeForToken({
code,
codeVerifier: attempt.codeVerifier,
discovery,
})
const claims = await verifyXiaoYingIdToken({
idToken: tokenSet.id_token,
nonce: attempt.nonce,
discovery,
})
const userinfo = await fetchXiaoYingUserinfo({
accessToken: tokenSet.access_token,
expectedSub: claims.sub,
discovery,
})
const identity = await prisma.externalIdentity.findUnique({
where: {
provider_issuer_subject: {
provider: "xiaoying",
issuer: claims.iss,
subject: claims.sub,
},
},
})
const localUser = identity
? await updateLocalUserFromXiaoYing({
localUserId: identity.localUserId,
nickname: userinfo?.nickname,
picture: userinfo?.picture,
})
: await createLocalUserFromXiaoYing({
nickname: userinfo?.nickname,
picture: userinfo?.picture,
})
if (!identity) {
await prisma.externalIdentity.create({
data: {
provider: "xiaoying",
issuer: claims.iss,
subject: claims.sub,
localUserId: localUser.id,
},
})
}
const redirectTo = attempt.redirectTo?.startsWith("/") ? attempt.redirectTo : "/"
return createLocalSessionResponse(req, {
localUserId: localUser.id,
redirectTo,
})
}接入时需要替换的函数
上面的代码刻意只留下三个接入点:
| 函数 | 需要接入方实现的内容 |
|---|---|
createLocalUserFromXiaoYing | 根据小应 userinfo 创建第三方自己的本地用户。 |
updateLocalUserFromXiaoYing | 根据小应 userinfo 更新或读取已有本地用户。 |
createLocalSessionResponse | 使用第三方已有认证系统创建本地登录态并返回响应。 |
如果第三方系统已经有用户表和登录态,这三个函数通常就是对现有服务的薄封装。
方案 B:替换为共享 attempt store
如果第三方使用 serverless 或多实例部署,可以把内存 attempt store 替换为 Prisma/PostgreSQL。此时再新增短期表:
model OidcLoginAttempt {
state String @id
nonce String
codeVerifier String
redirectTo String?
expiresAt DateTime
consumedAt DateTime?
createdAt DateTime @default(now())
}并把 src/lib/xiaoying-oidc-attempts.ts 替换为:
import { prisma } from "@/lib/prisma"
export async function saveOidcLoginAttempt(state: string, attempt: {
nonce: string
codeVerifier: string
redirectTo: string
expiresAt: number
}) {
await prisma.oidcLoginAttempt.create({
data: {
state,
nonce: attempt.nonce,
codeVerifier: attempt.codeVerifier,
redirectTo: attempt.redirectTo,
expiresAt: new Date(attempt.expiresAt),
},
})
}
export async function consumeOidcLoginAttempt(state: string) {
const attempt = await prisma.oidcLoginAttempt.findUnique({
where: { state },
})
if (!attempt || attempt.consumedAt || attempt.expiresAt <= new Date()) {
return null
}
await prisma.oidcLoginAttempt.update({
where: { state },
data: { consumedAt: new Date() },
})
return {
nonce: attempt.nonce,
codeVerifier: attempt.codeVerifier,
redirectTo: attempt.redirectTo ?? "/",
expiresAt: attempt.expiresAt.getTime(),
}
}这个替换不会影响发起登录和回调代码。
生产化检查
NEXT_PUBLIC_APP_ORIGIN必须和小应官方登记的redirect_uri所属 origin 一致。XIAOYING_OIDC_CLIENT_SECRET只能存在服务端环境变量或密钥系统中。- 内存 attempt 只适合单实例 standalone 部署;serverless 或多实例部署应使用方案 B。
- 如果使用方案 B,
OidcLoginAttempt需要定期清理过期数据。 ExternalIdentity必须保留provider + issuer + subject唯一约束。- 回调处理失败时不得创建本地 session。
- 不要落库保存 authorization code、
id_token、小应账号快捷登录access_token或client_secret。 - 不要在日志、错误上报或浏览器端暴露 authorization code、
id_token、小应账号快捷登录access_token或client_secret。