SDK 接入中心

用 @tongid/sdk 接入登录、权限、产品与支付

从安装到生产上线,分别为 Next.js 服务端应用与 Chrome MV3 扩展说明当前可用的接入路径。

npm i @tongid/[email protected]pnpm add @tongid/[email protected]

选择接入路径

01 / 入门

安装与初始化

安装 0.2.0,分清服务端主入口、Next 托管入口与不持有密钥的浏览器子入口。

Next.jsChrome MV3

安装 @tongid/sdk 后,Next.js 服务端从主入口创建客户端;Chrome MV3 只从 @tongid/sdk/browser 导入 public-client 工具,不创建 TongIdClient。

托管路由要求 applicationId、secretKey 和应用 siteUrl;baseURL 可选,默认 https://tongid.dev。Secret 仅在服务端或 Worker binding。低层客户端的 app.callbackPath 仍用于手写 OAuth callback,不改变托管路由的固定路径。

相关 SDK 入口

  • getSharedTongIdClient
  • @tongid/sdk/browser
服务端共享客户端Next.js

lib/tongid.ts

import 'server-only';
import { getSharedTongIdClient } from '@tongid/sdk';

export const tongid = getSharedTongIdClient({
  baseURL: 'https://tongid.dev',
  applicationId: process.env.TONGID_APPLICATION_ID!,
  secretKey: process.env.TONGID_SECRET_KEY!,
  app: { siteUrl: process.env.APP_URL! },
});
扩展后台只导入浏览器入口Chrome MV3

src/background/tongid.ts

import { buildAuthorizeUrl, generatePkcePair, logoutWithSessionToken } from '@tongid/sdk/browser';

const applicationId = 'YOUR_APPLICATION_ID'; // 控制台中的公开应用 ID
const baseURL = 'https://tongid.dev';
export { applicationId, baseURL, buildAuthorizeUrl, generatePkcePair, logoutWithSessionToken };

最佳实践

  • applicationId 可以公开;TONGID_SECRET_KEY 仅在服务端环境变量中配置。
  • 托管路由在请求时惰性读取配置,部署前检查服务端变量或 Worker binding。
  • 将持有密钥的模块限制在服务端路由,扩展 manifest、Client Component 与前端构建变量不引用它。

常见错误

  • 将服务端主入口打包进 Chrome 或客户端代码会泄露应用密钥。

02 / 基础

登录与会话

Next.js 在自身域名完成 Hosted 回调;Chrome 使用 PKCE 与 public-client token 端点。

Next.jsChrome MV3

Next.js 推荐注册托管入口:Next 16.3.3 Node 使用 Proxy,Next 15.5.22 或当前 Cloudflare/OpenNext Edge 使用 Middleware。五个固定路径为 /_tongid/api/auth/start、/_tongid/auth/tongid/callback、/_tongid/api/auth/logout、/_tongid/api/auth/session、/_tongid/api/checkout,均不加语言前缀;不支持改名或 basePath 挂载。Next 15.5 工程须用 moduleResolution: bundler 解析子入口。

服务端必需 TONGID_APPLICATION_ID、TONGID_SECRET_KEY 与 APP_URL;可选 TONGID_URL 默认为 https://tongid.dev。Node 从 process.env 惰性读取,Cloudflare 在异步请求工厂读取 runtime binding。已有 i18n Middleware 时,静态 matcher 合并原规则与 /_tongid/:path*,原函数作为 fallback。

退出必须同源 POST,Origin 须与 APP_URL 一致。回调失败带 auth=failed,远端退出未确认带 auth=logout_unconfirmed;会话上游故障返回 503 / UPSTREAM_UNAVAILABLE,应显示重试提示。切换时旧在途 OAuth 需重新登录;根入口旧 callback 默认路径不会全局改变。

Chrome 用 chrome.identity.getRedirectURL 取得已登记的回调,generatePkcePair 与 buildAuthorizeUrl 发起登录,chrome.identity.launchWebAuthFlow 返回 code/state 后先调用 verifyOAuthState,再以 code_verifier 请求 /api/v1/oauth/token。access_token 是签名会话凭证;扩展后台原样持有并作为 Bearer 转发,不自行签名或截断。退出时调用 logoutWithSessionToken 并删除本地 Token;保存过旧裸会话 ID 的客户端须重新登录。

