Base Account / Integration

账号系统接入文档

按能力模块拆分的接入规范,供产品工程和 AI Agent 按需获取。

Issuerhttps://user.stringzhao.lifeAudiencebase-account-clientJWKShttps://user.stringzhao.life/.well-known/jwks.jsonDocv2026-03-06.3

API Contract

仅复制认证系统文档

推荐使用 CLI 完成服务注册和管理操作:npm install -g @stringzhao/base-account-cli。 注册服务:ba admin services create --origin <url>,无需访问 Admin Console。

GET/authorize

统一授权入口。后端基于 return_to 的 origin 识别服务,未登录跳转登录页,已登录则按 consent 状态决定是否直接回跳。可选参数 prompt=select_account:即使已授权,也强制显示账号选择界面(用于多账号切换场景)。

Auth: none

展开查看请求/响应示例

Request

?return_to=https%3A%2F%2Fai-todo.stringzhao.life%2Fauth%2Fcallback&state=opaque-state
# 账号切换模式:
?return_to=...&state=...&prompt=select_account

Response

302 -> /login?... 或 302 -> return_to?authorized=1&state=opaque-state

Errors

  • 400 invalid_authorize_request
  • 400 invalid_return_to
  • 400 invalid_service
  • 400 invalid_state
POST/api/auth/send-code

发送邮箱验证码,返回 requestId。

Auth: none

展开查看请求/响应示例

Request

{
  "email": "user@example.com"
}

Response

{
  "success": true,
  "requestId": "8e8531eb-5ca9-4b89-a4a5-57bbe5cc526e"
}

Errors

  • 400 invalid_input
  • 429 rate_limited
  • 502 email_delivery_failed
POST/api/auth/verify-code

校验验证码并签发 access/refresh token(同时写入 cookie)。

Auth: none

展开查看请求/响应示例

Request

{
  "email": "user@example.com",
  "code": "123456"
}

Response

{
  "accessToken": "<jwt>",
  "refreshToken": "<opaque>",
  "user": {
    "id": "usr_xxx",
    "email": "user@example.com",
    "status": "ACTIVE"
  }
}

Errors

  • 400 invalid_code
  • 429 too_many_attempts
  • 403 account_disabled
POST/api/auth/authorize/approve

用户在首次授权页点击同意后写入 consent 记录并返回回跳地址。

Auth: access_token

展开查看请求/响应示例

Request

{
  "return_to": "https://user.stringzhao.life/app",
  "state": "opaque-state"
}

Response

{
  "success": true,
  "redirectTo": "https://user.stringzhao.life/app?authorized=1&state=opaque-state"
}

Errors

  • 401 missing_access_token
  • 400 invalid_input
  • 400 invalid_return_to
  • 400 invalid_service
POST/api/auth/refresh

刷新 access token 和 refresh token。

Auth: refresh_token

展开查看请求/响应示例

Response

{
  "accessToken": "<new-jwt>",
  "refreshToken": "<new-opaque>",
  "expiresIn": 900
}

Errors

  • 401 invalid_refresh_token
  • 400 missing_refresh_token
POST/api/auth/logout

注销当前会话并清理 cookie。

Auth: refresh_token

展开查看请求/响应示例

Response

{
  "success": true
}

Errors

  • 200 幂等,refresh token 失效也可安全调用
GET/api/auth/me

获取当前 access token 对应用户信息。

Auth: access_token

展开查看请求/响应示例

Response

{
  "id": "usr_xxx",
  "email": "user@example.com",
  "status": "ACTIVE",
  "createdAt": "2026-03-03T07:00:00.000Z",
  "updatedAt": "2026-03-03T07:00:00.000Z",
  "lastLoginAt": "2026-03-03T07:10:00.000Z"
}

Errors

  • 401 missing_access_token
  • 401 invalid_access_token
GET/.well-known/jwks.json

下游服务用于验证 JWT 的公钥集合。

Auth: none

展开查看请求/响应示例

Response

{
  "keys": [
    {
      "kty": "RSA",
      "kid": "auth-key-1",
      "alg": "RS256"
    }
  ]
}

Errors

  • 必须公网可访问,且与 AUTH_ISSUER 保持同源策略

