Next.js 接入指南

用 Next.js 接入 TongId Hosted 登录与 PKCE

优先用 SDK 固定路由注册 Next.js Proxy 或 Middleware;手写 OAuth callback helper 保留作高级用法。浏览器 public client 继续使用 PKCE。

01 / 05

为什么选择 Hosted 登录

TongId 托管登录和邮箱验证交互。你的 Next.js 应用把用户带到 Hosted 登录页,接收授权回调后,再在自己的域名建立会话;应用不需要自行实现登录表单。

回调只携带一次性 code 和原始 state。托管路由在服务端验证交易并兑换 code,核对签名 access_token 与 session_cookie 同值后写入应用域会话;Cookie 仅编码一次,读取后原样作为 Bearer 转发。新的保密 Web 流程不是 PKCE。浏览器或 Chrome public client 仍须使用 PKCE。

02 / 05

在 TongId 控制台准备应用

先在 TongId 控制台登记接入应用及其正式域名。托管路由在应用自己的域名提供固定回调 /_tongid/auth/tongid/callback,其他固定入口为 /_tongid/api/auth/start、/_tongid/api/auth/logout、/_tongid/api/auth/session、/_tongid/api/checkout。前缀无语言段,不支持改名或 basePath 挂载。

托管路由配置需要 applicationId、secretKey、siteUrl;Node 可从 process.env 惰性读取,Cloudflare/OpenNext 从异步 runtime binding 工厂读取。baseURL 可选,默认 https://tongid.dev。预览域名和正式域名应分别核对,不要让回调落到错误环境。

03 / 05

发起授权

Next.js 16 Node 在 proxy.ts 使用 createTongIdProxy;Next.js 15.5 与当前 Cloudflare/OpenNext Edge 在 middleware.ts 使用 createTongIdMiddleware。已有 i18n Middleware 时,将原静态 matcher 与 /_tongid/:path* 合并,原处理函数作为 fallback。Next 15.5 工程的 moduleResolution 需设为 bundler。

页面用 @tongid/sdk/routes 的 tongIdLoginPath 生成登录链接。退出使用 tongIdLogoutPath 的同源 POST 表单,Origin 须与 siteUrl origin 一致;不能用 GET 退出。下方保留手写 start/callback helper 作为高级接入,旧默认 callback 路径不会因托管入口全局改变。

浏览器扩展等 public client 不持有应用密钥。先为应用配置允许的完整回调 URL,使用 generatePkcePair 和 buildAuthorizeUrl 创建 challenge、state;保留 verifier 与 state,回调后先用 verifyOAuthState 核对,再以 code_verifier 向 /api/v1/oauth/token 兑换一次性 code。

@tongid/sdk/browser 没有浏览器端 code 兑换 helper;下面示例使用服务端已支持的 public-client HTTP 端点。运行环境须能访问该端点;普通网页若无跨域权限,应由自己的应用服务端执行这次 HTTP 请求,不传 secret key。

04 / 05

处理回调

托管回调失败时重定向到默认返回页并带 auth=failed;切换前已开始但未完成的旧 OAuth 交易须让用户重新登录。会话上游故障返回 503 / UPSTREAM_UNAVAILABLE,不能当作匿名成功。

高级手写方案在 /auth/tongid/callback 的服务端路由把 code、state 和 expectedState 交给 handleOAuthCallback;校验或兑换失败时,跳转到应用自己的登录错误页。

成功后调用 applyOAuthResultCookies,把兑换结果中的会话写成应用域名下的安全 Cookie,并清理 OAuth state Cookie。登录后的跳转目标只接受受控的站内路径;Cookie 写入失败也应回到应用自己的错误页。

handleOAuthCallback 不接收 PKCE verifier;这个 helper 仅用于此处持有 TONGID_SECRET_KEY 的服务端应用流程。

05 / 05

密钥与发布检查

TONGID_SECRET_KEY 仅在服务端环境配置和读取。包含 secretKey 的 SDK 客户端只能在服务端路由中创建或使用,不要传入 Client Component、浏览器脚本或公开构建变量。

上线前核对正式 HTTPS 域名与控制台记录、/_tongid/auth/tongid/callback 路由、同源 POST、失败状态与 public client 的 PKCE。结账只在 checkout.products 白名单配置后开放;业务 API 仍须自行鉴权。已验证 Next 16.3.3 Node Proxy/Middleware 与 Next 15.5.22 Middleware 的本地生产构建和 HTTP fixture,真实 Hosted 生产登录仍待逐站验收。

最小接入示例

推荐:Next 16 Node 注册 Proxy(proxy.ts)
import { createTongIdProxy } from '@tongid/sdk/next';

export const proxy = createTongIdProxy(() => ({
  applicationId: process.env.TONGID_APPLICATION_ID!,
  secretKey: process.env.TONGID_SECRET_KEY!,
  siteUrl: process.env.APP_URL!,
  auth: { defaultReturnPath: '/', allowedReturnPaths: ['/account'] },
}));
export const config = { matcher: ['/_tongid/:path*'] };
推荐:Next 15.5 Cloudflare 注册 Middleware(middleware.ts)
import { getCloudflareContext } from '@opennextjs/cloudflare';
import { createTongIdMiddleware } from '@tongid/sdk/next';
import { existingMiddleware } from './lib/existing-middleware'; // 应用原 i18n 处理函数

