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

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

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

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

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

## 始める前に

- KeenAuth 環境。その発行者は `https://your-environment.eu.keenauth.app` のような形式です。
- 環境内の単一ページアプリケーション ([アプリケーションを作成する](/docs/api/management/applications/create))。`app_type` は `spa`、`token_endpoint_auth_method` は `none` に設定してください (パブリッククライアントにはシークレットがありません)。`development` 環境では、次を登録してください。
  - リダイレクト URI `http://localhost/callback` (ループバックのリダイレクト URI はポートなしで登録され、任意のポートに一致します)
  - ログアウト後のリダイレクト URI `http://localhost/`
  - 許可する Web オリジン `http://localhost:5173` (ブラウザがトークンエンドポイントを呼び出せるようにするため)
- 環境に登録された API ([API を作成する](/docs/api/management/apis/create))。スコープは `messages:read` です。その `identifier` (例: `https://api.example.com`) が、発行されるアクセストークンの対象 (audience) になります。[Express クイックスタート](/docs/quickstarts/express) では、これらのトークンを受け付ける API を構築します。
- Node.js 22.18 以降。

## 1. インストールする

```sh
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` はこれらの値を読み込んで検証します。変数が欠けている場合は、その変数名を示すエラーでアプリを停止します。

```ts
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 クライアントを作成する

```ts
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 つ作成し、プロバイダーに渡します。

```tsx
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` は戻り先のパスです。

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

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

```ts
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` によってユーザーが別のサイトへ誘導されることはありません。

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

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

## 5. API を呼び出す

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

```ts
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 は不要です。

```ts
      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. サインアウトする

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

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

## 7. アプリを実行する

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

```sh
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 のテストを直接実行します。

```ts
  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");
```

```sh
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 環境が必要です。これはオフラインのテストには含まれていません。

## 次のステップ

- [JavaScript ブラウザ SDK](/docs/sdk/js): すべてのオプション、トークンの保存場所、現在の制限事項。
- [React SDK](/docs/sdk/react): 埋め込み型のサインイン、サインアップ、アカウントコンポーネント。
- [Express API を保護する](/docs/quickstarts/express): このアプリが呼び出す API。
