React Router Framework Mode 可以用服务端操作发起通行密钥(Passkey)登录,再用服务端数据加载函数接收回调。Sudomimus 负责 WebAuthn 验证。官方 @sudomimus/react-router 适配器处理 Connect 兑换和服务端会话;你的应用决定用户可以访问哪些数据、执行哪些操作。
你需要一个运行 Node.js 的 Framework Mode 项目,并安装 @sudomimus/react-router、@sudomimus/web、@sudomimus/connect 和 @sudomimus/session。本文使用 Connect;接入方式的区别见 OIDC 与 Connect。应用登录页展示了用户会看到的登录体验。
示例用于单进程开发,使用内存存储。生产环境需要共享、持久化的存储。纯浏览器 SPA 不能运行这些服务端处理函数。
1. 配置通行密钥和 Connect 回调
在 With 门户创建组织和应用。保存不可变的 applicationAnchor。为应用登记 RS256 客户端认证公钥,并将对应私钥保存在后端秘密存储中。这个密钥用于签名 Connect 请求,与用户的通行密钥不同。
配置全部三层规则:
| 规则层 | 配置 |
|---|---|
| 认证规则(Layer 1) | 用 PASSKEY_USERNAMELESS 在邮箱输入前显示通行密钥按钮,用 PASSKEY_REASONED 提供先输入邮箱的登录方式,或者同时启用两者。 |
| 身份准入规则(Layer 2) | 添加允许测试账户进入应用的规则。通行密钥验证成功本身不代表允许进入应用。 |
| 返回规则(Layer 3) | 添加允许回调 URL 主机的 CALLBACK 规则。 |
生产环境使用 https://app.example.com/auth/callback,并将主机替换为你的域名。本地开发时,Connect 也允许回环 HTTP 回调,例如 http://localhost:5173/auth/callback。在应用的 CALLBACK 规则中允许对应主机。回调与提交登录、退出的页面必须同源。配置的 URL 不要包含 exposure-key 或 confirmation-key;Connect 会自动追加这两个查询参数。
配置完成后启用应用。测试前,先在你的 Sudomimus 账户中注册通行密钥。启用认证规则不会自动为用户注册凭据。配置详情见通行密钥登录规则和 Connect 接入流程。
2. 配置服务端模块
在服务端环境中设置应用锚点、回调 URL 和私钥文件路径。例如,本地回调可以使用 http://localhost:5173/auth/callback。私钥 PEM 应保存在公开资源和源码仓库之外,不要通过浏览器环境变量暴露它。
创建一个仅供服务端使用的模块。示例会主动阻止生产环境使用本地内存存储。
import { readFileSync } from "node:fs";import { ConnectClient } from "@sudomimus/connect";import { createReactRouterHandlers } from "@sudomimus/react-router";import { SessionClient } from "@sudomimus/session";import { MemoryWebAuthStore } from "@sudomimus/web";
function requiredEnvironment(name: string): string { const value = process.env[name]; if (!value) { throw new Error(`Missing server environment variable: ${name}`); } return value;}
if (process.env.NODE_ENV === "production") { throw new Error("Configure a shared WebAuthStore before production");}
const applicationAnchor = requiredEnvironment("SUDOMIMUS_APPLICATION_ANCHOR");const connect = new ConnectClient({ clientAuth: { applicationAnchor, privateKeyPem: readFileSync( requiredEnvironment("SUDOMIMUS_CLIENT_AUTH_PRIVATE_KEY_FILE"), "utf8", ), },});
export const sudomimus = createReactRouterHandlers({ applicationAnchor, callbackUrl: requiredEnvironment("SUDOMIMUS_CALLBACK_URL"), afterLoginUrl: "/dashboard", afterLogoutUrl: "/", connect, session: new SessionClient(), store: new MemoryWebAuthStore(), pendingTtlSeconds: 600, sessionTtlSeconds: 86_400,});适配器会签名 /establish,保存待处理 Inquiry 的密钥,跳转到托管登录页,一次性兑换回调,并校验返回的访问令牌。hiddenKey、访问令牌和刷新令牌保留在服务端存储中。浏览器只拿到不透明的会话 Cookie。
current() 会在访问令牌即将过期时刷新,并保存轮换后的刷新令牌。登录后会创建新的本地会话标识符;退出会撤销 Session API 会话并清除 Cookie。示例的本地会话最长为一天;开发服务器重启后,待处理登录和已有会话都会丢失。生产环境的 HTTPS 回调会启用 Secure Cookie,两种环境都使用 HttpOnly 和 SameSite=Lax。
3. 注册操作、回调和页面路由
在 app/routes.ts 中声明路由。React Router 的路由配置将每个路径映射到对应模块。
import { type RouteConfig, index, route } from "@react-router/dev/routes";
export default [ index("routes/home.tsx"), route("login", "routes/login.tsx"), route("auth/callback", "routes/auth.callback.tsx"), route("logout", "routes/logout.tsx"), route("dashboard", "routes/dashboard.tsx"),] satisfies RouteConfig;用服务端操作处理登录发起和退出,用数据加载函数处理 GET 回调。直接返回处理函数的响应,保留其中的跳转和 Set-Cookie 响应头。
import { sudomimus } from "../sudomimus.server";
export const action = sudomimus.startAction;import { sudomimus } from "../sudomimus.server";
export const loader = sudomimus.callbackLoader;import { sudomimus } from "../sudomimus.server";
export const action = sudomimus.logoutAction;首页通过 POST 提交登录。指向 /login 的 GET 链接不能启动这个流程。
import { Form } from "react-router";
export default function Home() { return ( <Form method="post" action="/login"> <button type="submit">Sign in with Sudomimus</button> </Form> );}4. 只向页面返回需要的字段
在仪表盘的服务端数据加载函数中读取当前会话。只返回主体标识符,将令牌对和待处理登录状态保留在服务端。
import { Form, type LoaderFunctionArgs, redirect, useLoaderData } from "react-router";import { sudomimus } from "../sudomimus.server";
export async function loader({ request }: LoaderFunctionArgs) { const session = await sudomimus.current({ request }); if (!session) { throw redirect("/"); } return { subject: session.subject };}
export default function Dashboard() { const { subject } = useLoaderData<typeof loader>(); return ( <main> <h1>Signed in</h1> <p>User: {subject}</p> <Form method="post" action="/logout"> <button type="submit">Sign out</button> </Form> </main> );}subject 是应用可见、按扇区隔离的成对标识符。将它映射到你自己的用户记录,不要将它当作邮箱。应用仍需自行检查受保护数据和业务操作的权限。主体来自经过校验的令牌;离线校验不会立即观察到平台后续的撤销操作。
5. 测试登录、刷新和退出
打开应用,使用已注册的通行密钥登录,确认浏览器进入 /dashboard。退出后再次访问 /dashboard,数据加载函数应将浏览器重定向到 /。检查刷新时,保持登录到访问令牌即将过期,再加载仪表盘。服务端应轮换令牌对,且不会向页面返回任何令牌。
| 现象 | 检查项 |
|---|---|
| 没有显示通行密钥选项 | 应用允许预期的通行密钥认证方式,且账户已经注册通行密钥。 |
| 验证完成后仍被拒绝 | 应用已经启用,身份准入规则允许该账户进入。 |
| Establish 请求失败 | 应用锚点、已登记的 RS256 公钥与服务端使用的私钥对应。 |
| 回调被拒绝 | 规则允许回调主机,且浏览器仍保留待处理 Cookie。重启或回调已消费后,应重新发起登录。 |
| 发起登录或退出时提示来源无效 | 浏览器页面来源与配置的回调来源一致。保留 POST 来源检查。 |
| 切换服务实例后刷新失败 | 全部实例共用同一存储,并对每个本地会话串行执行刷新。 |
上线前,将 MemoryWebAuthStore 替换为共享、持久化的 WebAuthStore,再移除本地运行限制。takePending() 必须原子消费匹配的登录记录。withSession() 必须在读取、刷新和保存会话的整个过程持有锁,避免并发请求重复使用同一个刷新令牌。存储有效期应与配置的 TTL 一致,并保护存储中的令牌和密钥。
通过 HTTPS 部署。保留适配器的 POST 来源检查、Cookie 保护、回调检查和令牌校验。不要记录回调查询参数、Cookie、私钥或令牌。业务操作需要确认平台会话仍有效时,应使用 Session 在线自省;离线令牌校验无法立即观察到撤销。
存储与校验契约见 React Router 接入参考和 Session 生命周期指南。Connect 的资料字段通过 Session UserInfo 获取;OIDC 使用独立端点和作用域,详见邮箱排查指南。