# 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 示例](https://github.com/relytcloud/relyt-auth-examples/tree/main/todo)，它采用同样的模式，功能更多（社交登录、团队与角色，以及测试套件）。如果你想先了解托管认证为什么这样设计，请先阅读 [Relyt One 托管认证预览版发布](/blog/zh-CN/managed-auth-preview)。

> **注意**
>
> 托管认证目前是预览版。以下步骤与当前控制台一致；名称和限制仍可能变化。

## 我们要构建什么

![浏览器在认证 URL 上登录并获得 JWT，API 用 JWKS 验证它并设置 app.user_id，RLS 过滤 notes 表](/blog/assets/c6f96b7af2edb179.svg)

- 一张 `notes` 表，每行有一个所有者，受行级安全保护。
- 一个 Next.js 路由 `/api/notes`，列出并创建调用方的笔记。它只接受 `Authorization: Bearer <jwt>`。
- 一个浏览器端辅助模块，在认证 URL 上为用户登录，并带着新鲜的 JWT 调用 API。

整个设计靠两条规则撑起来，下面的每一步都由它们推导而来：

1. **用户 ID 只能来自经过验证的 JWT。** 绝不能来自请求头、查询字符串或请求体。
2. **数据库也要检查。** API 按用户过滤一次，RLS 再过滤一次，所以即使忘了写 `WHERE`，也不会泄露别人的行。

## 第 1 步：为分支启用托管认证

在控制台中打开你的项目，选择应用要使用的分支。开发阶段，`main` 的一个子分支是不错的选择：它的认证设置、会话和签名密钥都是独立的，所以实验永远不会影响生产环境的登录。（如果你是在 `main` 启用认证之后才创建这个子分支的，它一开始会带有 `main` 的可信来源和终端用户的副本，因此要把它当作存有真实用户数据的分支来对待。）

进入 **Auth**（认证）标签页，选择 **Enable auth**（启用认证）。首次设置会唤醒分支的计算实例并创建认证相关的表，大约需要一分钟，请保持页面打开。只有组织的所有者和管理员才能执行此操作。

状态变为 **Ready**（就绪）后，概览中会显示两个 URL：

- **Auth URL**（认证 URL）：你的用户在这里登录，也是你的后端期望的令牌签发方（issuer）的值。
- **JWKS URL**：你的后端用来检查令牌签名的公钥。

把两个都复制下来。现在，用户、会话和组织都存储在这个分支数据库的 `relyt_auth` schema 中。

## 第 2 步：开启一种登录方式

在 **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。

## 第 3 步：信任你的应用来源

认证 URL 和你的应用是不同的源，所以你的应用每次让用户登录时，浏览器都会发出一个 CORS 请求。认证服务只响应你列出的来源。

在 **Redirects & origins**（重定向与来源）下，把应用的精确来源添加到 **Trusted origins**（可信来源）：

- 开发时添加 `http://localhost:3000`；
- 之后添加 `https://app.example.com`（或你部署的任何地址）。

不带路径、不带末尾斜杠、不用通配符。如果之后要加社交登录，还要在 **Callback URLs**（回调 URL）下注册完成登录的那个页面，例如 `http://localhost:3000/auth/callback`。回调 URL 是精确匹配的，而且每个回调 URL 的来源也必须是可信来源。

> **提示**
>
> 如果浏览器中登录失败，只报了一个笼统的网络错误，原因几乎总是这一步。

## 第 4 步：创建表、策略和应用角色

以数据库所有者身份（即 `relytdb` 数据库上的 `relytdb_owner`，密码是你创建项目时设置的那个）连接到分支，创建表：