手写服务端 callback 的高级说明见 /guides/nextjs-oauth-pkce,其中保留 getAuthorizeUrlAsync、handleOAuthCallback 与 applyOAuthResultCookies。新保密 Web 流程由服务端 Secret 兑换 code,不宣称使用 PKCE;Chrome/browser public-client PKCE 不变。

相关 SDK 入口

  • createTongIdProxy
  • createTongIdMiddleware
  • tongIdLoginPath
  • tongIdLogoutPath
  • auth.getSession
  • logoutWithSessionToken
Next 16 Node 注册 ProxyNext.js

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!,
  baseURL: process.env.TONGID_URL ?? 'https://tongid.dev',
  auth: { defaultReturnPath: '/', allowedReturnPaths: ['/dashboard'] },
}));
export const config = { matcher: ['/_tongid/:path*'] };
Next 15.5 / Cloudflare 注册 MiddlewareNext.js

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).*)'],
};
站内登录与同源 POST 退出Next.js

app/account/page.tsx

import { tongIdLoginPath, tongIdLogoutPath } from '@tongid/sdk/routes';

export default function AccountLinks() {
  return <>
    <a href={tongIdLoginPath('/dashboard')}>登录</a>
    <form action={tongIdLogoutPath('/')} method="post">
      <button type="submit">退出</button>
    </form>
  </>;
}
扩展后台登录、读取会话与退出Chrome MV3

src/background/auth.ts

import { buildAuthorizeUrl, generatePkcePair, logoutWithSessionToken, verifyOAuthState } from '@tongid/sdk/browser';

const applicationId = 'YOUR_APPLICATION_ID';
const baseURL = 'https://tongid.dev';
// 后台启动即限制整个 storage.local;Token 持久保存,但 content scripts 不可读取。
const storageReady = chrome.storage.local.setAccessLevel({ accessLevel: 'TRUSTED_CONTEXTS' });

export async function login() {
  await storageReady;
  const redirect = chrome.identity.getRedirectURL('tongid'); // 完整地址须登记到应用回调白名单
  const { codeVerifier, codeChallenge } = await generatePkcePair();
  const { url, state } = buildAuthorizeUrl({ baseURL, applicationId, redirect, codeChallenge });
  const callback = await chrome.identity.launchWebAuthFlow({ url, interactive: true });
  if (!callback) throw new Error('登录未完成');
  const params = new URL(callback).searchParams;
  if (!verifyOAuthState(params.get('state'), state) || !params.get('code')) throw new Error('回调无效');
  const response = await fetch(baseURL + '/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: params.get('code')!, state, code_verifier: codeVerifier }),
  });
  if (!response.ok) throw new Error('登录兑换失败');
  const payload = await response.json() as { data?: { access_token?: string } };
  const token = payload.data?.access_token;
  if (!token) throw new Error('登录响应无会话');
  await chrome.storage.local.set({ tongidAccessToken: token }); // 仅扩展后台读取
}

export async function getSession() {
  await storageReady;
  const { tongidAccessToken } = await chrome.storage.local.get('tongidAccessToken');
  if (!tongidAccessToken) return null;
  const response = await fetch(baseURL + '/api/v1/auth/me', {
    headers: { Authorization: 'Bearer ' + tongidAccessToken, 'x-tongid-application-id': applicationId },
  });
  if (!response.ok) return null;
  return (await response.json()).data;
}

export async function logout() {
  await storageReady;
  const { tongidAccessToken: sessionToken } = await chrome.storage.local.get('tongidAccessToken');
  try {
    if (sessionToken) await logoutWithSessionToken({ applicationId, sessionToken });
  } finally {
    await chrome.storage.local.remove('tongidAccessToken');
  }
}

最佳实践

  • next 仅允许明确列出的站内路径,拒绝双斜杠和外站 URL。
  • state 与 PKCE verifier 属于单次登录上下文;code 只兑换一次。
  • 扩展后台先将 storage.local 设为 TRUSTED_CONTEXTS,再存取 Token;不传给 content script、页面 DOM 或网页 localStorage。
  • 失败时返回本应用的受控错误页;上游错误详情只写入脱敏服务端日志。

