# Relyt One のマネージド認証、プレビュー版を公開

アプリのユーザー向けのサインアップ、サインイン、セッション、組織を、ブランチのすぐ隣で動かし、ユーザーはあなた自身のデータベースに保存します。トークンは JWKS で検証する短命な EdDSA JWT で、残りは行レベルセキュリティが引き受けます。

**Relyt One でマネージド認証のプレビュー提供を開始しました。ブランチで有効にすると、そのブランチにアプリのユーザー向けの専用サインインサービスが用意されます。メールアドレスとパスワード、GitHub と Google、セッション、ロールと権限を備えた組織、そしてローテーションできる署名キーがそろっています。ユーザーはブランチ自身のデータベース内の `relyt_auth` スキーマに保存され、バックエンドは短命な JWT を JWKS URL で検証し、Postgres の行レベルセキュリティがあるユーザーの行を別のユーザーから守ります。**

有効化にかかるのは 1 分ほどです。オープンソースの [`@relyt/auth`](https://github.com/relytcloud/relyt-auth-js) SDK と、完全な[マルチユーザーのサンプル](https://github.com/relytcloud/relyt-auth-examples/tree/main/todo)を GitHub で公開しています。すぐに試したい方は、ステップバイステップのガイド「[Relyt One マネージド認証 入門](/blog/ja/managed-auth-getting-started)」をご覧ください。

この記事では、なぜこのような作りにしたのかをお話しします。

## 認証は、どのアプリにも最初に必要で、最初にずれていくもの

アプリが個人に関わるデータを何か 1 つでも保存した瞬間から、3 つのものが必要になります。ユーザーがサインインする手段、誰が呼び出しているのかをバックエンドが知る手段、そしてあるユーザーが別のユーザーのデータを決して見られないという保証です。

よくあるやり方は、認証プロバイダーを選んでアプリにつなぎ、その後アプリが続く限り 2 つのシステムの整合性を保ち続けることです。難しくなるのはここからです。

- **ユーザーが別の場所にいる。** 認証プロバイダーは独自のユーザーテーブルを、独自のデータベースに、独自の ID で持っています。データからユーザーへの外部キーは、どれもシステムの境界をまたいでいます。「注文」と「ユーザー」を結合するには、API 呼び出しか同期ジョブが必要です。
- **環境がそろわない。** データベースには本番用、ステージング用、そしてプルリクエストごとのブランチがあります。認証プロバイダーには通常、本番テナントが 1 つと、せいぜいテスト用が 1 つあるだけです。結果としてプレビュー環境は、本番のユーザーでサインインするか、どのテストフィクスチャとも一致しない空のテナントでサインインすることになります。
- **認可は 1 か所でしか強制されない。** プロバイダーが API に伝えるのは、呼び出し元が誰かということだけです。その呼び出し元が特定の行を見てよいかどうかは、アプリケーションコードでクエリごとにチェックされます。`WHERE user_id = ...` を 1 つ書き忘れれば、データ漏えいです。

コーディングエージェントは、この 3 つをいっそう深刻にします。新しいアプリをセットアップするエージェントは、たまたま設定されているテナントに対して急いで認証を組み込み、思い出したところでだけ権限をチェックします。

## ブランチの上で動く認証

マネージド認証の出発点は、Relyt One のほかの部分と同じ考え方です。データに属するものは、データと一緒に置くべきだということです。

![ブラウザーがブランチの認証 URL でサインインして短命な JWT を受け取り、API を呼び出す。API は JWKS で JWT を検証し、relyt_auth と同じデータベースで行レベルセキュリティ用に app.user_id を設定する](/blog/assets/29483cec0b2a8d28.svg)

ブランチでマネージド認証を有効にすると、次のようになります。

- **ブランチに専用の認証 URL が割り当てられます。** ユーザーはそこでサインアップとサインインを行います。アプリとは別のオリジンで、サインインフォームはそこへ直接送信されます。そのため、バックエンドがパスワードを受け取ることも、保存することもありません。
- **ユーザー、セッション、組織はあなたのデータベースに保存されます。** 保存先はそのブランチの `relyt_auth` スキーマです。SQL で確認できる行であり、それらが所有する行のすぐ隣にあります。
- **ブランチに JWKS URL が割り当てられます。** ここでブランチのトークン用の公開鍵が公開されます。バックエンドに必要なのは公開鍵の側だけです。
- **認証の設定はブランチに属します。** ブランチごとにサインイン方法、オリジン、キーを持ち、ブランチやプロジェクトを削除するとそれらも削除されます。認証が有効なブランチの子ブランチは、親のコピーとして始まります。信頼済みオリジンとメールの設定を引き継ぎ、そのデータベースには親のエンドユーザーのコピーが入りますが、セッションと署名キーは引き継がれません。

このサービスは [Better Auth](https://www.better-auth.com/) の上に構築されているので、Better Auth を使ったことがあれば、サインインのエンドポイントやセッションモデルには見覚えがあるはずです。

## リクエストが認証される流れ

フレームワークが何であれ、流れはどのアプリでも同じです。

1. **認証 URL でサインインする。** ブラウザーはメールアドレスとパスワードをブランチの認証 URL に送信し（または GitHub や Google を経由し）、セッショントークンを受け取ります。
2. **セッションを JWT に交換する。** SDK はセッションを、EdDSA で署名された有効期間 15 分の JSON Web Token と交換します。トークンはキャッシュされ、有効期限が切れる前に更新されます。
3. **JWT を付けて API を呼び出す。** ブラウザーは `Authorization: Bearer <jwt>` を送信します。ブラウザー自身がトークンを検証することはありません。
4. **サーバーで検証する。** API は、ブランチの JWKS に対して署名を確認するとともに、発行者、オーディエンス、有効期限をチェックし、`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 アプリのクライアント ID とシークレットをコンソールに貼り付けます。シークレットは書き込み専用で、二度と表示されません。オリジンをまたぐソーシャルサインインでは、PKCE 付きの使い捨ての受け渡しコードを使うので、サードパーティ Cookie なしで動作します。

### リダイレクトとオリジン

認証 URL が応答するのは、`https://app.example.com` や `http://localhost:3000` のように、あなたが登録した**信頼済みオリジン**からのブラウザーだけです。ワイルドカードは使えません。ソーシャルサインインがリダイレクトで戻る先も、完全一致で登録された**コールバック URL** に限られます。どちらのリストもブランチごとなので、本番には信頼させずに、プレビューブランチだけに `https://pr-128.example.dev` を信頼させることができます。

### トークンとキー

- **JWT の有効期間は 15 分**で、EdDSA で署名されます。SDK は EdDSA しか受け付けないので、ほかのアルゴリズムで署名されたトークンは拒否されます。
- **署名キーは必要なときにローテーションできます。** ローテーション後は新しいキーが次のトークンに署名し、発行済みのトークンが引き続き検証できるよう、古いキーは 960 秒（約 16 分）間 JWKS に残ります。エッジノードは約 30 秒以内に新しいキーセットを取り込みます。この猶予期間中に保持される古いキーは最大 3 つなので、期間内にさらにローテーションしようとすると、いずれかのキーが期限切れになるまで拒否されます。検証側は、未知のキー ID を見つけたら JWKS を再取得する必要があります。`@relyt/auth` はそのように動作します。
- **セッションは失効させられます。** 対象は 1 人のユーザーでも、ブランチ上の全員でも構いません。失効したセッションは、更新することも新しいトークンと交換することもできなくなります。ただし発行済みの 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 ポリシーがどのチームの行が見えるかを決めます。権限のリストはトークンに収まらなければならないため、コンソールでは合計を `resource:action` の組 64 個、かつ 1 KiB までに制限しています。万一収まらなかった場合、`perms` は `null` になります。そのとき API は推測せず、リクエストを拒否してください。

### 運用

- **Users**（ユーザー）には、ブランチのエンドユーザーが表示されます。メールアドレスが確認済みかどうか、ロール、アクティブなセッション、最終アクティビティを確認でき、読み込まれた一覧の中を検索することもできます。
- **Integration code**（連携コード）には、Next.js のミドルウェア、ルートハンドラー、ブラウザークライアント、ソーシャルサインイン向けの環境変数とスニペットが、ブランチの URL を埋め込んだ状態で表示されます。
- **Disable auth**（認証を無効化）はサインインを止めますが、ユーザー、設定、キーは削除しません。再び有効にすれば、すべてが元のままです。

## 注意点：BYPASSRLS

行レベルセキュリティが守ってくれるのは、ポリシーが適用されるロールでアプリが接続している場合だけです。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 のコマンドなので、パスワードがシェルの履歴に残ることはありません。続いて `SELECT rolbypassrls FROM pg_roles WHERE rolname = 'app_user';` で確認します。結果は `f` になるはずです。サンプルアプリのテストスイートには、ここを間違えると何が起きるかを示すケースが含まれています。

## プレビュー版の制限

マネージド認証はプレビュー版です。皆さんがどう使うのかを学ぶ間、いくつかの機能は意図的に小さくしているか、まだ用意していません。

- 認証の有効化と設定の変更ができるのは、組織のオーナーと管理者だけです。
- 子ブランチはソーシャルサインインの認証情報をコピーしません。また、GitHub の OAuth アプリに登録できるコールバック URL は 1 つだけなので、ブランチごとに 1 つの OAuth アプリを使ってください。
- メールはまだ送信されません。確認メール、パスワードリセットのメール、招待メールのいずれもありません。
- 一般提供（GA）までに、名前、制限、コンソールのレイアウトが変わる可能性があります。

## 試してみる

1. コンソールでブランチを開き、**Auth**（認証）に移動して **Enable auth**（認証を有効化）を選びます。
2. 「[Relyt One マネージド認証 入門](/blog/ja/managed-auth-getting-started)」に沿って、アプリにサインイン、トークン検証、RLS を追加します。
3. あるいは [TODO サンプル](https://github.com/relytcloud/relyt-auth-examples/tree/main/todo)をクローンしてください。個人とチームの TODO、そしてテストスイートで、これらすべてを実装しています。

うまくいかないところがあれば教えてください。変えるなら、プレビューの今がそのときです。

[マネージド認証を試す](/auth/signin?ref=blog-managed-auth-preview)
