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

账号快捷登录

更新于 2026-05-25

Next.js TypeScript 案例

使用 Next.js App Router、TypeScript、Prisma 和 PostgreSQL 的生产化接入骨架。

本文给出一个尽量精简的 Next.js App Router + TypeScript + Prisma + PostgreSQL 接入骨架。默认方案只建议新增一类长期数据:

  • 外部身份绑定:用于保存 provider + issuer + subject 到第三方本地用户的映射。

小应账号快捷登录的临时 attempt 只保存 statenoncecode_verifier,有效期很短。除非第三方部署在 Vercel 等 serverless 平台、不是 standalone 进程,或者是多实例部署,否则默认使用内存 Map 即可。

本地用户表、业务账号创建、session 创建和退出登录通常已经存在于第三方系统中,本文用函数占位,不要求照搬新的账号体系。

相关文档:

技术栈

推荐使用:

  • Next.js App Router
  • TypeScript
  • Prisma
  • PostgreSQL
  • jose

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

npm install @prisma/client jose
npm install -D prisma
pnpm add @prisma/client jose
pnpm add -D prisma
yarn 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_oidc
pnpm exec prisma migrate dev --name add_xiaoying_oidc
yarn prisma migrate dev --name add_xiaoying_oidc

Prisma 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_tokenclient_secret
  • 不要在日志、错误上报或浏览器端暴露 authorization code、id_token、小应账号快捷登录 access_tokenclient_secret