export const middleware = createTongIdMiddleware(async () => {
  const { env } = await getCloudflareContext();
  return {
    applicationId: env.TONGID_APPLICATION_ID,
    secretKey: env.TONGID_SECRET_KEY,
    siteUrl: env.APP_URL,
  };
}, existingMiddleware);
export const config = {
  matcher: ['/_tongid/:path*', '/((?!_next/static|_next/image|favicon.ico).*)'],
};
高级手写方案:发起授权(app/api/auth/start/route.ts)
import { NextResponse } from 'next/server';
import { createTongIdClient } from '@tongid/sdk';

const client = createTongIdClient({
  baseURL: 'https://tongid.dev',
  applicationId: process.env.TONGID_APPLICATION_ID!,
  secretKey: process.env.TONGID_SECRET_KEY!, // 仅服务端
});

export async function GET() {
  const { url, state } = await client.auth.getAuthorizeUrlAsync({ kind: 'login', next: '/account' });
  const response = NextResponse.redirect(url);
  response.cookies.set('myapp.oauth.state', state, {
    httpOnly: true, sameSite: 'lax', secure: true, path: '/', maxAge: 600,
  });
  return response;
}
高级手写方案:校验回调并写入会话(app/auth/tongid/callback/route.ts)
import { NextRequest, NextResponse } from 'next/server';
import { applyOAuthResultCookies, createTongIdClient, handleOAuthCallback } from '@tongid/sdk';

const STATE_COOKIE = 'myapp.oauth.state';
const client = createTongIdClient({
  baseURL: 'https://tongid.dev',
  applicationId: process.env.TONGID_APPLICATION_ID!,
  secretKey: process.env.TONGID_SECRET_KEY!, // 仅服务端
});

export async function GET(request: NextRequest) {
  const result = await handleOAuthCallback(client, {
    applicationId: process.env.TONGID_APPLICATION_ID!,
    code: request.nextUrl.searchParams.get('code'),
    state: request.nextUrl.searchParams.get('state'),
    expectedState: request.cookies.get(STATE_COOKIE)?.value,
  });
  const errorUrl = new URL('/login/?error=handoff_failed', request.url);
  if (!result.ok) return NextResponse.redirect(errorUrl);

  const requestedNext = request.nextUrl.searchParams.get('next');
  const next = requestedNext === '/account' ? '/account' : '/';
  const response = NextResponse.redirect(new URL(next, request.url));
  if (!applyOAuthResultCookies(response, {
    applicationId: process.env.TONGID_APPLICATION_ID!,
    accessToken: result.exchange.access_token,
    sessionCookie: result.exchange.session_cookie,
    expiresIn: result.exchange.expires_in ?? 0,
    oauthStateCookieName: STATE_COOKIE,
  })) return NextResponse.redirect(errorUrl);
  return response;
}
public client:创建 PKCE、核对 state 并兑换 code
import { buildAuthorizeUrl, generatePkcePair, verifyOAuthState } from '@tongid/sdk/browser';

const applicationId = 'app_xxx';
const redirect = 'https://my-app.com/auth/tongid/callback'; // 须在应用回调白名单内
const STORAGE_KEY = 'myapp.oauth.pkce';

export async function startPublicLogin() {
  const { codeVerifier, codeChallenge } = await generatePkcePair();
  const { url, state } = buildAuthorizeUrl({
    baseURL: 'https://tongid.dev', applicationId, redirect, codeChallenge,
  });
  sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ state, codeVerifier }));
  window.location.assign(url);
}

export async function finishPublicLogin() {
  const saved = sessionStorage.getItem(STORAGE_KEY);
  if (!saved) throw new Error('Missing PKCE state');
  const { state, codeVerifier } = JSON.parse(saved) as { state: string; codeVerifier: string };
  const params = new URL(window.location.href).searchParams;
  const returnedState = params.get('state');
  const code = params.get('code');
  if (!verifyOAuthState(returnedState, state) || !returnedState || !code) {
    throw new Error('Invalid OAuth callback');
  }
  sessionStorage.removeItem(STORAGE_KEY);
  const response = await fetch('https://tongid.dev/api/v1/oauth/token', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
      'x-tongid-application-id': applicationId,
    },
    body: new URLSearchParams({
      grant_type: 'authorization_code', code, state: returnedState,
      code_verifier: codeVerifier,
    }),
  });
  if (!response.ok) throw new Error('Code exchange failed');
  return response.json(); // { data: { access_token, expires_in, ... }, status, error }
}

发布前核对

上线检查清单

  • 应用正式域名与 TongId 控制台登记的回调域名一致,/_tongid/auth/tongid/callback 可在该域名访问。
  • 正式站点使用 HTTPS;state 保存在短时安全 Cookie,回调时与原值比较。
  • public client 已启用 PKCE,code verifier 不暴露给其他客户端。
  • 登录后 next 只允许明确列出的站内路径;授权 code 只兑换一次。
  • TONGID_SECRET_KEY 仅在服务端保存,未进入 Client Component 或公开环境变量。
  • 成功回调安全写入会话 Cookie;失败回调进入应用自己的错误页,后续跳转仅使用受控站内路径。