常见错误

  • 只检查回调 code 而不校验 state,或在 Chrome 中使用持密钥的服务端 callback helper。

03 / 基础

个人中心

从当前会话展示资料、订单入口与有效权益,并使用更新返回值刷新视图。

Next.jsChrome MV3

从 AuthSession.user 展示邮箱、名称与用户 ID,根据 canEditDisplayName 决定是否显示名称编辑;当前可编辑字段只有 displayName。

订单通过 orders.listMine 查询当前用户,服务端路径为 /api/v1/orders/me;有效权益读取 AuthSession.effective.entitlements。Chrome 读取自己的 Bearer 会话,敏感资料操作仍受 TongID 服务端鉴权。

相关 SDK 入口

  • auth.getSession
  • auth.updateMe
  • orders.listMine
  • AuthSession.effective.entitlements
读取当前用户资料和权益Next.js

app/account/page.tsx

import { headers } from 'next/headers';
import { redirect } from 'next/navigation';
import { tongid } from '@/lib/tongid';

export default async function AccountPage() {
  const cookieHeader = (await headers()).get('cookie') ?? '';
  const session = await tongid.auth.getSession({ cookieHeader });
  if (!session) redirect('/login');
  const { orders } = await tongid.orders.listMine({ cookieHeader });
  return <main>
    <h1>{session.user.displayName ?? session.user.email}</h1>
    <p>邮箱:{session.user.email}</p>
    <p>用户 ID:{session.user.id}</p>
    <p>有效权益:{session.effective?.entitlements.length ?? 0}</p>
    <p>订单:{orders.length}</p>
    {session.canEditDisplayName && <a href="/account/edit">编辑名称</a>}
  </main>;
}
用更新响应立即刷新个人资料服务端

app/account/actions.ts

'use server';

import { headers } from 'next/headers';
import { tongid } from '@/lib/tongid';

export async function updateProfile(displayName: string) {
  const cookieHeader = (await headers()).get('cookie') ?? '';
  const current = await tongid.auth.getSession({ cookieHeader });
  if (!current) throw new Error('请先登录');
  if (!current.canEditDisplayName) throw new Error('当前应用不允许修改名称');
  const updated = await tongid.auth.updateMe({ displayName }, { cookieHeader });
  return updated; // 新 AuthSession 直接用于视图更新
}

最佳实践

  • 服务端先确认会话,未登录立即跳转或返回 401。
  • “我的”订单由当前会话确定用户,不接收客户端提交的 userId。
  • 使用 updateMe 返回的新 AuthSession 立即更新视图,不假定默认缓存中的 getSession 已回源。
  • 资料或权益为空时呈现空状态,保留 TongID 返回的业务状态。

常见错误

  • 更新资料后立刻调用 getSession 并把默认 60 秒缓存结果当成已刷新的资料。

04 / 进阶

权限与角色

服务端使用有效权限作真实守卫,游客态与已登录用户分别处理。

Next.js服务端

已登录用户从 session.effective 或 auth.effective 读取角色、权限和权益;auth.effective 对应 /api/v1/applications/effective。游客态使用 applications.guestEffective,服务端管理角色时使用 applications.roles 查看继承后的权限。

权限使用稳定 slug 判断;显示名称只用于界面。前端隐藏按钮可改善体验,真正的数据读取或写入必须在服务端按 TongID 返回的权限再判断。

相关 SDK 入口

  • auth.effective
  • applications.guestEffective
  • applications.roles
在服务端保护报表Next.js

app/dashboard/page.tsx

import { headers } from 'next/headers';
import { notFound, redirect } from 'next/navigation';
import { tongIdLoginPath } from '@tongid/sdk/routes';
import { tongid } from '@/lib/tongid';

export default async function DashboardPage() {
  const cookieHeader = (await headers()).get('cookie') ?? '';
  const session = await tongid.auth.getSession({ cookieHeader });
  if (!session) redirect(tongIdLoginPath('/dashboard'));
  if (!session.effective?.permissions?.includes('reports.read')) notFound();
  return <main>报表</main>;
}
服务端区分游客与应用角色服务端

lib/tongid-permissions.ts

