
# Express のセッション

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

Bearer トークンを受け取る API を保護する場合は、[API の保護](/docs/sdk/protect-api) を参照してください。管理 API を呼び出す場合は、[Node.js 管理 SDK](/docs/sdk/node) を参照してください。

## 始める前に

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 express from "express";
import { createBff, expressRequireAuth, expressSession } 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 = express();
app.use(expressSession(bff));

app.get("/orders", expressRequireAuth(bff), (req, res) => {
  const { session } = req.keenauth!;
  res.send(`Signed in as ${session!.user.email}`);
});
```

`private_key_jwt` を使う場合は、シークレットの代わりに秘密鍵を渡します。SDK は、トークンエンドポイントと PAR エンドポイントへのリクエストごとに、新しいクライアントアサーションに署名します (`aud` は issuer、`jti` はランダム値、有効期間は 120 秒、`kid` は鍵の RFC 7638 サムプリントです)。アルゴリズムは鍵に従います。RSA の場合は `RS256` (または `alg: "PS256"`)、P-256 の場合は `ES256`、Ed25519 の場合は `EdDSA` を使用します。

```ts
import { readFileSync } from "node:fs";

const bff = createBff({
  issuer: "https://acme.eu.keenauth.app",
  clientId: "app_01j9zzzzzzzzzzzzzzzzzzzz",
  clientAuth: { method: "private_key_jwt", privateKey: readFileSync("bff-key.pem", "utf8") },
  appBaseUrl: "https://app.acme.example",
  secret: process.env.KEENAUTH_COOKIE_SECRET!,
});
```

鍵が KMS または HSM に保管されている場合は、代わりに `clientAuth: { method: "private_key_jwt", assertion }` を渡します。ここで `assertion()` は、呼び出すたびに新しい署名済みクライアントアサーションを返します。`privateKey` と `assertion` のどちらか一方だけを渡し、両方は渡さないでください。

`appBaseUrl` は、アプリの公開アドレスです。SDK はリダイレクト URI とすべてのリダイレクトをこの値から作成します。リクエストの `Host` や `X-Forwarded-*` ヘッダーからは作成しないため、`trust proxy` の設定は影響しません。平文の `http` は `localhost` でのみ許可されます。

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

## ルート

`expressSession` は 3 つのルートを提供し、それ以外のリクエストではセッションを読み込みます。

| ルート | 役割 |
|---|---|
| `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 などのそれ以外の値は無視され、ユーザーは `/` に遷移します。

## ルートの保護

`expressRequireAuth(bff)` は、セッションがあるリクエストだけを通過させます。

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

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

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

`req.keenauth` には次の値が入ります。

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

```ts
app.get("/api/orders", expressRequireAuth(bff), async (req, res) => {
  const upstream = await fetch("https://api.acme.example/v1/orders", {
    headers: { Authorization: `Bearer ${req.keenauth!.accessToken}` },
  });
  res.status(upstream.status).json(await upstream.json());
});
```

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

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

declare module "express-serve-static-core" {
  interface Request {
    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 に格納されます。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 回目のリフレッシュでセッションが終了します。