Integration Templates

Node / Express 鉴权中间件

Node 20+ / Express
展开查看模板代码
import express from "express";
import { createRemoteJwksVerifier } from "@stringzhao/auth-sdk";

const app = express();

const verifier = createRemoteJwksVerifier({
  jwksUrl: process.env.AUTH_JWKS_URL!,
  config: {
    issuer: process.env.AUTH_ISSUER!,
    audience: process.env.AUTH_AUDIENCE!
  }
});

async function authGuard(req, res, next) {
  try {
    const raw = req.headers.authorization ?? "";
    const token = raw.startsWith("Bearer ") ? raw.slice(7) : "";
    if (!token) {
      return res.status(401).json({ error: "missing_access_token" });
    }

    const user = await verifier.verifyAccessToken(token);
    req.user = user;
    next();
  } catch (error) {
    return res.status(401).json({
      error: "invalid_access_token",
      message: error instanceof Error ? error.message : "unknown"
    });
  }
}

app.get("/api/private", authGuard, (req, res) => {
  res.json({ ok: true, user: req.user });
});

Next.js Route Handler 保护接口

Next.js App Router
展开查看模板代码
import { NextResponse } from "next/server";
import { createRemoteJwksVerifier } from "@stringzhao/auth-sdk";

const verifier = createRemoteJwksVerifier({
  jwksUrl: process.env.AUTH_JWKS_URL!,
  config: {
    issuer: process.env.AUTH_ISSUER!,
    audience: process.env.AUTH_AUDIENCE!
  }
});

export async function GET(request: Request) {
  const raw = request.headers.get("authorization") ?? "";
  const token = raw.startsWith("Bearer ") ? raw.slice(7) : "";
  if (!token) {
    return NextResponse.json(
      { error: "missing_access_token" },
      { status: 401 }
    );
  }

  try {
    const user = await verifier.verifyAccessToken(token);
    return NextResponse.json({ ok: true, user });
  } catch (error) {
    return NextResponse.json(
      {
        error: "invalid_access_token",
        message: error instanceof Error ? error.message : "unknown"
      },
      { status: 401 }
    );
  }
}

前端最小登录流程

Browser / SPA
展开查看模板代码
// 1) 发送验证码
await fetch("https://user.stringzhao.life/api/auth/send-code", {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ email: "user@example.com" }),
  credentials: "include"
});

// 2) 校验验证码
await fetch("https://user.stringzhao.life/api/auth/verify-code", {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ email: "user@example.com", code: "123456" }),
  credentials: "include"
});

// 3) 获取当前用户(/me 直接返回 user DTO)
const me = await fetch("https://user.stringzhao.life/api/auth/me", {
  credentials: "include"
}).then((r) => r.json());

console.log(me.id, me.email);

外部服务统一授权入口

Browser / Web App
展开查看模板代码
const state = crypto.randomUUID();
const returnTo = encodeURIComponent("https://user.stringzhao.life/app");

window.location.href =
  "https://user.stringzhao.life/authorize" +
  "?return_to=" + returnTo +
  "&state=" + encodeURIComponent(state);

回跳校验 + 获取用户态(简易版)

Browser / Web App
展开查看模板代码
// 发起授权前保存 state
const state = crypto.randomUUID();
sessionStorage.setItem("auth_state", state);

// ...跳转 /authorize 后,用户会回到 return_to
const callback = new URL(window.location.href);
const returnedState = callback.searchParams.get("state");
const authorized = callback.searchParams.get("authorized");

if (returnedState !== sessionStorage.getItem("auth_state")) {
  throw new Error("state mismatch");
}
if (authorized !== "1") {
  throw new Error("authorization not completed");
}

// 注意:此方式直接依赖共享 access_token cookie,
// 跨应用切换账号时会导致身份污染。
// 生产环境推荐使用下方的 "Gateway Session 模式"。
const me = await fetch("https://user.stringzhao.life/api/auth/me", {
  credentials: "include"
}).then((r) => r.json());

console.log("authorized user:", me.email);

Next.js Gateway Session 模式(推荐)