import { tongid } from '@/lib/tongid';

export async function readPermissionModels() {
  const guest = await tongid.applications.guestEffective();
  const { roles } = await tongid.applications.roles();
  return { guest, roles }; // 管理能力只在服务端使用
}

最佳实践

  • 所有敏感数据读写都在服务端入口检查 effective permissions。
  • 使用稳定的权限 slug,展示用的角色名称不参与鉴权。
  • 游客、已登录用户与工作空间管理员分别建立权限判断。

常见错误

  • 只根据前端按钮是否显示决定能否读取或修改数据。

05 / 进阶

产品与权益

用已配置的 productId 创建结账和兑换入口,以服务端返回的有效权益控制功能。

Next.jsChrome MV3服务端

当前 SDK 不提供商品列表方法;productId 来自 TongID 控制台或应用自己的服务端配置。服务端使用 checkout.getUrl 生成签名商品页链接,不接收浏览器传来的价格、角色或权益期限。

托管结账入口仅在 Proxy/Middleware 服务端选项中显式配置 checkout: { products: { pro: 已登记的商品ID } } 后开放;未配置返回 404,未知别名拒绝,不能用浏览器传入价格或商品 ID。已登录用户也可用 redemptions.getUrl 将自己的会话换成短时、一次性的兑换页链接;Chrome 使用 browser 子入口的 getRedemptionUrl。用户侧从 effective entitlements 读取权益,entitlements.list/create 仅供服务端管理。

相关 SDK 入口

  • checkout.getUrl
  • redemptions.getUrl
  • getRedemptionUrl
  • AuthSession.effective.entitlements
  • entitlements.list/create
托管商品入口只接受配置过的别名Next.js

app/pricing/page.tsx

import { tongIdCheckoutPath } from '@tongid/sdk/routes';

export default function PricingPage() {
  return <a href={tongIdCheckoutPath('pro')}>购买 Pro</a>;
}
扩展后台获取一次性兑换链接Chrome MV3

src/background/redemption.ts

import { getRedemptionUrl } from '@tongid/sdk/browser';

export async function openRedemption(applicationId: string) {
  const { tongidAccessToken: sessionToken } = await chrome.storage.local.get('tongidAccessToken');
  if (!sessionToken) throw new Error('请先登录');
  const url = await getRedemptionUrl({ applicationId, sessionToken });
  await chrome.tabs.create({ url }); // 链接短时且只能使用一次
}

最佳实践

  • productId 由受控配置提供;价格、角色和期限不信任浏览器输入。
  • 以 TongID 返回的权益状态为准,不自行猜测结账结果。
  • 兑换链接短时且一次性,不记录完整 URL 或其中的 token。

常见错误

  • 在浏览器伪造商品配置,或把管理用 entitlements.list/create 下放给扩展。

06 / 交易

支付与订单

结账链接由服务端签发,订单及权益状态通过 TongID 查询确认。

Next.js服务端

服务端生成 checkout.getUrl 链接后,用户进入 TongID 商品页和支付流程;当前用户用 orders.listMine 查看自己的订单,底层路径是 /api/v1/orders/me。已支付订单对应的权益从 session.effective.entitlements 核对。

orders.list/create 属于服务端管理能力。页面返回不等于支付成功;金额、币种、订单状态和权益均以 TongID 服务端返回结果为准。

相关 SDK 入口

  • checkout.getUrl
  • orders.listMine
  • orders.list
  • orders.create
用当前会话读取我的订单Next.js

app/account/orders/page.tsx

import { headers } from 'next/headers';
import { redirect } from 'next/navigation';
import { tongid } from '@/lib/tongid';

export default async function OrdersPage() {
  const cookieHeader = (await headers()).get('cookie') ?? '';
  const session = await tongid.auth.getSession({ cookieHeader });
  if (!session) redirect('/login');
  const { orders } = await tongid.orders.listMine({ cookieHeader });
  return <main>
    <h1>我的订单</h1>
    <p>订单:{orders.length}</p>
    <p>有效权益:{session.effective?.entitlements.length ?? 0}</p>
  </main>;
}
管理订单只在服务端调用服务端

lib/admin-orders.ts

import 'server-only';
import { tongid } from '@/lib/tongid';

