React 単一ページアプリにサインインを追加する

このクイックスタートでは、@keenauth/js ブラウザ SDK を使用して、Vite で構築した React 単一ページアプリに KeenAuth のサインインを追加します。アプリはリダイレクトでユーザーをサインインさせ、DPoP にバインドされたアクセストークンで保護された API を呼び出し、ユーザーをサインアウトさせます。また、ネットワークなしでのテストも行います。SDK については JavaScript ブラウザ SDK で説明しています。

シングルページアプリでは、トークンはブラウザに保存されます。SDK はトークンをメモリにのみ保持し、DPoP によって抽出不可能なキーにバインドするため、盗まれたトークンはそのキーなしでは使用できません。アプリにサーバーがある場合は、代わりに Next.js クイックスタート を使用すると、すべてのトークンをサーバー側に保持できます (バックエンド・フォー・フロントエンド)。こちらの方が安全性が高い選択肢です。

アプリには次のパスがあります。

パス 内容
/ サインインボタン、またはユーザー、API を呼び出すボタン、サインアウトボタン
/callback リダイレクト URI です。SDK はここでサインインを完了し、アプリはユーザーが開始したページに戻ります

始める前に

1. インストールする

npm create vite@latest my-app -- --template react-ts
cd my-app
npm install @keenauth/js

SDK はまだ公開されていません。公開されるまでは、この例では package.json 内の SDK のソースにリンクしています。この例では vite@8.3.4、@vitejs/plugin-react@6.1.2、react@19.3.1、react-dom@19.3.1 を固定しています。

2. 設定する

アプリは設定を環境変数からのみ読み込みます。Vite が読み込む .env.local に記述し、そのファイルは決してコミットしないでください。Vite はすべての VITE_ 変数を、ブラウザがダウンロードする JavaScript にビルドするため、そこにシークレットを置かないでください。このアプリにはシークレットはありません。

変数 必須 意味
VITE_KEENAUTH_ISSUER はい 環境の発行者
VITE_KEENAUTH_CLIENT_ID はい 単一ページアプリケーションの client_id
VITE_KEENAUTH_API_AUDIENCE はい KeenAuth 内の API の識別子
VITE_API_URL いいえ API のベース URL。空の場合はアプリ自身のオリジンになります
API_PROXY_TARGET いいえ 開発時のみ: 開発サーバーが /api を転送する先。既定値は http://localhost:8080

loadConfig はこれらの値を読み込んで検証します。変数が欠けている場合は、その変数名を示すエラーでアプリを停止します。

export function loadConfig(env: Readonly<Record<string, unknown>>): AppConfig {
  const need = (name: string): string => {
    const v = env[name];
    if (typeof v !== "string" || v.trim() === "") throw new Error(`${name} is required (see .env.example)`);
    return v.trim();
  };

3. SDK クライアントを作成する

export function createAuth(config: AppConfig, origin: string, inject: Injectables = {}): KeenAuth {
  return new KeenAuth({
    issuer: config.issuer,
    clientId: config.clientId,
    redirectUri: `${origin}${CALLBACK_PATH}`,
    postLogoutRedirectUri: `${origin}/`,
    // The access token is for this API: its aud is the API identifier (RFC 8707).
    resource: config.apiAudience,
    // messages:read is a scope of that API; the example API requires it on /api/messages.
    scope: "openid profile email offline_access messages:read",
    ...inject,
  });
}

resource は API の識別子です (RFC 8707)。SDK はこれを認可リクエスト、コード交換、およびすべてのリフレッシュで送信し、アクセストークンの対象は API になります。これがないと、アクセストークンは API 向けのものではなくなり、API はそれを拒否します。SDK はクライアントの作成時にすべてのオプションを検証します。発行者または resource が無効な場合は、コード config_invalid の KeenAuthError をスローします。

ページ用のクライアントを 1 つ作成し、プロバイダーに渡します。

const config = loadConfig(import.meta.env);
const auth = createAuth(config, window.location.origin);

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <AuthProvider auth={auth}>
      <App apiUrl={config.apiUrl} />
    </AuthProvider>
  </StrictMode>,
);

4. サインインとコールバックを処理する

サインインボタンはリダイレクトによるサインインを開始します。PKCE (S256)、state、nonce を用いた認可コードフローです。appState は戻り先のパスです。

        <button onClick={() => auth.signInRedirect({ appState: window.location.pathname })}>Sign in</button>

