
# Fastify のセッション

`@keenauth/node/bff` は、Fastify 5 アプリケーションにサインインを追加します。サーバーは OAuth クライアントとして動作し、すべてのトークンをサーバー側に保持します。ブラウザが受け取るのは暗号化された HttpOnly のセッション Cookie のみで、アクセストークン、リフレッシュトークン、ID トークンは一切渡されません。これは Backend-for-Frontend (BFF) パターンです。このパッケージは Fastify をインポートしないため、追加の依存関係は発生しません。まだ npm には公開されていません。

ベアラートークンを受け取る API を保護する場合は、[API の保護](/docs/sdk/protect-api)を参照してください。

## 始める前に

1. ダッシュボードで、環境内に Web アプリケーションを作成します。
2. クライアント認証方式を選択します。クライアントシークレット (`client_secret_basic`) を使うか、公開鍵をアプリケーションに登録した鍵ペアで `private_key_jwt` を使います。共有シークレットがサーバーの外に出ないため、`private_key_jwt` をおすすめします。
3. リダイレクト URI `https://app.acme.example/auth/callback` と、サインアウト後のリダイレクト URI `https://app.acme.example/` を追加します。
4. 32 文字以上の Cookie 用シークレットを生成します。例: `openssl rand -base64 32`。リポジトリではなく、シークレットストアに保管してください。

## セットアップ

```ts
import Fastify from "fastify";
import { createBff, fastifyRequireAuth, fastifySession } from "@keenauth/node/bff";

const bff = createBff({
  issuer: "https://acme.eu.keenauth.app", // your environment's issuer, exactly
  clientId: "app_01j9zzzzzzzzzzzzzzzzzzzz",
  clientAuth: { method: "client_secret_basic", clientSecret: process.env.KEENAUTH_CLIENT_SECRET! },
  appBaseUrl: "https://app.acme.example",
  secret: process.env.KEENAUTH_COOKIE_SECRET!,
});

const app = Fastify();
fastifySession(app, bff); // on the root instance, so it applies to every route

app.get("/orders", { preHandler: fastifyRequireAuth(bff) }, async (request) => {
  return `Signed in as ${request.keenauth!.session!.user.email}`;
});
```

`private_key_jwt` を使う場合は、シークレットの代わりに秘密鍵を渡します: `clientAuth: { method: "private_key_jwt", privateKey: readFileSync("bff-key.pem", "utf8") }`。SDK は、トークンエンドポイントと PAR エンドポイントへのリクエストごとに新しいクライアントアサーションを署名します。鍵が KMS や HSM に保管されている場合は、`clientAuth: { method: "private_key_jwt", assertion }` を渡してください。`assertion()` は呼び出されるたびに新しく署名されたクライアントアサーションを返します。アサーションと対応アルゴリズムについては、[Express のページ](/docs/sdk/express)を参照してください。

`fastifySession` はルートインスタンスで呼び出してください。カプセル化されたプラグイン内で呼び出すと、そのプラグインのルートにのみ適用されます。

`appBaseUrl` はアプリの公開アドレスです。SDK はリダイレクト URI とすべてのリダイレクトをこの値から構築します。リクエストの `Host` ヘッダーや `X-Forwarded-*` ヘッダーからは構築しないため、`trustProxy` オプションの影響を受けません。平文の `http` は `localhost` の場合のみ受け付けられます。

オプションは `createBff` の実行時に検証されるため、設定エラーがあるとアプリは起動時に停止します。

## ルート

`fastifySession` は 3 つのルートを追加し、それ以外のリクエストでは `onRequest` フックでセッションを読み取ります:

| ルート | 内容 |
|---|---|
| `GET /auth/login` | サインインを開始します。任意のクエリパラメータ: `returnTo` (ユーザーの着地先) と `organization` (組織にサインインする)。 |
| `GET /auth/callback` | サインインを完了し、セッション Cookie を設定します。 |
| `GET` または `POST /auth/logout` | KeenAuth でサインアウトし、アプリに戻ります。 |

サインインには、PKCE (S256) を用いた認可コードフローを使います。環境が対応している場合、リクエストはサーバーに送信されます (PAR)。コールバックでは、SDK が `state`、`iss`、および ID トークン (`nonce` を含む) を検証します。サインインが成功するたびに新しいセッションが作成され、古いセッションは破棄されます。

`returnTo` は `/orders?tab=open` のような、アプリ自身のパスである必要があります。`//evil.example` や完全な URL など、それ以外の値は無視され、ユーザーは `/` に移動します。

## ルートの保護

`fastifyRequireAuth(bff)` は、セッションがあるリクエストのみを通す `preHandler` です:

- セッションのないページリクエスト (`GET` または `HEAD`) は、`returnTo` を付けて `/auth/login` にリダイレクトされます。
- `/api/…` へのリクエスト、または他のメソッドのリクエストには、問題ドキュメントを含む `401` が返されます。どのパスでも `401` で応答するには `{ api: true }` を渡します。

