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 の両方でセッションを終了します。このアプリのページからのリクエストのみ受け付けます

始める前に

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 のままになります。

現在の制限

次のステップ