Express のセッション
@keenauth/node/bff は、Express 4 または 5 のアプリケーションにサインイン機能を追加します。サーバーが OAuth クライアントとして動作し、すべてのトークンをサーバー側で保持します。ブラウザが受け取るのは暗号化された HttpOnly のセッション Cookie だけで、アクセストークン、リフレッシュトークン、ID トークンは一切渡されません。これは backend-for-frontend (BFF) パターンです。このパッケージは Express をインポートしないため、追加の依存関係は発生しません。まだ npm には公開されていません。
Bearer トークンを受け取る API を保護する場合は、API の保護 を参照してください。管理 API を呼び出す場合は、Node.js 管理 SDK を参照してください。
始める前に
- ダッシュボードで、環境内に 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 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 を使用します。
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です。
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 では、プロパティを一度だけ宣言してください。
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 は次の処理を行います。
- セッション Cookie を削除します (ストアを使用している場合は、保存されたセッションも削除します)。
- リフレッシュトークンを取り消します。
- 環境のログアウトエンドポイントにブラウザを移動させ、アプリに戻します。
ブラウザが別のサイトからのリクエストとしてマークしたリクエストは拒否されます。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 に分割されます。
トークンをサーバー側に保持するには、ストアを渡します。
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 回目のリフレッシュでセッションが終了します。