`POST`、`PUT`、`PATCH`、`DELETE` など安全でないメソッドでは、ブラウザがそのリクエストがアプリから来たことを示す必要があります。`Sec-Fetch-Site: same-origin` ヘッダー、またはアプリのオリジンと一致する `Origin` ヘッダーが必要です。それ以外のリクエストには `403` が返されます。これは `SameSite=Lax` に加えて、クロスサイトリクエストフォージェリから保護します。

## セッションを使い、API を呼び出す

`request.keenauth` には次の情報が含まれます:

- `session`: `null`、またはサインイン済みのユーザー。`session.user` には、`subject`、`email`、`name`、`orgId`、`orgRoles` などの型付き ID トークンクレームが含まれます。すべてのクレームは `session.user.claims` にあります。セッションオブジェクトにトークンが含まれることはありません。
- `accessToken`: サーバー側の呼び出しに使える有効なアクセストークン、または `null`。

```ts
app.get("/api/orders", { preHandler: fastifyRequireAuth(bff) }, async (request, reply) => {
  const upstream = await fetch("https://api.acme.example/v1/orders", {
    headers: { Authorization: `Bearer ${request.keenauth!.accessToken}` },
  });
  return reply.code(upstream.status).send(await upstream.json());
});
```

TypeScript では、プロパティを一度宣言してください:

```ts
import type { SessionContext } from "@keenauth/node/bff";

declare module "fastify" {
  interface FastifyRequest {
    keenauth?: SessionContext | null;
  }
}
```

アクセストークンの有効期限が 60 秒以内に迫ると、SDK はハンドラーの実行前にトークンを更新します。更新のたびにリフレッシュトークンはローテーションされ、新しいセッション Cookie がレスポンスに設定されます。サーバーがリフレッシュトークンを拒否した場合、セッションは終了します。一時的なサーバーエラーの場合、セッションは変更されません。

アクセストークンをブラウザに送信しないでください。

## 組織を切り替える

`bff.switchOrganizationPath("org_01j9…", "/dashboard")` は、そのユーザーを該当組織で再度サインインさせる `/auth/login` へのリンクを返します。KeenAuth がメンバーシップを確認します。サインイン後、`session.user.orgId` にその組織が格納されます。

## サインアウト

フォームから `/auth/logout` に `POST` を送信します。SDK は次の処理を行います:

1. セッション Cookie を削除します (ストアを使用している場合は、保存されたセッションも削除します)。
2. リフレッシュトークンを取り消します。
3. ブラウザを環境のログアウトエンドポイントに移動させ、そこからアプリに戻します。

ブラウザが別サイトからのリクエストとして示すリクエストは拒否されます。`Sec-Fetch-Site` を送信しないブラウザでは、自分のページからの `POST` によってのみサインアウトできます。

## セッションの保存

デフォルトでは、セッション全体が Cookie に保存されます。`secret` から導出した鍵を用いた AES-256-GCM で暗号化・認証されます。変更またはコピーされた Cookie は拒否されます。Cookie の名前は `__Host-keenauth_session.0` で、`Secure`、`HttpOnly`、`SameSite=Lax`、`Path=/` が設定されます。`http://localhost` では `__Host-` プレフィックスと `Secure` は省略されます。大きなセッションは最大 3 つの Cookie に分割されます。

トークンをサーバー側に保持するには、ストアを渡します:

```ts
import { createBff, type SessionStore } from "@keenauth/node/bff";

const store: SessionStore = {
  get: async (id) => JSON.parse((await redis.get(`sess:${id}`)) ?? "null"),
  set: async (id, session, ttl) => void (await redis.set(`sess:${id}`, JSON.stringify(session), { EX: ttl })),
  delete: async (id) => void (await redis.del(`sess:${id}`)),
};
const bff = createBff({ /* … */ store });
```

この場合、Cookie には暗号化されたランダムなセッション ID のみが保存されます。サインインするたびに新しい ID が作成され、以前のレコードは削除されます。ストアにはトークンが含まれるため、アクセスを制限してください。`MemorySessionStore` は開発とテストの専用です。

セッションの有効期間は最大 7 日間 (`sessionMaxAge`) です。更新によって延長されることはありません。シークレットを変更するには、リストを渡します: `secret: [newSecret, oldSecret]`。古いシークレットは `sessionMaxAge` の経過後に削除してください。

## 現在の制限

- DPoP にバインドされたトークンと、バックチャネルログアウトはまだ扱われません。
- リフレッシュの共有は、1 つのサーバープロセス内に限られます。複数のインスタンスで動作させる場合は、アプリケーションの設定 `refresh_reuse_interval_s` (デフォルトは 5 秒) により、並行するリフレッシュが成功します。`0` に設定すると、2 回目のリフレッシュでセッションが終了します。