Next.js App Router
展开查看模板代码
// ============================================================
// 推荐模式:每个应用创建自己的 gateway session cookie,
// 不直接依赖共享的 access_token cookie 作为日常登录态。
//
// 原因:access_token cookie 在 .stringzhao.life 域共享,
// 任一子域应用的登录/切换会覆盖其他应用的登录态。
// ============================================================

// --- 1. lib/auth-gateway-session.ts ---
// HMAC-SHA256 签名的 cookie,存储 email + 过期时间

import crypto from "node:crypto";
import { NextResponse } from "next/server";

const COOKIE_NAME = "my_app_gateway_session"; // 每个应用使用不同名称

function secret(): string {
  return process.env.AUTH_GATEWAY_SESSION_SECRET || "dev-secret";
}

function sign(payload: string): string {
  return crypto.createHmac("sha256", secret())
    .update(payload).digest("base64url");
}

export function createSession(email: string, ttl = 43_200): string {
  const now = Date.now();
  const data = JSON.stringify({
    email: email.trim().toLowerCase(),
    issuedAt: now,
    expiresAt: now + ttl * 1000,
  });
  const encoded = Buffer.from(data).toString("base64url");
  return encoded + "." + sign(encoded);
}

export function verifySession(raw: string) {
  const [encoded, sig] = raw.split(".", 2);
  if (!encoded || !sig) return null;
  const expected = sign(encoded);
  try {
    if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig)))
      return null;
  } catch { return null; }
  const parsed = JSON.parse(
    Buffer.from(encoded, "base64url").toString()
  );
  if (Date.now() > parsed.expiresAt) return null;
  return { email: parsed.email };
}

export function applySessionCookie(res: NextResponse, value: string) {
  res.cookies.set({
    name: COOKIE_NAME, value, path: "/",
    httpOnly: true, secure: true, sameSite: "lax",
    maxAge: 43_200,
  });
}

// --- 2. app/api/auth/session/finalize/route.ts ---
// 回跳后调用此接口:读取共享 access_token → 验签 JWT → 创建本地会话

import { jwtVerify } from "jose";

export async function POST(request: Request) {
  // 从 cookie 中读取共享 access_token(一次性使用)
  const cookies = request.headers.get("cookie") || "";
  const match = cookies.match(/access_token=([^;]+)/);
  const accessToken = match ? decodeURIComponent(match[1]) : "";

  if (!accessToken) {
    return Response.json({ error: "missing_access_token" }, { status: 401 });
  }

  // JWT 验签
  const { payload } = await jwtVerify(accessToken, jwksResolver, {
    issuer: process.env.AUTH_ISSUER,
    audience: process.env.AUTH_AUDIENCE,
  });

  // 创建应用自有的 gateway session cookie
  const res = NextResponse.json({ ok: true, email: payload.email });
  applySessionCookie(res, createSession(payload.email as string));
  return res;
}

// --- 3. 中间件或页面:检查 gateway session ---

export async function middleware(request: NextRequest) {
  const raw = request.cookies.get(COOKIE_NAME)?.value;
  const session = raw ? verifySession(raw) : null;
  if (!session) {
    return NextResponse.redirect(new URL("/auth/start", request.url));
  }
  return NextResponse.next();
}

账号切换(多账号场景)

Browser / Web App
展开查看模板代码
// ============================================================
// 账号切换:清除本地会话后跳转 /authorize?prompt=select_account
// 用户将在账号中心看到所有历史登录过的邮箱,可直接选择或登录新账号。
// 注意:不要调用 base-account 的 /api/auth/logout,
// 保留 base-account 登录态才能显示当前账号。
// ============================================================

// --- 方式一:纯前端(适用于无 gateway session 的 SPA)---

function switchAccount() {
  const state = crypto.randomUUID();
  sessionStorage.setItem("auth_state", state);

  const returnTo = encodeURIComponent(window.location.origin + "/auth/callback");
  window.location.href =
    "https://user.stringzhao.life/authorize" +
    "?return_to=" + returnTo +
    "&state=" + encodeURIComponent(state) +
    "&prompt=select_account";
}