```sql
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。

现在开启行级安全，并添加一条策略，把每一行的所有者与应用在每个事务中设置的一个配置项进行比较：

```sql
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` 表示「不要报错」）。配置项缺失时没有任何行能匹配，所以忘了设置它的代码看到的是一张空表，而不是所有人的笔记。

### 以受 RLS 约束的角色连接

这是大家最常跳过的一步，而它会悄无声息地让上面的一切失效。

超级用户和带有 `BYPASSRLS` 的角色会无视所有策略。**通过 Relyt One 控制台创建的数据库用户带有 `BYPASSRLS`，每个项目自带的所有者角色 `relytdb_owner` 也带有。** 所有者只用来建表，不要拿它来处理请求。如果你的应用以这样的用户连接，策略根本不会执行，唯一把用户隔开的就只剩你的应用代码了。

请改用 SQL 为应用创建一个专用角色：

```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 历史或服务器日志里。检查结果：

```sql
SELECT rolname, rolbypassrls, rolsuper FROM pg_roles WHERE rolname = 'notes_app';
--  rolname   | rolbypassrls | rolsuper
-- -----------+--------------+----------
--  notes_app | f            | f
```

两列都必须是 `f`。这样建出来的角色经直连和连接池两种连接字符串都能登录；本步的整套做法我们已在真实分支上验证过，包括策略在连接池后面同样生效。

## 第 5 步：搭建应用

创建一个 Next.js 15 应用，安装 SDK 和 Postgres 驱动（`@relyt/auth/next` 支持 Next.js 14.2.35+ 和 15.5.28+，暂不支持 16，所以请固定版本）：

```bash
npx create-next-app@15 notes --ts --app
cd notes
npm install @relyt/auth pg
npm install -D @types/pg
```

把配置写入 `.env.local`：

```bash
# 第 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=require
```

issuer 必须与认证 URL 的源完全一致，因为令牌的 `iss` 和 `aud` 中都带着这个值，而 SDK 会逐字符比较。`sslmode=require` 同样重要：连接 Relyt One 需要使用 TLS，而 node-postgres 默认不会开启它，除非你显式要求。

## 第 6 步：通往数据库的唯一入口

把所有数据库访问都收拢到一个函数后面：它开启一个事务，用经过验证的用户 ID 设置 `app.user_id`，然后才执行你的查询。

```ts
// 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 的事务级连接池后面也是如此，因为这个配置项的生命周期永远不会超过事务。

## 第 7 步：在每个请求上验证令牌

`@relyt/auth/next` 中的 `getClaims` 会读取 Bearer 令牌，对照 JWKS 检查它的 EdDSA 签名，检查签发方、受众和过期时间，然后返回声明；如果令牌缺失或无效，则返回 `null`。

```ts
// 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` 请求头。

## 第 8 步：笔记 API

```ts
// 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` 就是令牌里的那个。

## 第 9 步：在浏览器中登录

浏览器要和两个地方打交道：认证 URL，用于登录；你的 API，带着 JWT 调用。

```ts
// 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，供页面导航使用。

一个用来试用的最小页面：

```tsx
// 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`，在两个浏览器配置文件中分别注册两个用户，各自添加一条笔记。每个用户都只能看到自己的笔记。

## 第 10 步：在终端里证明隔离生效

在浏览器里看到效果固然不错，看到它在缺了东西时拒绝访问，才更让人放心。用 `curl` 为一个用户获取 JWT：

```bash
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"
```

然后试试那些应该失败的情况：

```bash
# 没有令牌：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` 时，无论实际有多少条笔记，这张表都是空的：

```sql
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` 声明：

```json
"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 示例](https://github.com/relytcloud/relyt-auth-examples/tree/main/todo)，获取社交登录、团队、角色，以及检查上述所有要点的测试套件。
- 阅读 [`@relyt/auth` README](https://github.com/relytcloud/relyt-auth-js#readme)，了解 `createAuthClient`、`getClaims` 和 `createVerifier` 的全部选项。
- 阅读 [Relyt One 托管认证预览版发布](/blog/zh-CN/managed-auth-preview)，了解设计思路与安全模型。

[创建免费项目](/auth/signin?ref=blog-managed-auth-getting-started)
