Next.js SDK
@keenauth/nextjs は Next.js App Router アプリケーションにサインインを追加します。アプリが OAuth クライアントとなり、すべてのトークンをサーバー側で保持します。ブラウザが受け取るのは暗号化された HttpOnly のセッション Cookie だけで、アクセストークン、リフレッシュトークン、ID トークンは一切見えません。これは Backend-for-Frontend (BFF) パターンです。このパッケージには実行時依存関係がありません。まだ npm には公開されていません。
開始する前に
- ダッシュボードで、環境内に Web アプリケーションを作成します。
private_key_jwtのクライアント認証を選択し、公開鍵を登録します。クライアントシークレットも使用できます。- リダイレクト URI
https://app.acme.example/auth/callbackと、サインアウト後のリダイレクト URIhttps://app.acme.example/を追加します。 - 32 文字以上の Cookie シークレットを生成します(例:
openssl rand -base64 32)。リポジトリではなく、シークレットストアに保管してください。
インスタンスを作成する
// lib/auth.ts
import { createKeenAuth } from "@keenauth/nextjs";
export const auth = createKeenAuth({
issuer: "https://acme.eu.keenauth.app", // your environment's issuer, exactly
clientId: "app_01j9zzzzzzzzzzzzzzzzzzzz",
clientAuth: { method: "private_key_jwt", privateKey: JSON.parse(process.env.KEENAUTH_CLIENT_KEY!) },
appBaseUrl: "https://app.acme.example",
secret: process.env.KEENAUTH_COOKIE_SECRET!,
});
appBaseUrl はアプリの公開アドレスです。SDK はリダイレクト URI とすべてのリダイレクトをここから組み立てます。リクエストの Host または X-Forwarded-* ヘッダーからは組み立てません。平文の http は localhost の場合にのみ受け入れられます。
オプションはインスタンスの作成時に検証されるため、設定エラーがあればアプリは起動時に停止します。
ルートを追加する
// app/auth/[...keenauth]/route.ts
import { auth } from "@/lib/auth";
export const { GET, POST } = auth.handlers;
これにより、次の 3 つのルートが提供されます:
| ルート | 役割 |
|---|---|
/auth/login |
サインインを開始します。任意の returnTo クエリパラメーターで、サインイン後にユーザーが移動する先を指定します。 |
/auth/callback |
サインインを完了し、セッション Cookie を設定します。 |
/auth/logout |
KeenAuth でサインアウトし、アプリに戻します。 |
サインインには PKCE 付きの認可コードフローを使用します。環境がサポートしている場合、リクエストはサーバーに事前送信(PAR)されます。コールバックでは、SDK は state、iss と ID トークン(その nonce を含む)を検証します。
returnTo はアプリ自身のパス(例: /orders?tab=open)である必要があります。//evil.example や完全な URL などそれ以外の値は無視され、ユーザーは / に移動します。
ページを保護しトークンを更新する
// proxy.ts (middleware.ts before Next.js 16)
import { NextResponse, type NextRequest } from "next/server";
import { auth } from "@/lib/auth";
export async function proxy(request: NextRequest) {
const result = await auth.proxy(request, {
protect: (path) => path.startsWith("/orders") || path.startsWith("/api/"),
});
if (result.response) return result.response;
const response = NextResponse.next({ request: { headers: result.requestHeaders } });
result.apply(response);
return response;
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};
セッションのない保護対象パスの場合:
- ページリクエスト(
GETまたはHEAD)は、returnToを付けて/auth/loginにリダイレクトされます。 /api/…へのリクエスト、または別のメソッドのリクエストには、問題ドキュメントを添えた401が返されます。
アクセストークンの有効期限が 60 秒以内に切れると、プロキシがそれを更新します。更新のたびにリフレッシュトークンもローテーションされ、新しいセッション Cookie がレスポンスに設定されます。requestHeaders は、同じリクエストを描画するページに新しいセッションを渡します。
サーバーがリフレッシュトークンを拒否した場合、セッションは終了します。一時的なサーバーエラーの場合、セッションは変更されません。
また、各 Server Function とルートハンドラーの内部でもセッションを確認してください。プロキシの matcher がパスを見落とすことがあります。
セッションを読み取る
Server Component では、Cookie ストアを渡します:
// app/orders/page.tsx
import { cookies } from "next/headers";
import { auth } from "@/lib/auth";
export default async function Orders() {
const session = await auth.getSession(await cookies());
if (!session) return <a href="/auth/login?returnTo=/orders">Sign in</a>;
return <p>Signed in as {session.user.email}</p>;
}
session.user には ID トークンの型付きクレームが含まれます: subject, email, emailVerified, name, orgId, orgRoles など。カスタムクレームを含むすべてのクレームは session.user.claims にあります。セッションオブジェクトにトークンが含まれることはありません。
Server Component は Cookie を設定できないため、そこでセッションを読み取っても更新されることはありません。
API を呼び出す
Server Component では、Cookie ストアからアクセストークンを取得します:
const token = await auth.getAccessToken(await cookies());
const res = await fetch("https://api.acme.example/v1/orders", {
headers: { Authorization: `Bearer ${token}` },
});
getAccessToken は次の場合に失敗します:
- トークンの有効期限が近く、本来はプロキシによって更新されているべき場合は
refresh_requiredとなります; - セッションがない場合は
login_requiredとなります。
ルートハンドラーでは、updateSession も更新を行います:
// app/api/orders/route.ts
import { auth } from "@/lib/auth";
export async function GET(request: Request) {
const { accessToken, apply } = await auth.updateSession(request);
if (!accessToken) return new Response(null, { status: 401 });
const upstream = await fetch("https://api.acme.example/v1/orders", {
headers: { Authorization: `Bearer ${accessToken}` },
});
const response = new Response(upstream.body, { status: upstream.status });
apply(response);
return response;
}
auth.authorizedFetch は 1 回の呼び出しで同じことを行います。getAccessToken と同様にトークンを読み取り、Authorization ヘッダーを追加します。DPoP を使用している場合は、証明も追加します:
const res = await auth.authorizedFetch(await cookies(), "https://api.acme.example/v1/orders");
アクセストークンを Client Component に渡さないでください。API はサーバーから呼び出し、ブラウザにはデータのみを送信してください。API でこれらのトークンを検証する方法については、API でのトークンの検証 を参照してください。
サインアウト
/auth/logout へのリンクを設定するか、フォームから POST を送信します。SDK は次の処理を行います:
- セッション Cookie を削除します;
- リフレッシュトークンを取り消します;
- 環境のログアウトエンドポイントにブラウザを送り、そこからアプリに戻します。
ブラウザが別サイト由来と判断したリクエストは拒否されるため、別のサイトからユーザーをサインアウトさせることはできません。Sec-Fetch-Site を送信しないブラウザでは、アプリ自身のページからの POST でのみサインアウトできます。/auth/logout に POST するフォームの使用を推奨します。
送信者制約付きトークン (DPoP)
DPoP(RFC 9449)を使用すると、アクセストークンは、サーバーのみが保持する鍵に紐付けられます。たとえば API ログから漏えいしたトークンも、その鍵がなければ使用できません。
- アプリケーション用の ES256(P-256)鍵を 1 つ生成し、秘密鍵の JWK をシークレットストアに保管します。
- それを
dpopとして渡します:
export const auth = createKeenAuth({
// ...the options above
dpop: { privateKey: JSON.parse(process.env.KEENAUTH_DPOP_KEY!) },
});
その後、SDK は次の処理を行います:
- 認可コードを鍵に紐付けます(
dpop_jktを、事前送信した認可リクエストとともに送信します); - すべてのトークンおよびリフレッシュのリクエストに DPoP 証明を付与し、サーバーが新しい nonce を要求した場合は 1 回だけ再試行します;
token_typeがDPoPでないトークンレスポンスを拒否します。
API を呼び出すには auth.authorizedFetch を使用します。このメソッドは、リクエストごとに Authorization: DPoP <token> と ath 付きの新しい証明を送信し、API が 401 で error="use_dpop_nonce" を返した場合は 1 回だけ再試行します。独自の HTTP クライアントを使用する場合は、auth.createDpopProof({ method, url, accessToken, nonce }) で証明を作成してください。
すべてのサーバープロセスで同じ鍵を使用してください。環境はトークンとリフレッシュトークンをこの鍵に紐付けます。鍵を差し替えたり、DPoP を有効化/無効化したりすると、既存のセッションは終了し、ユーザーは再度サインインすることになります。
サーバー側セッション
既定では、セッション全体が Cookie に封入されます。代わりにセッションをサーバー側に保持する場合は sessionStore を渡します。この場合、Cookie にはランダムなセッション ID への暗号化された参照のみが含まれます。
import { createKeenAuth, memorySessionStore, type SessionStore } from "@keenauth/nextjs";
// Next.js bundles proxy.ts, route handlers and pages separately, and each bundle runs its own copy of this module.
// Keep the store on globalThis so that they all share one store in the server process.
const shared = globalThis as { keenauthSessions?: SessionStore };
const sessionStore = (shared.keenauthSessions ??= memorySessionStore());
export const auth = createKeenAuth({
// ...the options above
sessionStore,
});
インメモリストアは、上記のようにプロセスごとに 1 回だけ作成してください。globalThis を使わずにモジュールのトップレベルで memorySessionStore() を呼び出すと、プロキシ、各ルートハンドラー、各ページがそれぞれ空のストアを持つことになります。その結果、コールバックルートが保存したセッションがプロキシやページから見つからず、ユーザーはサインアウトしているように見えます。データをプロセス外に保持するストア(Redis やデータベースなど)であれば、この問題は起きません。
memorySessionStore はセッションを 1 つのプロセスのメモリに保持し、再起動時に失われます。開発環境や単一インスタンスのサーバーで使用してください。複数インスタンスやサーバーレス関数で運用する場合は、Redis やデータベースなどの共有ストアに SessionStore インターフェースを実装してください:
interface SessionStore {
get(id: string): Promise<SessionRecord | null>;
set(id: string, record: SessionRecord): Promise<void>;
delete(id: string): Promise<void>;
deleteByLogout(target: { sub?: string; sid?: string }): Promise<void>;
}
// SessionRecord: { value: string; sub: string; sid?: string; expiresAt: number }
valueもsecretで引き続き暗号化されるため、ストアのコピーからトークンが漏れることはありません。ログには出力しないでください。sidを指定したdeleteByLogoutは、そのsidを持つセッションを削除します。subも指定した場合は、両方が一致する必要があります。subのみを指定した場合は、そのユーザーのすべてのセッションを削除します。expiresAtの経過後にレコードを破棄してください。- パッケージのリポジトリには、ストアが満たすべきコントラクトテスト(
clients/nextjs/test/store-contract.ts)があります。
ストアで障害が発生した場合、SDK はユーザーをサインアウト状態とみなす代わりに session_store_error をスローします。サインアウトを行った場合も、Cookie は引き続き削除されます。
バックチャネルログアウト
sessionStore を指定すると、SDK は /auth/backchannel-logout も提供します。KeenAuth でセッションが終了すると(たとえば、ユーザーが別のアプリケーションからサインアウトした場合や、管理者がセッションを取り消した場合)、環境はそこに署名付きのログアウトトークンを送信します。SDK は該当するセッションを削除するため、アプリでユーザーの次のリクエストにはセッションがなくなります。
https://app.acme.example/auth/backchannel-logoutをアプリケーションのバックチャネルログアウト URI として登録します。- このルートがインターネットからアクセス可能であることを確認してください。プロキシの matcher に含まれている場合、プロキシはこのリクエストを通過させます。
SDK は、OpenID Connect Back-Channel Logout 1.0 が要求するとおりにログアウトトークンを検証します。検証項目は、署名、発行者、オーディエンス、iat と exp、ログアウトイベント、sub または sid、nonce がないこと、および logout+jwt タイプです。成功時は 200 を、それ以外の場合は 400 を返し、配信に失敗した場合は環境側が再試行します。
各ログアウトトークンの jti は、トークンの有効期限が切れるまで記録されるため、取得されたトークンを再送することはできません。同じ jti が繰り返された場合は、再度処理せずに 200 を返します。既定のキャッシュは 1 つのプロセス内にあります。複数インスタンスで運用する場合は、remember(jti, expiresAt) をアトミックに実装した共有の logoutReplayCache(たとえば Redis の SET NX)を渡し、forget(jti) してください。
セッション Cookie
- Cookie は AES-256-GCM で暗号化され、認証されます。鍵は
secretから導出されます。変更されたり複製されたりした Cookie は拒否されます。 - Cookie の名前は
__Host-keenauth_session.0で、Secure、HttpOnly、SameSite=Lax、Path=/を指定して設定されます。http://localhostの場合は、__Host-プレフィックスとSecureは付けません。 - セッションの有効期間は最長 7 日(
sessionMaxAge)です。更新しても延長されません。 - 大きなセッションは、最大 3 つの Cookie に分割されます。
シークレットを変更するには、リストを渡します: secret: [newSecret, oldSecret]。最初のシークレットで暗号化され、すべてのシークレットで復号できます。古いシークレットは sessionMaxAge の後に削除してください。
現在の制限
- 埋め込みコンポーネントによるサインインは、このパッケージではまだ利用できません。
- 更新の共有は 1 つのサーバープロセス内に限られます。複数インスタンスがある場合、2 つのインスタンスが同じセッションを同時に更新することがあります。アプリケーションの設定
refresh_reuse_interval_s(既定値は 5 秒。アプリケーションを更新する を参照)により、2 回目の更新が成功します。0に設定した場合は、2 回目の更新でセッションが終了します。 sessionStoreがない場合、バックチャネルログアウトは利用できません。その場合、リフレッシュトークンが拒否されると、セッションはサーバー側で終了します。