// --- 方式二:服务端路由(适用于有 gateway session 的 Next.js 应用)---
// 创建 /api/auth/switch-account 路由,清除本地 gateway session cookie
// 后重定向到 /authorize?...&prompt=select_account。
// 前端按钮直接 window.location.href = "/api/auth/switch-account"。

Deployment Checklist

  • AUTH_ISSUER 与账号服务域名保持一致(当前: https://user.stringzhao.life)。
  • AUTH_AUDIENCE 在账号服务和下游服务严格一致(当前: base-account-client)。
  • AUTH_JWKS_URL 配置为 https://user.stringzhao.life/.well-known/jwks.json。
  • 新接入服务需要先登记并启用 origin。推荐使用 CLI:ba admin services create --origin <url>(也可在 Admin Console -> Services 手动操作)。
  • /authorize 的 service 参数已弃用(兼容保留,但后端不再依赖该参数判定服务)。
  • AUTH_ALLOWED_RETURN_ORIGINS 建议至少包含 http://localhost:3000, https://user.stringzhao.life, https://stringzhao.life。
  • AUTH_ALLOWED_RETURN_SUFFIXES 建议配置为 .stringzhao.life,.vercel.app(一次覆盖你全部 Vercel 服务)。
  • 外部服务统一从 /authorize 进入登录授权流程,不直接拼接 /login。
  • 业务接口对 401/403/429 做显式处理,不把鉴权失败当系统异常。
  • access_token / refresh_token cookie 在 .stringzhao.life 域共享,任一子域的登录/切换会覆盖所有子域的登录态。接入方应创建应用自有的 gateway session cookie(参考模板),避免跨应用账号污染。
  • 上线后至少做一次 send-code / verify-code / me 全链路回归。

External Integration Checklist

  • 授权入口统一改为 /authorize?return_to=<absolute_url>&state=<opaque_state>。
  • service 参数可传可不传(兼容保留),但不能再用于服务身份判定。
  • 发起授权前生成并持久化 state(建议 randomUUID + sessionStorage)。
  • 回跳后必须校验 authorized=1 且 returned state 与本地 state 完全一致。
  • 每个业务回跳域名(return_to origin)需先开通并启用。推荐使用 CLI:ba admin services create --origin <url>(也可在 /admin -> Services 手动操作)。
  • 回跳后在服务端读取共享 access_token cookie 并验签 JWT(避免前端 CORS),然后创建应用自有的 gateway session cookie 作为日常登录态。不建议直接依赖共享 access_token cookie(跨应用账号污染风险)。
  • 后端 JWT 验签配置保持一致:AUTH_ISSUER、AUTH_AUDIENCE、AUTH_JWKS_URL。
  • 业务侧显式处理 400 invalid_service / 400 invalid_return_to / 401 invalid_access_token。
  • 上线前至少完成首次授权、重复授权直跳、停用服务拦截、icon 展示回退四项回归。
  • 如需账号切换功能,跳转 /authorize 时附加 prompt=select_account 参数。已授权用户将看到账号选择界面,可选择当前账号、历史登录账号或登录新账号。

Troubleshooting

401 invalid_access_token

优先检查 issuer、audience 是否与账号服务完全一致。

429 rate_limited

验证码发送命中冷却窗口,前端按 retryAfterSeconds 控制重试。

502 email_delivery_failed

检查 RESEND_API_KEY、RESEND_FROM_EMAIL、Resend 域名验证状态。

400 invalid_return_to

检查 AUTH_ALLOWED_RETURN_ORIGINS / AUTH_ALLOWED_RETURN_SUFFIXES,确认 return_to 域名在放行范围内。

400 invalid_service

当前 return_to origin 尚未开通。推荐使用 CLI 注册:ba admin services create --origin <url>(也可在 Admin Console -> Services 手动登记并启用)。

回跳后 state mismatch

确认发起授权前本地持久化 state,并在回跳时严格比对 returned state。

跨应用账号被覆盖

access_token cookie 在 .stringzhao.life 域共享,任一子域的登录/切换会影响所有子域。解决方案:每个应用创建自己的 gateway session cookie(如 ai_todo_gateway_session),回跳后一次性读取 access_token 验签 JWT 建立本地会话,后续不再依赖共享 cookie。