React 単一ページアプリにサインインを追加する
このクイックスタートでは、@keenauth/js ブラウザ SDK を使用して、Vite で構築した React 単一ページアプリに KeenAuth のサインインを追加します。アプリはリダイレクトでユーザーをサインインさせ、DPoP にバインドされたアクセストークンで保護された API を呼び出し、ユーザーをサインアウトさせます。また、ネットワークなしでのテストも行います。SDK については JavaScript ブラウザ SDK で説明しています。
シングルページアプリでは、トークンはブラウザに保存されます。SDK はトークンをメモリにのみ保持し、DPoP によって抽出不可能なキーにバインドするため、盗まれたトークンはそのキーなしでは使用できません。アプリにサーバーがある場合は、代わりに Next.js クイックスタート を使用すると、すべてのトークンをサーバー側に保持できます (バックエンド・フォー・フロントエンド)。こちらの方が安全性が高い選択肢です。
アプリには次のパスがあります。
| パス | 内容 |
|---|---|
/ |
サインインボタン、またはユーザー、API を呼び出すボタン、サインアウトボタン |
/callback |
リダイレクト URI です。SDK はここでサインインを完了し、アプリはユーザーが開始したページに戻ります |
始める前に
- KeenAuth 環境。その発行者は
https://your-environment.eu.keenauth.appのような形式です。 - 環境内の単一ページアプリケーション (アプリケーションを作成する)。
app_typeはspa、token_endpoint_auth_methodはnoneに設定してください (パブリッククライアントにはシークレットがありません)。development環境では、次を登録してください。- リダイレクト URI
http://localhost/callback(ループバックのリダイレクト URI はポートなしで登録され、任意のポートに一致します) - ログアウト後のリダイレクト URI
http://localhost/ - 許可する Web オリジン
http://localhost:5173(ブラウザがトークンエンドポイントを呼び出せるようにするため)
- リダイレクト URI
- 環境に登録された API (API を作成する)。スコープは
messages:readです。そのidentifier(例:https://api.example.com) が、発行されるアクセストークンの対象 (audience) になります。Express クイックスタート では、これらのトークンを受け付ける API を構築します。 - Node.js 22.18 以降。
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 環境が必要です。これはオフラインのテストには含まれていません。
次のステップ
- JavaScript ブラウザ SDK: すべてのオプション、トークンの保存場所、現在の制限事項。
- React SDK: 埋め込み型のサインイン、サインアップ、アカウントコンポーネント。
- Express API を保護する: このアプリが呼び出す API。