Fastify のセッション

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

ベアラートークンを受け取る API を保護する場合は、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。リポジトリではなく、シークレットストアに保管してください。

セットアップ

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 のページを参照してください。

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 です:

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

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

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

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 では、プロパティを一度宣言してください:

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 に分割されます。

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

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 の経過後に削除してください。

現在の制限