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。预览域名和正式域名应分别核对,不要让回调落到错误环境。
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 生产登录仍待逐站验收。
最小接入示例
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*'] };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).*)'],
};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;
}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;
}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;失败回调进入应用自己的错误页,后续跳转仅使用受控站内路径。