export async function listManagedOrders() {
  // 此入口还需先完成你自己管理后台的操作者权限校验。
  const { orders } = await tongid.orders.list();
  return orders;
}
// orders.create 也只在已鉴权的服务端管理入口调用。

最佳实践

  • 金额、币种与支付状态均从 TongID 读取,不采用浏览器回传值。
  • 用户返回页面后重新查询订单和有效权益,再决定业务展示。
  • 支付失败仅显示安全提示,详细错误分类留在服务端脱敏日志。
  • 管理订单接口增加应用自己的操作者权限检查。

常见错误

  • 把支付返回页视作成功凭据,或在普通网页与 Chrome 调用管理订单能力。

07 / 生产

生产最佳实践

复用服务端客户端、理解会话缓存边界,并在上线前核对回调与退出路径。

Next.jsChrome MV3服务端

单应用在模块级使用 getSharedTongIdClient;多租户或测试场景用 scopeKey 区分配置,或使用显式 cacheStore。共享客户端的会话缓存仍是进程内的;默认 TTL 为 60 秒,上游故障宽限为 5 分钟,多实例各有独立缓存。

需要跨实例共享时实现 SessionCacheStore 的 get/set/delete 并接入 Redis 等外部存储。业务 API 自行做会话和权限验证。上线前核对正式回调域名、HTTPS、服务端 Secret、固定回调路径与退出后的本地状态清理;旧在途授权须重试。已验证 Next 16.3.3 Node Proxy/Middleware 与 Next 15.5.22 Middleware 构建及本地 HTTP fixture,真实 Hosted 生产登录待逐站验收。

相关 SDK 入口

  • getSharedTongIdClient
  • createMemorySessionStore
  • SessionCacheStore
单应用复用客户端服务端

lib/tongid.ts

import 'server-only';
import { getSharedTongIdClient } from '@tongid/sdk';

export const tongid = getSharedTongIdClient({
  baseURL: 'https://tongid.dev',
  applicationId: process.env.TONGID_APPLICATION_ID!,
  secretKey: process.env.TONGID_SECRET_KEY!,
  app: { siteUrl: process.env.APP_URL! },
});
// 多租户需为不同配置提供不同 scopeKey。
多实例注入共享会话缓存服务端

lib/tongid-cache.ts

import 'server-only';
import { createTongIdClient, type CachedSession, type SessionCacheStore } from '@tongid/sdk';
import { redis } from '@/lib/redis'; // 应用自己的 Redis 客户端

const cacheStore: SessionCacheStore = {
  async get(key) {
    const value = await redis.get('tongid:session:' + key);
    return value ? JSON.parse(value) as CachedSession : undefined;
  },
  async set(key, value) {
    await redis.set('tongid:session:' + key, JSON.stringify(value), { EX: 360 });
  },
  async delete(key) {
    await redis.del('tongid:session:' + key);
  },
};

export const tongid = createTongIdClient({
  baseURL: 'https://tongid.dev',
  applicationId: process.env.TONGID_APPLICATION_ID!,
  secretKey: process.env.TONGID_SECRET_KEY!,
  cacheStore,
});

最佳实践

  • 日志只记录状态码、错误类别和脱敏请求 ID,不记录会话、code、Cookie 或兑换链接。
  • 区分未登录、无权限、上游故障与网络故障,分别给出安全的处理。
  • Secret Key 按环境独立配置并规划轮换。
  • 用测试账号覆盖成功登录、失败回调、权限拒绝、结账返回与退出清理。

常见错误

  • 每个请求都新建独立内存缓存客户端,会让默认会话缓存反复失效。

发布前核对

上线检查清单

  • 应用正式 HTTPS 域名与控制台回调白名单一致,/_tongid/auth/tongid/callback 可访问。
  • Secret Key 仅在服务端,Chrome 仅使用 PKCE public-client;state 和 code verifier 只用于单次登录。
  • 回调成功写入应用域 Cookie,失败进入受控错误页,next 只允许明确列出的站内路径。
  • 权限守卫位于服务端;订单、支付结果和有效权益从 TongID 服务端复核。
  • 多实例缓存策略已确定;退出时撤销会话并清理扩展本地 Token。