Relyt One 托管认证入门
一步步在 Relyt One 上搭建一个多用户的 Next.js 应用:为分支启用托管认证、让用户登录、用 @relyt/auth 验证他们的 JWT,并由 Postgres 行级安全把每个用户的行隔离开。
本页目录
在本指南中,我们会在 Relyt One 上构建一个小型的多用户笔记 API。用户在分支的托管认证 URL 上注册和登录,应用用 @relyt/auth 验证他们的令牌,Postgres 行级安全则确保每个用户只能读写自己的笔记。过程中我们还会讲到最容易出错的几处:可信来源、issuer 不匹配、BYPASSRLS 角色和连接池中的连接。
你需要一个 Relyt One 项目、Node.js 20 或更高版本、psql,以及大约三十分钟。这里的所有内容也适用于开源的 TODO 示例,它采用同样的模式,功能更多(社交登录、团队与角色,以及测试套件)。如果你想先了解托管认证为什么这样设计,请先阅读 Relyt One 托管认证预览版发布。
- 一张
notes表,每行有一个所有者,受行级安全保护。 - 一个 Next.js 路由
/api/notes,列出并创建调用方的笔记。它只接受Authorization: Bearer <jwt>。 - 一个浏览器端辅助模块,在认证 URL 上为用户登录,并带着新鲜的 JWT 调用 API。
整个设计靠两条规则撑起来,下面的每一步都由它们推导而来:
- 用户 ID 只能来自经过验证的 JWT。 绝不能来自请求头、查询字符串或请求体。
- 数据库也要检查。 API 按用户过滤一次,RLS 再过滤一次,所以即使忘了写
WHERE,也不会泄露别人的行。
在控制台中打开你的项目,选择应用要使用的分支。开发阶段,main 的一个子分支是不错的选择:它的认证设置、会话和签名密钥都是独立的,所以实验永远不会影响生产环境的登录。(如果你是在 main 启用认证之后才创建这个子分支的,它一开始会带有 main 的可信来源和终端用户的副本,因此要把它当作存有真实用户数据的分支来对待。)
进入 Auth(认证)标签页,选择 Enable auth(启用认证)。首次设置会唤醒分支的计算实例并创建认证相关的表,大约需要一分钟,请保持页面打开。只有组织的所有者和管理员才能执行此操作。
状态变为 Ready(就绪)后,概览中会显示两个 URL:
- Auth URL(认证 URL):你的用户在这里登录,也是你的后端期望的令牌签发方(issuer)的值。
- JWKS URL:你的后端用来检查令牌签名的公钥。
把两个都复制下来。现在,用户、会话和组织都存储在这个分支数据库的 relyt_auth schema 中。
在 Sign-in methods(登录方式)下,确认 Email + password(邮箱 + 密码)已启用(默认开启)并保存。本指南只需要这一种。
有了 OAuth App 之后,GitHub 和 Google 的配置方式也一样:把控制台显示的回调 URL(它指向认证 URL,例如 <认证 URL>/api/auth/callback/github)复制到服务商的设置中,再把 Client ID 和 Client Secret 粘贴到控制台,选择 Save & enable(保存并启用)。Secret 只能写入。一个 GitHub OAuth App 只能注册一个回调 URL,所以请每个分支使用一个 OAuth App。
认证 URL 和你的应用是不同的源,所以你的应用每次让用户登录时,浏览器都会发出一个 CORS 请求。认证服务只响应你列出的来源。
在 Redirects & origins(重定向与来源)下,把应用的精确来源添加到 Trusted origins(可信来源):
- 开发时添加
http://localhost:3000; - 之后添加
https://app.example.com(或你部署的任何地址)。
不带路径、不带末尾斜杠、不用通配符。如果之后要加社交登录,还要在 Callback URLs(回调 URL)下注册完成登录的那个页面,例如 http://localhost:3000/auth/callback。回调 URL 是精确匹配的,而且每个回调 URL 的来源也必须是可信来源。
以数据库所有者身份(即 relytdb 数据库上的 relytdb_owner,密码是你创建项目时设置的那个)连接到分支,创建表:
CREATE TABLE notes (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id text NOT NULL,
body text NOT NULL CHECK (length(body) BETWEEN 1 AND 2000),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX notes_user_id_idx ON notes (user_id, created_at DESC);user_id 存放用户 JWT 中的 sub 声明,它是一个文本 ID。
现在开启行级安全,并添加一条策略,把每一行的所有者与应用在每个事务中设置的一个配置项进行比较:
ALTER TABLE notes ENABLE ROW LEVEL SECURITY;
ALTER TABLE notes FORCE ROW LEVEL SECURITY;
CREATE POLICY notes_owner ON notes FOR ALL
USING (user_id = current_setting('app.user_id', true))
WITH CHECK (user_id = current_setting('app.user_id', true));各部分的作用:
ENABLE开启 RLS;FORCE让它对表的所有者同样生效,除非所有者带有BYPASSRLS。在 Relyt One 上,relytdb_owner就带有BYPASSRLS,所以它仍能看到所有行(见下一节)。USING过滤一条查询能看到、更新或删除哪些行。WITH CHECK拒绝会产生「属于别人的行」的插入和更新。current_setting('app.user_id', true)返回该配置项,如果从未设置过则什么也不返回(true表示「不要报错」)。配置项缺失时没有任何行能匹配,所以忘了设置它的代码看到的是一张空表,而不是所有人的笔记。
这是大家最常跳过的一步,而它会悄无声息地让上面的一切失效。
超级用户和带有 BYPASSRLS 的角色会无视所有策略。通过 Relyt One 控制台创建的数据库用户带有 BYPASSRLS,每个项目自带的所有者角色 relytdb_owner 也带有。 所有者只用来建表,不要拿它来处理请求。如果你的应用以这样的用户连接,策略根本不会执行,唯一把用户隔开的就只剩你的应用代码了。
请改用 SQL 为应用创建一个专用角色:
CREATE ROLE notes_app LOGIN NOBYPASSRLS NOSUPERUSER NOCREATEROLE NOCREATEDB;
\password notes_app
GRANT USAGE ON SCHEMA public TO notes_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON notes TO notes_app;\password 是一个 psql 命令,会提示你输入密码,这样密码就不会留在 shell 历史或服务器日志里。检查结果:
SELECT rolname, rolbypassrls, rolsuper FROM pg_roles WHERE rolname = 'notes_app';
-- rolname | rolbypassrls | rolsuper
-- -----------+--------------+----------
-- notes_app | f | f两列都必须是 f。这样建出来的角色经直连和连接池两种连接字符串都能登录;本步的整套做法我们已在真实分支上验证过,包括策略在连接池后面同样生效。
创建一个 Next.js 15 应用,安装 SDK 和 Postgres 驱动(@relyt/auth/next 支持 Next.js 14.2.35+ 和 15.5.28+,暂不支持 16,所以请固定版本):
npx create-next-app@15 notes --ts --app
cd notes
npm install @relyt/auth pg
npm install -D @types/pg把配置写入 .env.local:
# 第 1 步中的认证 URL:只写源,不带末尾斜杠,不带路径。
RELYT_AUTH_ISSUER=https://<你的认证 URL>
RELYT_AUTH_JWKS_URL=https://<你的 JWKS URL>
# 同一个认证 URL,供浏览器使用。
NEXT_PUBLIC_RELYT_AUTH_URL=https://<你的认证 URL>
# 以第 4 步创建的角色 notes_app 连接,绝不要用控制台创建的用户。
DATABASE_URL=postgresql://notes_app:<密码>@<主机>/relytdb?sslmode=requireissuer 必须与认证 URL 的源完全一致,因为令牌的 iss 和 aud 中都带着这个值,而 SDK 会逐字符比较。sslmode=require 同样重要:连接 Relyt One 需要使用 TLS,而 node-postgres 默认不会开启它,除非你显式要求。
把所有数据库访问都收拢到一个函数后面:它开启一个事务,用经过验证的用户 ID 设置 app.user_id,然后才执行你的查询。
// lib/db.ts
import { Pool, type PoolClient } from "pg";
const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 10 });
export async function withUser<T>(userId: string, fn: (db: PoolClient) => Promise<T>): Promise<T> {
if (!userId) throw new Error("withUser: empty user id");
const client = await pool.connect();
try {
await client.query("BEGIN");
// `true` = 仅在当前事务内有效:连接池中的连接永远不会把它带到下一个请求。
await client.query("SELECT set_config('app.user_id', $1, true)", [userId]);
const result = await fn(client);
await client.query("COMMIT");
client.release();
return result;
} catch (e) {
await client.query("ROLLBACK").catch(() => undefined);
client.release(true); // 丢弃可能已损坏的连接,而不是把它放回连接池
throw e;
}
}set_config 的第三个参数比看上去重要得多。没有它,这个配置项会在整个会话期间一直有效;在连接池下,下一个借用这条连接的请求就会以上一个用户的身份运行。传入 true 后,它会在 COMMIT 或 ROLLBACK 时消失。
在 PgBouncer 的事务级连接池后面也是如此,因为这个配置项的生命周期永远不会超过事务。
@relyt/auth/next 中的 getClaims 会读取 Bearer 令牌,对照 JWKS 检查它的 EdDSA 签名,检查签发方、受众和过期时间,然后返回声明;如果令牌缺失或无效,则返回 null。
// lib/auth.ts
import { getClaims, RelytAuthUnavailableError } from "@relyt/auth/next";
export type Caller = { ok: true; userId: string } | { ok: false; response: Response };
export async function requireUser(req: Request): Promise<Caller> {
try {
const claims = await getClaims(
req,
{ issuer: process.env.RELYT_AUTH_ISSUER!, jwksUrl: process.env.RELYT_AUTH_JWKS_URL },
{ cookie: false }, // API 路由只接受 Bearer 令牌
);
if (!claims?.sub) {
return { ok: false, response: Response.json({ error: "unauthorized" }, { status: 401, headers: { "www-authenticate": "Bearer" } }) };
}
return { ok: true, userId: claims.sub };
} catch (e) {
if (e instanceof RelytAuthUnavailableError) {
// 无法获取公钥。不要返回 401:那样会在一次网络抖动中让所有人都退出登录。
return { ok: false, response: Response.json({ error: "auth_unavailable" }, { status: 503, headers: { "retry-after": "2" } }) };
}
throw e;
}
}只接受 Bearer 令牌(cookie: false)能让 API 免受 CSRF 攻击:浏览器绝不会自行在跨站请求上附带 Authorization 请求头。
// app/api/notes/route.ts
import { requireUser } from "@/lib/auth";
import { withUser } from "@/lib/db";
export async function GET(req: Request) {
const caller = await requireUser(req);
if (!caller.ok) return caller.response;
const notes = await withUser(caller.userId, async (db) => {
const { rows } = await db.query(
"SELECT id, body, created_at FROM notes WHERE user_id = $1 ORDER BY created_at DESC LIMIT 100",
[caller.userId],
);
return rows;
});
return Response.json(notes);
}
export async function POST(req: Request) {
const caller = await requireUser(req);
if (!caller.ok) return caller.response;
const input = await req.json().catch(() => null);
const body = typeof input?.body === "string" ? input.body.trim() : "";
if (body.length < 1 || body.length > 2000) return Response.json({ error: "invalid_body" }, { status: 400 });
const note = await withUser(caller.userId, async (db) => {
const { rows } = await db.query(
"INSERT INTO notes (user_id, body) VALUES ($1, $2) RETURNING id, body, created_at",
[caller.userId, body],
);
return rows[0];
});
return Response.json(note, { status: 201 });
}注意,即使 RLS 本来就会过滤,查询里仍然写着 WHERE user_id = $1。两者都保留是有意为之:查询快速而明确,策略则是安全网,防的是哪天有人写出一条没带过滤条件的查询。
还要注意这个处理器没有做什么:它从不从请求体或查询字符串中读取用户 ID。它写入的 user_id 就是令牌里的那个。
浏览器要和两个地方打交道:认证 URL,用于登录;你的 API,带着 JWT 调用。
// lib/auth-client.ts
"use client";
import { createAuthClient } from "@relyt/auth";
const authUrl = process.env.NEXT_PUBLIC_RELYT_AUTH_URL!;
const KEY = "notes.session-token";
export const authClient = createAuthClient({
authUrl,
sessionToken: () => localStorage.getItem(KEY),
onSessionToken: (t) => localStorage.setItem(KEY, t),
});
export async function emailAuth(mode: "sign-in" | "sign-up", email: string, password: string) {
const body = mode === "sign-up" ? { email, password, name: email.split("@")[0] } : { email, password };
const res = await fetch(`${authUrl}/api/auth/${mode}/email`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
credentials: "omit",
});
if (!res.ok) throw new Error(`${mode} failed (HTTP ${res.status})`);
const token = res.headers.get("set-auth-token");
if (!token) throw new Error("No session token returned. Is this origin a trusted origin?");
localStorage.setItem(KEY, token);
}
export async function api(path: string, init: RequestInit = {}) {
const jwt = await authClient.getToken(); // 已缓存;过期前自动刷新
const headers = new Headers(init.headers);
headers.set("authorization", `Bearer ${jwt}`);
return fetch(path, { ...init, headers });
}
export async function signOut() {
const token = localStorage.getItem(KEY);
if (token) {
// 在认证 URL 上结束会话,而不只是在当前标签页中清除
await fetch(`${authUrl}/api/auth/sign-out`, { method: "POST", headers: { authorization: `Bearer ${token}` }, credentials: "omit" }).catch(() => undefined);
}
localStorage.removeItem(KEY);
authClient.clear();
}各部分如何配合:
emailAuth向认证 URL 发送请求。响应的set-auth-token响应头中带有一个会话令牌。这个令牌就是用户的会话:在会话结束之前,任何持有它的人都能以该用户的身份换取 JWT、调用认证 URL 的各个接口,所以要像对待密码一样对待它。authClient.getToken()用会话令牌换取一个 JWT(15 分钟,EdDSA)并缓存起来,在过期前大约两分钟刷新。api()把 JWT 发给你的 API。浏览器从不验证令牌,那是服务端的事。
把会话令牌放在 localStorage 里,是为了让本指南保持简短;但存放在那里的任何内容,都能被页面上运行的任意脚本读取,所以一个跨站脚本(XSS)漏洞就会让它暴露。请不要在处理登录的页面上加载第三方脚本,并使用严格的内容安全策略(Content Security Policy)。TODO 示例展示了一种更稳健的做法:还会把 JWT 放进 HttpOnly Cookie,供页面导航使用。
一个用来试用的最小页面:
// app/page.tsx
"use client";
import { useState } from "react";
import { api, emailAuth } from "@/lib/auth-client";
export default function Home() {
const [notes, setNotes] = useState<{ id: string; body: string }[]>([]);
const load = async () => setNotes(await (await api("/api/notes")).json());
return (
<main>
<form
onSubmit={async (e) => {
e.preventDefault();
const f = new FormData(e.currentTarget);
await emailAuth("sign-up", String(f.get("email")), String(f.get("password")));
await load();
}}
>
<input name="email" type="email" required />
<input name="password" type="password" required />
<button>Sign up</button>
</form>
<button onClick={async () => { await api("/api/notes", { method: "POST", body: JSON.stringify({ body: "hello" }) }); await load(); }}>
Add note
</button>
<ul>{notes.map((n) => <li key={n.id}>{n.body}</li>)}</ul>
</main>
);
}运行 npm run dev,在两个浏览器配置文件中分别注册两个用户,各自添加一条笔记。每个用户都只能看到自己的笔记。
在浏览器里看到效果固然不错,看到它在缺了东西时拒绝访问,才更让人放心。用 curl 为一个用户获取 JWT:
AUTH=https://<你的认证 URL>
ORIGIN=http://localhost:3000
SESSION=$(curl -s -D - -o /dev/null "$AUTH/api/auth/sign-in/email" \
-H "origin: $ORIGIN" -H "content-type: application/json" \
-d '{"email":"alice@example.com","password":"<password>"}' \
| awk 'tolower($1)=="set-auth-token:" {print $2}' | tr -d '\r')
JWT=$(curl -s "$AUTH/api/auth/token" -H "authorization: Bearer $SESSION" | sed 's/.*"token":"\([^"]*\)".*/\1/')
curl -s http://localhost:3000/api/notes -H "authorization: Bearer $JWT"然后试试那些应该失败的情况:
# 没有令牌:401
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/api/notes
# 被篡改的令牌:401
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/api/notes -H "authorization: Bearer ${JWT}x"再直接检查数据库那一半的承诺。以 notes_app 身份连接、不设置 app.user_id 时,无论实际有多少条笔记,这张表都是空的:
SELECT count(*) FROM notes; -- 0
BEGIN;
SELECT set_config('app.user_id', '<alice 的 sub>', true);
SELECT count(*) FROM notes; -- 只有 Alice 的笔记
COMMIT;如果第一条查询返回了所有笔记,说明你的应用是以一个绕过 RLS 的角色连接的。请回到第 4 步。
startSocialSignIn 通过认证 URL 把用户送到 GitHub 或 Google,completeSocialSignIn 则在你的回调页面上用一次性交接码和 PKCE 完成登录,因此无需第三方 Cookie 也能工作。控制台的 Integration code(集成代码)面板提供了这两段代码,并已填好你的分支 URL。TODO 示例的 README 列出了回调页面需要遵守的规则(它必须在做任何其他事情之前先运行 completeSocialSignIn,并且登录必须在同一个源上开始和结束)。
当用户有一个活跃组织时,他们的 JWT 会带有一个 o 声明:
"o": { "id": "…", "slug": "acme", "role": "member", "perms": ["todo:create", "todo:read"] }在控制台的 Roles & permissions(角色与权限)下定义资源和操作,在 API 中执行每个操作前检查 o.perms,并为共享的行添加第二条基于 org_id 列的 RLS 策略(在 withUser 中再调用一次 set_config 来设置它)。TODO 示例正是这样实现的,所有权限判断都集中在一个模块里。
| 现象 | 可能的原因 |
|---|---|
| 浏览器中登录失败,报网络错误 | 应用的来源不在 Trusted origins 中,或者带了路径或末尾斜杠 |
每次 API 调用都返回 500,服务端日志中有来自 @relyt/auth 的 config 错误 | RELYT_AUTH_ISSUER 不是一个单纯的源:带了末尾斜杠或路径,或者对 localhost 以外的主机使用了 http |
| 令牌看起来有效,但每次 API 调用都返回 401 | RELYT_AUTH_ISSUER 与签发该令牌的源不同,例如用了另一个分支的认证 URL |
| API 返回 503 | 服务端无法获取 JWKS。请检查服务端到 JWKS URL 的出站网络访问 |
| 数据库连接失败或卡住 | 连接字符串缺少 ?sslmode=require |
| 查询漏写过滤条件时,用户能看到彼此的行 | 应用以带有 BYPASSRLS 的角色连接(控制台创建的用户和 relytdb_owner 都带有它)。请使用第 4 步创建的角色 |
| 查询完全不返回任何行 | app.user_id 没有在与查询相同的事务中设置,或者查询运行在 withUser 之外 |
| 某个请求间歇性地看到另一个用户的行 | 在连接池的连接上以 false(整个会话有效)调用了 set_config。请使用 true |
- 应用以专用的
NOBYPASSRLS角色连接(不是relytdb_owner),且rolbypassrls为f。 - 每张存有用户数据的表都已启用并强制启用 RLS,同时带有
USING和WITH CHECK。 - 每个路由处理器都调用了
requireUser,即使前面已经有中间件。 - 用户 ID 只来自
claims.sub。 - 退出登录时会调用认证 URL 的
/api/auth/sign-out,并且持有会话令牌的页面上不运行任何第三方脚本。 - JWKS 不可用时返回 503,而不是 401。
- 生产环境的来源已加入 Trusted origins,生产环境的回调 URL 已精确注册。
- 如果你自己缓存 JWKS,缓存时间不超过 960 秒,并且在令牌带有未知密钥 ID 时重新获取,这样密钥轮换才能正常工作。(
@relyt/auth已经同时做到了这两点。) - 你清楚在控制台中撤销某个用户的会话后,已签发的 JWT 最多仍会有效 15 分钟,并且你的应用处理好了这一点会造成影响的场景。
- 克隆 TODO 示例,获取社交登录、团队、角色,以及检查上述所有要点的测试套件。
- 阅读
@relyt/authREADME,了解createAuthClient、getClaims和createVerifier的全部选项。 - 阅读 Relyt One 托管认证预览版发布,了解设计思路与安全模型。



