Fastify のセッション
@keenauth/node/bff は、Fastify 5 アプリケーションにサインインを追加します。サーバーは OAuth クライアントとして動作し、すべてのトークンをサーバー側に保持します。ブラウザが受け取るのは暗号化された HttpOnly のセッション Cookie のみで、アクセストークン、リフレッシュトークン、ID トークンは一切渡されません。これは Backend-for-Frontend (BFF) パターンです。このパッケージは Fastify をインポートしないため、追加の依存関係は発生しません。まだ npm には公開されていません。
ベアラートークンを受け取る API を保護する場合は、API の保護を参照してください。
始める前に
- ダッシュボードで、環境内に Web アプリケーションを作成します。
- クライアント認証方式を選択します。クライアントシークレット (
client_secret_basic) を使うか、公開鍵をアプリケーションに登録した鍵ペアでprivate_key_jwtを使います。共有シークレットがサーバーの外に出ないため、private_key_jwtをおすすめします。 - リダイレクト URI
https://app.acme.example/auth/callbackと、サインアウト後のリダイレクト URIhttps://app.acme.example/を追加します。 - 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 です:
- セッションのないページリクエスト (
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。
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 は次の処理を行います:
- セッション Cookie を削除します (ストアを使用している場合は、保存されたセッションも削除します)。
- リフレッシュトークンを取り消します。
- ブラウザを環境のログアウトエンドポイントに移動させ、そこからアプリに戻します。
ブラウザが別サイトからのリクエストとして示すリクエストは拒否されます。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 の経過後に削除してください。
現在の制限
- DPoP にバインドされたトークンと、バックチャネルログアウトはまだ扱われません。
- リフレッシュの共有は、1 つのサーバープロセス内に限られます。複数のインスタンスで動作させる場合は、アプリケーションの設定
refresh_reuse_interval_s(デフォルトは 5 秒) により、並行するリフレッシュが成功します。0に設定すると、2 回目のリフレッシュでセッションが終了します。