← 文章与指南

为 React Router 接入通行密钥(Passkey)登录

使用 Sudomimus React Router SDK 发起通行密钥登录、兑换 Connect 回调、刷新服务端会话,并完成退出。

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 应保存在公开资源和源码仓库之外,不要通过浏览器环境变量暴露它。

创建一个仅供服务端使用的模块。示例会主动阻止生产环境使用本地内存存储。

app/sudomimus.server.tsTypeScript
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 的路由配置将每个路径映射到对应模块。

app/routes.tsTypeScript
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 响应头。

app/routes/login.tsxTypeScript
import { sudomimus } from "../sudomimus.server";
export const action = sudomimus.startAction;
app/routes/auth.callback.tsxTypeScript
import { sudomimus } from "../sudomimus.server";
export const loader = sudomimus.callbackLoader;
app/routes/logout.tsxTypeScript
import { sudomimus } from "../sudomimus.server";
export const action = sudomimus.logoutAction;

首页通过 POST 提交登录。指向 /login 的 GET 链接不能启动这个流程。

app/routes/home.tsxTypeScript
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. 只向页面返回需要的字段

在仪表盘的服务端数据加载函数中读取当前会话。只返回主体标识符,将令牌对和待处理登录状态保留在服务端。

app/routes/dashboard.tsxTypeScript
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 使用独立端点和作用域,详见邮箱排查指南。