React Router アプリにサインインを追加する
このクイックスタートでは、標準の OpenID Connect クライアント openid-client を使い、フレームワークモードの React Router 8 アプリに KeenAuth のサインインを追加します。ローダーとアクションはサーバー上で動作するため、お使いのアプリが OAuth クライアントになります。すべてのトークンはサーバー側のセッションに保持され、ブラウザに渡されるのは HTML と署名付きのセッション ID Cookie だけです。サインインのルートを追加し、ページを保護し、ユーザーのアクセストークンで保護された API を呼び出し、サインアウトして、アプリをオフラインでテストします。
サーバーなしのシングルページアプリについては、React のシングルページアプリにサインインを追加するを参照してください。
このアプリには次のルートがあります。
| ルート | 役割 |
|---|---|
GET / |
公開のホームページです。サインインボタン、またはユーザー名とサインアウトボタンを表示します |
GET /dashboard |
セッションが必要です。ユーザーと、API が返すメッセージを表示します |
POST /login |
サインインを開始します。このアプリのページからのリクエストのみ受け付けます |
GET /auth/callback |
レスポンスを検証し、コードを交換してセッションを開始します |
POST /logout |
ここと KeenAuth の両方でセッションを終了します。このアプリのページからのリクエストのみ受け付けます |
始める前に
- KeenAuth の環境。その発行者は
https://your-environment.eu.keenauth.appのような形式です。 - 環境内の Web アプリケーション(アプリケーションを作成する)。クライアントシークレット(
client_secret_basic)を持つものです。development環境では、リダイレクト URIhttp://localhost/auth/callbackと、ログアウト後のリダイレクト URIhttp://localhost/を登録してください。ループバック URI はポートなしで登録され、5173などの任意のポートに一致します。 - 環境内に登録された API(API を作成する)。
messages:readスコープを持つものです。そのidentifier(例:https://api.example.com)が、そのアクセストークンの対象者になります。Express のクイックスタートでは、これらのトークンを受け入れる API を構築します。 - Node.js 22.22 以降と、フレームワークモードの React Router アプリ(
npx create-react-router@8.5.0)。
1. インストールする
npm install --save-exact openid-client@6.8.8
openid-client は OpenID Connect のリライングパーティを実装します。ディスカバリー、PKCE を使った認可コードフロー、RFC 9207 の iss チェック、ID トークンのチェックを行います。サンプルではすべてのパッケージを厳密なバージョンで固定し、npm ci --ignore-scripts で package-lock.json からインストールするため、インストール中にパッケージのスクリプトは実行されません。
2. 設定する
アプリは設定を環境変数からのみ読み込みます。値は .env に記述してください。npm run dev と npm start は node --env-file=.env でこのファイルを読み込みます。このファイルは決してコミットしないでください。
| 変数 | 必須 | 意味 |
|---|---|---|
KEENAUTH_ISSUER |
はい | お使いの環境の発行者。https を使用します |
KEENAUTH_CLIENT_ID |
はい | Web アプリケーションの client_id |
KEENAUTH_CLIENT_SECRET |
はい | そのクライアントシークレット |
APP_BASE_URL |
はい | アプリの公開 URL。リダイレクト URI はここから組み立てられます。http は localhost、127.0.0.1、[::1] の場合のみ使用できます |
SESSION_SECRET |
はい | 32 文字以上。セッション Cookie に署名します。openssl rand -base64 32 で生成できます |
API_URL |
はい | アプリが呼び出す API。例: http://localhost:8080 |
KEENAUTH_API_AUDIENCE |
はい | KeenAuth における、その API の識別子 |
app/lib/config.server.ts の loadConfig は、最初のリクエストでこれらの値を検証します。値が欠けている、または使用できない場合はリクエストが停止します。エラーメッセージに値が含まれることはありません。
const issuer = url("KEENAUTH_ISSUER", false);
const appBaseUrl = url("APP_BASE_URL", true).origin;
const sessionSecret = need("SESSION_SECRET");
if (sessionSecret.length < 32) throw new Error("SESSION_SECRET must be at least 32 characters");
.server.ts で終わるファイルは、ブラウザ向けのバンドルに含まれることはありません。ページがブラウザ向けにこれらをインポートすると、React Router はビルドを停止します。
3. OpenID Connect クライアントを作成する
app/lib/keenauth.server.ts は、ディスカバリー(<issuer>/.well-known/openid-configuration)から一度だけクライアントを構築します。
export function keenauth(): Promise<client.Configuration> {
discovered ??= client
.discovery(config().issuer, config().clientId, undefined, client.ClientSecretBasic(config().clientSecret), {
// Also check the ID token signature with your environment's keys (jwks_uri), not only the TLS connection.
execute: [client.enableNonRepudiationChecks],
})
openid-client は、デフォルトでは TLS 接続経由でトークンエンドポイントから直接届いた ID トークンを信頼します。enableNonRepudiationChecks は、お使いの環境が公開する鍵を使って、その署名も検証します。
4. セッションをサーバー上に保持する
セッションはサーバー上のファイルに保存されます。Cookie に保持されるのは署名付きのセッション ID だけです。
storage ??= createFileSessionStorage<SessionData>({
dir: ".sessions",
cookie: {
name: "__keenauth_session",
httpOnly: true,
sameSite: "lax",
// Secure whenever the app is served over https; plain http only for localhost.
secure: config().redirectUri.startsWith("https:"),
path: "/",
maxAge: 8 * 60 * 60,
secrets: [config().sessionSecret],
},
});
ファイルストアは 1 台のサーバーでのみ動作します。複数のサーバーで動かす場合は、createSessionStorage を使い、Redis などの共有ストアを使ったセッションストレージを作成してください。トークンの保存に Cookie セッションストレージは使用しないでください。2 つのトークンで 4 KB の Cookie 上限を超える可能性があり、ブラウザがトークンを保持することになってしまいます。
5. サインインを開始する
ルートは app/routes.ts に記述します。
export default [
index("routes/home.tsx"),
route("dashboard", "routes/dashboard.tsx"),
route("login", "routes/login.ts"),
route("auth/callback", "routes/callback.ts"),
route("logout", "routes/logout.ts"),
] satisfies RouteConfig;
サインインは、/login に POST するフォームです。
<Form method="post" action="/login" reloadDocument>
<button type="submit">Sign in</button>
</Form>
アクションは、サインインごとに新しい state、nonce と PKCE の検証子を作成し、それらをセッションに保持します。
export async function action({ request }: Route.ActionArgs) {
assertSameOrigin(request);
const session = await getSession(request);
const transaction = newTransaction();
session.set("transaction", transaction);
const url = await authorizationUrl(transaction);
return redirect(url.href, { headers: { "Set-Cookie": await sessions().commitSession(session) } });
}
認可リクエストでは、検証子の S256 チャレンジと、API の識別子を resource(RFC 8707)として送信します。resource がなければ、そのアクセストークンはお使いの API 向けではなく、API はそれを拒否します。
return client.buildAuthorizationUrl(await keenauth(), {
redirect_uri: config().redirectUri,
// messages:read is a scope of your API; the example API requires it on /api/messages.
scope: "openid profile email messages:read",
state: transaction.state,
nonce: transaction.nonce,
// PKCE (RFC 7636): S256 of the verifier that only this session knows.
code_challenge: await client.calculatePKCECodeChallenge(transaction.codeVerifier),
code_challenge_method: "S256",
// RFC 8707: an access token for your API.
resource: config().apiAudience,
});
自分のページからの POST のみを受け付ける
React Router は、ページのアクションに対するクロスサイトの POST を拒否します。しかし /login と /logout はリソースルート(ページを持たないアクション)であるため、その確認は自分で行う必要があります。assertSameOrigin は、Origin ヘッダー(ない場合は Referer)が APP_BASE_URL を示している場合にのみ POST を受け付けます。
const app = new URL(config().redirectUri).origin;
const source = request.headers.get("Origin") ?? request.headers.get("Referer");
6. コールバックを処理する
コールバックは最初にセッションからトランザクションを取り出すため、state は 1 回しか使えません。
const old = await getSession(request);
const transaction = old.get("transaction");
// The transaction works once: it leaves the session before anything else happens.
old.unset("transaction");
authorizationCodeGrant は、state と、お使いの環境がすべての認可レスポンスで送信する iss(RFC 9207)を検証し、PKCE の検証子でコードを交換し、ID トークンを検証します。検証対象は、署名、iss、aud、exp、iat、そしてこのサインインの nonce です。リダイレクト URI は Host ヘッダーからではなく、常に APP_BASE_URL から組み立てられます。
const callbackUrl = new URL(config().redirectUri);
callbackUrl.search = new URL(request.url).search;
return client.authorizationCodeGrant(await keenauth(), callbackUrl, {
pkceCodeVerifier: transaction.codeVerifier,
expectedState: transaction.state,
expectedNonce: transaction.nonce,
idTokenExpected: true,
});
検証に失敗すると、固定のエラーコードとともに 401 を返します。サインインに成功すると、アプリは古いセッションを削除し、ユーザーとトークンを新しいセッションに保持します。
// A new session id after sign-in prevents session fixation: the old session is deleted, a new one is created.
await sessions().destroySession(old);
const session = await sessions().getSession(null);
7. ページを保護し、API を呼び出す
ユーザーを必要とするローダーは requireUser を呼び出します。セッションがない場合は、ホームページにリダイレクトされます。サンプルにはリフレッシュトークンがありません。アクセストークンの有効期限が切れたら、ユーザーは再度サインインします。
export async function loader({ request }: Route.LoaderArgs) {
const { user, accessToken } = await requireUser(request);
const api = await fetchMessages(accessToken);
return { user, api };
}
ローダーは、アクセストークンをベアラートークンとして API を呼び出します。トークンはサーバー上にとどまり、ページが受け取るのはユーザーとメッセージだけです。
response = await fetch(`${config().apiUrl}/api/messages`, {
headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json" },
});
8. サインアウトする
サインアウトは、このアプリのページからの POST です。アクションはローカルのセッションを終了し、ブラウザを環境のログアウトエンドポイント(OpenID Connect RP-Initiated Logout)へ送ります。その際、id_token_hint、client_id、post_logout_redirect_uri を渡し、アプリに戻されるようにします。
const session = await getSession(request);
const idToken = session.get("idToken");
const destroyed = await sessions().destroySession(session);
return redirect((await logoutUrl(idToken)).href, { headers: { "Set-Cookie": destroyed } });
9. アプリを実行する
Express のクイックスタートの API(またはこれらのトークンを受け入れる任意の API)を起動し、アプリを実行します。
npm run dev
http://localhost:5173 を開き、サインイン を選択して、KeenAuth でサインインし、ダッシュボードを開きます。
10. ネットワークなしでテストする
テストにはネットワークも KeenAuth のアカウントも必要ありません。npm test はアプリをビルドし、そのビルドを React Router のリクエストハンドラーで実行します。偽の fetch が環境(ディスカバリー、テスト用に作成された鍵セット、トークンエンドポイント)と API の役割を果たします。
test("callback: an unknown or reused state is refused before any token request and starts no session", async () => {
const b = browser();
await startSignIn(b);
const wrong = await returnFromKeenAuth(b, { state: "not-the-state" });
npm test
npm run deps # exact pins, lockfile sources and licences
テストでは、次のことを確認します。設定が検証されること(どのエラーにもシークレットが含まれないこと)。サインインが PKCE の S256、state、nonce、resource を送信し、別のサイトからのリクエストや GET を拒否すること。コードが対応する検証子とクライアント認証で交換されること。アクセストークンが Authorization ヘッダーで API に送られ、ブラウザには決して送られないこと。不明または再利用された state、iss の欠落または誤り、認可エラー、別のクライアント向け、別の nonce、または別の鍵で署名された ID トークンが、セッションなしで拒否されること。サインインで新しいセッション ID が開始されること。セッションのないページがホームページにリダイレクトされること。サインアウトが別のサイトからの要求を拒否し、id_token_hint を付けてログアウトエンドポイントに移動すること。
実際のブラウザによる完全なサインインには KeenAuth の環境が必要です。これはオフラインテストには含まれません。
プロキシやロードバランサーの背後で動かす
APP_BASE_URL にアプリの公開 URL を設定してください。リダイレクト URI と Origin のチェックはこの値を使い、Host ヘッダーは使用しません。プロキシで TLS を終端する場合も、APP_BASE_URL は https の URL のままなので、セッション Cookie は Secure のままになります。
現在の制限
- リフレッシュトークンもサイレントサインインもありません。アクセストークンの有効期限が切れたら、ユーザーは再度サインインします。
- ファイルのセッションストアは、トークンを
.sessions/配下のプレーンファイルに保存し、期限切れのファイルを削除しません。そのディレクトリを保護するか、セッションを暗号化して期限切れにする共有ストアを使用してください。 - クライアントはクライアントシークレットで認証します。
openid-clientはprivate_key_jwtもサポートしていますが、サンプルではそれを示していません。
次のステップ
- Express API を保護する: このアプリが呼び出す API です。
- トークンの検証: お使いの API のミドルウェアの下で動作する検証機能です。