Next.js SDK

@keenauth/nextjs は Next.js App Router アプリケーションにサインインを追加します。アプリが OAuth クライアントとなり、すべてのトークンをサーバー側で保持します。ブラウザが受け取るのは暗号化された HttpOnly のセッション Cookie だけで、アクセストークン、リフレッシュトークン、ID トークンは一切見えません。これは Backend-for-Frontend (BFF) パターンです。このパッケージには実行時依存関係がありません。まだ npm には公開されていません。

開始する前に

  1. ダッシュボードで、環境内に Web アプリケーションを作成します。
  2. private_key_jwt のクライアント認証を選択し、公開鍵を登録します。クライアントシークレットも使用できます。
  3. リダイレクト URI https://app.acme.example/auth/callback と、サインアウト後のリダイレクト URI https://app.acme.example/ を追加します。
  4. 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).*)"],
};

セッションのない保護対象パスの場合:

アクセストークンの有効期限が 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 は次の場合に失敗します:

ルートハンドラーでは、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 は次の処理を行います:

  1. セッション Cookie を削除します;
  2. リフレッシュトークンを取り消します;
  3. 環境のログアウトエンドポイントにブラウザを送り、そこからアプリに戻します。

ブラウザが別サイト由来と判断したリクエストは拒否されるため、別のサイトからユーザーをサインアウトさせることはできません。Sec-Fetch-Site を送信しないブラウザでは、アプリ自身のページからの POST でのみサインアウトできます。/auth/logout に POST するフォームの使用を推奨します。

送信者制約付きトークン (DPoP)

DPoP(RFC 9449)を使用すると、アクセストークンは、サーバーのみが保持する鍵に紐付けられます。たとえば API ログから漏えいしたトークンも、その鍵がなければ使用できません。

  1. アプリケーション用の ES256(P-256)鍵を 1 つ生成し、秘密鍵の JWK をシークレットストアに保管します。
  2. それを dpop として渡します:
export const auth = createKeenAuth({
  // ...the options above
  dpop: { privateKey: JSON.parse(process.env.KEENAUTH_DPOP_KEY!) },
});

その後、SDK は次の処理を行います:

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 }

ストアで障害が発生した場合、SDK はユーザーをサインアウト状態とみなす代わりに session_store_error をスローします。サインアウトを行った場合も、Cookie は引き続き削除されます。

バックチャネルログアウト

sessionStore を指定すると、SDK は /auth/backchannel-logout も提供します。KeenAuth でセッションが終了すると(たとえば、ユーザーが別のアプリケーションからサインアウトした場合や、管理者がセッションを取り消した場合)、環境はそこに署名付きのログアウトトークンを送信します。SDK は該当するセッションを削除するため、アプリでユーザーの次のリクエストにはセッションがなくなります。

  1. https://app.acme.example/auth/backchannel-logout をアプリケーションのバックチャネルログアウト URI として登録します。
  2. このルートがインターネットからアクセス可能であることを確認してください。プロキシの 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) してください。

シークレットを変更するには、リストを渡します: secret: [newSecret, oldSecret]。最初のシークレットで暗号化され、すべてのシークレットで復号できます。古いシークレットは sessionMaxAge の後に削除してください。

現在の制限