# Relyt One 托管认证预览版发布

为你的应用用户提供注册、登录、会话和组织能力，服务运行在分支旁边，用户就存放在你自己的数据库里。令牌是短时效的 EdDSA JWT，用 JWKS 验证，剩下的交给行级安全（RLS）。

**托管认证现已在 Relyt One 上开放预览。为某个分支启用它，这个分支就会拥有一套专属的登录服务，供你的应用用户使用：邮箱和密码、GitHub 和 Google 登录、会话、带角色和权限的组织，以及可以轮换的签名密钥。用户存放在该分支自己数据库中的 `relyt_auth` schema 里；你的后端对照 JWKS URL 验证短时效的 JWT；Postgres 行级安全则确保一个用户的行不会被另一个用户看到。**

启用只需大约一分钟。开源的 [`@relyt/auth`](https://github.com/relytcloud/relyt-auth-js) SDK 和一个完整的[多用户示例](https://github.com/relytcloud/relyt-auth-examples/tree/main/todo)都已发布在 GitHub 上。如果你想直接上手，分步指南见 [Relyt One 托管认证入门](/blog/zh-CN/managed-auth-getting-started)。

本文讲的是我们为什么要这样设计它。

## 认证是每个应用最先需要的，也是最先走样的

应用一旦存储任何个人数据，就需要三样东西：让用户登录的方式，让后端知道是谁在调用的方式，以及一个用户永远看不到另一个用户数据的保证。

通常的做法是选一家认证服务商，把它接进应用，然后在应用的整个生命周期里让两套系统保持一致。难点就在这里。

- **你的用户住在别处。** 认证服务商有自己的用户表，放在它自己的数据库里，用的是它自己的 ID。你的数据里每一个指向用户的外键，都要跨越系统边界。把「订单」和「用户」关联起来，意味着一次 API 调用或一个同步任务。
- **环境对不上。** 你的数据库有生产副本、预发副本，每个 Pull Request 还有一个分支。而认证服务商通常只有一个生产租户，也许再加一个测试租户。结果预览环境要么用生产用户登录，要么对着一个空租户登录，没有任何测试数据能对上。
- **授权只在一个地方执行。** 服务商告诉你的 API 调用方是谁。至于这个调用方能不能看某一行，要靠应用代码一条查询一条查询地检查。漏掉一个 `WHERE user_id = ...`，就是一次数据泄露。

编程智能体让这三点都更加突出。一个搭建新应用的智能体会匆忙接好认证，用的是当时配置的随便哪个租户，权限检查则是想起来哪里就在哪里做。

## 住在分支上的认证

托管认证的出发点和 Relyt One 的其他部分一样：属于你的数据的东西，就应该和你的数据放在一起。

![浏览器在分支的认证 URL 上登录，拿到短时效的 JWT 后调用你的 API；API 用 JWKS 验证令牌，并为行级安全设置 app.user_id，而这一切都发生在 relyt_auth 所在的同一个数据库里](/blog/assets/c6f96b7af2edb179.svg)

为分支启用托管认证后：

- **分支会获得自己的认证 URL。** 你的用户在那里注册和登录。它和你的应用是不同的源：登录表单直接提交到这里，所以你的后端既不会收到密码，也不会存储密码。
- **用户、会话和组织存储在你的数据库中**，位于该分支的 `relyt_auth` schema 里。它们就是一行行数据，你可以用 SQL 查看，而且就在它们所拥有的那些行旁边。
- **分支会获得一个 JWKS URL**，用于发布其令牌的公钥。你的后端只需要公钥那一半。
- **认证设置属于分支。** 每个分支都有自己的登录方式、来源和密钥，分支或项目被删除时它们也会一并移除。从已启用认证的分支创建的子分支，一开始是父分支的一份副本：它继承可信来源和邮箱设置，其数据库中也有一份父分支终端用户的副本，但会话和签名密钥不会被带过来。

该服务基于 [Better Auth](https://www.better-auth.com/) 构建，所以如果你用过它，登录接口和会话模型会让你感到很熟悉。

## 一个请求是如何完成认证的

无论应用使用什么框架，流程都是一样的。

1. **在认证 URL 上登录。** 浏览器把邮箱和密码提交到分支的认证 URL（或者走 GitHub、Google 登录流程），拿回一个会话令牌。
2. **用会话换取 JWT。** SDK 用会话换取一个有效期 15 分钟、以 EdDSA 签名的 JSON Web Token。它会缓存这个令牌，并在过期前刷新。
3. **带着 JWT 调用你的 API。** 浏览器发送 `Authorization: Bearer <jwt>`。它自己从不验证令牌。
4. **在服务端验证。** 你的 API 对照分支的 JWKS 检查签名，同时检查签发方（issuer）、受众（audience）和过期时间，并从 `sub` 中取出用户 ID。
5. **让 Postgres 来执行。** 你的 API 为当前事务设置用户 ID，行级安全策略决定这个用户能看到哪些行。

写成代码，第 4、5 步只有几行：

```ts
import { getClaims, RelytAuthUnavailableError } from "@relyt/auth/next";

export async function GET(req: Request) {
  let claims;
  try {
    claims = await getClaims(req, { issuer: process.env.RELYT_AUTH_ISSUER! });
  } catch (e) {
    // 无法获取公钥：返回 503，免得一次网络抖动就让所有人退出登录
    if (e instanceof RelytAuthUnavailableError) return new Response(null, { status: 503 });
    throw e;
  }
  if (!claims) return new Response(null, { status: 401 });

  const rows = await db.transaction(async (tx) => {
    await tx.execute(sql`select set_config('app.user_id', ${claims.sub}, true)`);
    return tx.select().from(notes); // 只有这个用户的笔记
  });
  return Response.json(rows);
}
```

```sql
ALTER TABLE notes ENABLE ROW LEVEL SECURITY;
ALTER TABLE notes FORCE ROW LEVEL SECURITY;

CREATE POLICY own_notes ON notes FOR ALL
  USING (owner_id = current_setting('app.user_id', true))
  WITH CHECK (owner_id = current_setting('app.user_id', true));
```

这个设置只在当前事务内有效，所以连接池里的连接永远不会把它带到下一个请求。如果代码忘了设置它，`current_setting` 什么也不返回，策略也就匹配不到任何行。漏掉检查的结果是拒绝访问，而绝不会是数据泄露。

## 预览版包含什么

### 登录方式

- **邮箱和密码。**
- **GitHub 和 Google。** 把你的 OAuth App 的 Client ID 和 Client Secret 粘贴到控制台即可；Secret 只能写入，之后不会再显示。跨源的社交登录使用一次性交接码配合 PKCE，因此无需第三方 Cookie 也能工作。

### 重定向与来源

认证 URL 只响应来自你所列出的**可信来源**的浏览器请求，例如 `https://app.example.com` 或 `http://localhost:3000`，不支持通配符。社交登录只会重定向回精确注册过的**回调 URL**。这两个列表都按分支配置，所以预览分支可以信任 `https://pr-128.example.dev`，而生产环境不必跟着信任它。

### 令牌与密钥

- **JWT 有效期为 15 分钟**，以 EdDSA 签名。SDK 只接受 EdDSA，用其他任何算法签名的令牌都会被拒绝。
- **签名密钥可按需轮换。** 轮换后，新密钥会为下一个令牌签名；旧密钥在 JWKS 中保留 960 秒（约 16 分钟），让已签发的令牌仍能通过验证；边缘节点会在约 30 秒内获取新的密钥集。宽限期内最多保留三个旧密钥，因此在宽限期内继续轮换会被拒绝，直到有一个旧密钥过期。验证方在遇到未知的密钥 ID 时应重新获取 JWKS；`@relyt/auth` 已经这样做了。
- **会话可以撤销**，可以针对单个用户，也可以针对分支上的所有人。被撤销的会话无法再刷新，也无法再换取新令牌。已签发的 JWT 在过期前仍然有效，最长 15 分钟，所以如果必须立即中止访问，请在你的应用中也做相应检查。

### 组织、角色与权限

用户可以创建组织，所有者和管理员可以通过认证 URL 的组织接口邀请成员（预览版不会发送邀请邮件或验证邮件，因此受邀者必须使用已验证的邮箱登录，目前也就是通过 GitHub 或 Google 登录）。每个成员都有一个角色，你可以在控制台中定义自己的**资源和操作**，例如一个带有 `create`、`read`、`update` 和 `delete` 操作的 `todo` 资源，并把它们授权给角色。内置角色有 `owner`、`admin` 和 `member`；你最多可以添加 16 个自定义角色和 32 个自定义资源。

当用户有一个活跃组织时，JWT 会带上它：

```json
"o": {
  "id": "…",
  "slug": "acme",
  "role": "member",
  "perms": ["todo:create", "todo:read", "todo:update"]
}
```

你的 API 针对具体操作检查 `o.perms`，再由一条基于 `org_id` 的 RLS 策略决定哪些团队行可见。权限列表必须能装进令牌，因此控制台把总量限制在 64 个 `resource:action` 对、1 KiB 以内；万一装不下，`perms` 会是 `null`，这时你的 API 应当拒绝请求，而不是去猜。

### 日常运维

- **Users**（用户）列出分支的终端用户，包括邮箱是否已验证、角色、活跃会话和最近活动时间，并支持在已加载的列表中搜索。
- **Integration code**（集成代码）展示环境变量，以及 Next.js 中间件、路由处理器、浏览器客户端和社交登录的代码片段，并已填好该分支的 URL。
- **Disable auth**（停用认证）会停止登录，但不会删除用户、设置或密钥。重新启用后，一切都还是你离开时的样子。

## 需要留意的一点：BYPASSRLS

只有当你的应用以一个受行级安全约束的角色连接时，RLS 才能保护你。通过 Relyt One 控制台创建的数据库用户带有 `BYPASSRLS`，Postgres 会对它们悄无声息地跳过所有策略。任何依赖 RLS 的应用，都请用 SQL 创建一个专用的应用角色：

```sql
CREATE ROLE app_user LOGIN NOBYPASSRLS NOSUPERUSER NOCREATEROLE NOCREATEDB;
\password app_user
GRANT USAGE ON SCHEMA public TO app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON notes TO app_user;
```

`\password` 是一个 psql 命令，会提示你输入密码，这样密码就不会留在 shell 历史里。然后用 `SELECT rolbypassrls FROM pg_roles WHERE rolname = 'app_user';` 检查这个角色，结果应为 `f`。示例应用的测试套件里有一个用例，专门展示了这一步做错时会发生什么。

## 预览版限制

托管认证目前是预览版，在我们了解大家如何使用它的过程中，有些功能刻意做得很小，有些暂时缺席：

- 只有组织的所有者和管理员可以启用认证、修改认证设置。
- 子分支不会复制社交登录凭据，而且一个 GitHub OAuth App 只能注册一个回调 URL，所以请每个分支使用一个 OAuth App。
- 暂不发送任何邮件：没有验证邮件、重置密码邮件或邀请邮件。
- 在正式发布（GA）之前，名称、限制和控制台布局仍可能变化。

## 试一试

1. 在控制台中打开一个分支，进入 **Auth**（认证），选择 **Enable auth**（启用认证）。
2. 按照 [Relyt One 托管认证入门](/blog/zh-CN/managed-auth-getting-started)，为应用加上登录、令牌验证和 RLS。
3. 或者克隆 [TODO 示例](https://github.com/relytcloud/relyt-auth-examples/tree/main/todo)，它用个人 TODO 和团队 TODO 实现了上述全部内容，还附带测试套件。

告诉我们哪里出了问题。预览期正是做出改变的时候。

[试用托管认证](/auth/signin?ref=blog-managed-auth-preview)