KeenAuth が /callback にリダイレクトして戻ると、プロバイダーがサインインを完了します。SDK は state、iss、ID トークンを検証し、DPoP プルーフを付けてコードを交換します。認可レスポンスは 1 回だけ使用できます。また、React の StrictMode は開発時にエフェクトを 2 回実行するため、completeSignIn は最初の結果を保持します。

export function completeSignIn(auth: KeenAuth, href: string): Promise<string | null> {
  const url = new URL(href);
  if (url.pathname !== CALLBACK_PATH || !(url.searchParams.has("code") || url.searchParams.has("error"))) {
    return Promise.resolve(null);
  }
  let done = completions.get(auth);
  if (!done) {
    done = auth.handleRedirectCallback(href).then((r) => safeReturnPath(r.appState));
    completions.set(auth, done);
  }
  return done;
}

その後、プロバイダーはアドレスバーから code と state を削除します。戻り先のパスとして使われるのは、アプリ内のパスのみです。そのため、細工された appState によってユーザーが別のサイトへ誘導されることはありません。

export function safeReturnPath(path: string | undefined): string {
  return path !== undefined && /^\/(?![/\\])/.test(path) ? path : "/";
}

本番環境でも /callback に対して index.html を配信してください (Vite の開発サーバーでは既にそのように動作しています)。

5. API を呼び出す

authorizedFetch は、トークンにバインドされた新しいプルーフとともに Authorization: DPoP <token> を送信し、トークンの有効期限が切れる直前にはリフレッシュします。

export async function fetchMessages(auth: KeenAuth, apiUrl: string): Promise<unknown> {
  const res = await auth.authorizedFetch(`${apiUrl}/api/messages`, { headers: { Accept: "application/json" } });
  if (!res.ok) throw new Error(`The API answered ${res.status}`);
  return res.json();
}

開発時には、Vite の開発サーバーが /api を API に転送するため、ブラウザはアプリ自身のオリジンで API を呼び出し、API に CORS は不要です。

      proxy: { "/api": env.API_PROXY_TARGET || "http://localhost:8080" },

API は、DPoP プルーフを、ブラウザが呼び出した URL に対して検証します。このプロキシの背後で Express クイックスタートの API を実行する場合は、その PUBLIC_BASE_URL を http://localhost:5173 に設定してください。本番環境では、API を同じオリジンで配信するか、アプリのオリジンに対して API の CORS を有効にし、VITE_API_URL を設定してください。

6. サインアウトする

      <button onClick={() => auth.signOut()}>Sign out</button>

signOut はトークンを破棄し、リフレッシュトークンを取り消して、ブラウザを環境のログアウトエンドポイントに送ります。そこから / に戻ります。

7. アプリを実行する

PUBLIC_BASE_URL=http://localhost:5173 と、API の識別子を設定した KEENAUTH_AUDIENCE を指定して Express クイックスタートの API を起動し、その後アプリを実行します。

cp .env.example .env.local   # then fill it in
npm run dev

http://localhost:5173 を開き、Sign in を選択し、KeenAuth でサインインして、Call the API を選択します。

SDK はトークンをメモリにのみ保持します。ページを再読み込みすると、ユーザーはアプリ内でサインアウトされた状態になり、再度サインインします。KeenAuth のセッションがあれば、通常は確認画面なしのリダイレクトで完了します。

8. ネットワークなしでテストする

テストにはネットワークも KeenAuth アカウントも必要ありません。偽の fetch が認可サーバーと API の役割を果たし、ID トークンはテスト用に作成したキーで署名されます。Node は TypeScript のテストを直接実行します。

  assert.deepEqual(await fetchMessages(s.auth, ""), { messages: ["hello"] });
  const call = s.seen.find((c) => c.url === `${ORIGIN}/api/messages`)!;
  assert.equal(call.headers.get("Authorization"), "DPoP access-token-1");
npm test
npm run typecheck
npm run deps    # pinned versions, lockfile sources and licences
npm run build

テストでは、次の内容を確認します。設定が検証されること、SDK が無効な発行者または resource を拒否すること、サインインが PKCE S256 と API を resource として送信すること、コールバックが 1 回だけ完了し、コードとともに resource を送信すること、偽造された state がトークンリクエストの前に拒否されること、サインイン後にアプリ内のパスのみが使用されること、トークンがストレージに書き込まれないこと、API 呼び出しが DPoP にバインドされたトークンとそのプルーフを伴うこと。

実際のブラウザを使った完全なサインインには、KeenAuth 環境が必要です。これはオフラインのテストには含まれていません。

次のステップ