Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions packages/hono-kit/docs/api-root.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@ re-export: retaining it would make MySQL runtime and declaration resolution mand
| `getAuthenticationSecret<T>(options, secretId)` / `AwsSecretsOptions` | Fetch a secret from AWS Secrets Manager (SigV4 `fetch`, per-isolate cache). |
| `getTemporaryCredentials(options)` / `GetTemporaryCredentialsOptions` / `StsCredentials` | STS `AssumeRole` via SigV4 `fetch` (global `sts.amazonaws.com`); returns temporary credentials for browser S3 uploads. |
| `getCloudFrontSignedUrl(url, privateKeyPem, keyPairId, dateLessThan)` | CloudFront signed URL (canned policy, RSA-SHA1, URL-safe base64) — Web Crypto reimpl of `@aws-sdk/cloudfront-signer`, byte-identical query order. |
| `JoseFirebaseVerifier` / `FirebaseVerifier` / `DecodedIdToken` | Firebase ID-token verification (`verifyIdToken`, `getUser`, `deleteUser`). |
| `JoseFirebaseVerifier` / `FirebaseVerifier` / `DecodedIdToken` / `FirebaseUserRecord` | Firebase ID-token verification (`verifyIdToken`, `getUser`, `getUsers`, `getUserByEmail`, `deleteUser`); account records include optional email and disabled status. |
| `createRemoteFirebaseVerifier(projectId)` | Convenience factory: production verifier with a cached remote JWKS (verification only). |
| `createServiceAccountVerifier(serviceAccountJson)` | Cached verifier built from a service-account JSON, **with `IdentityToolkit`** (getUser/deleteUser). One per isolate, re-created only when the SA JSON changes. |
| `IdentityToolkit` / `ServiceAccount` / `SECURETOKEN_JWK_URL` | Identity Toolkit REST client + constants for `getUser` / `deleteUser`. |
| `createServiceAccountVerifier(serviceAccountJson)` | Cached verifier built from a service-account JSON, **with `IdentityToolkit`** (`getUser`, `getUsers`, `getUserByEmail`, `deleteUser`). One per isolate, re-created only when the SA JSON changes. |
| `IdentityToolkit` / `ServiceAccount` / `SECURETOKEN_JWK_URL` | Identity Toolkit REST client + constants for UID/email account lookup and deletion. |
| `getUserProtocol(c)` / `IUserProtocol` | Read client IP / UA (`CF-Connecting-IP` → `X-Forwarded-For`). |
| `getAppInfo(c)` / `AppInfo` | Read `x-amz-meta-version` / `x-amz-meta-uuid`. |
| `resolveAppEnv(env)` / `isProductionEnv(env)` / `AppEnv` | Resolve `'development'` / `'production'` from `env.APP_ENV` (defaults to `'production'` for safety). |
Expand Down
4 changes: 3 additions & 1 deletion packages/hono-kit/docs/api-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,16 @@ For candidate tarball installation, see [Development](./development.md).

| Export | Description |
| ----------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `FakeFirebaseVerifier` | In-memory `FirebaseVerifier` for offline route tests (`register` / `verifyIdToken` / `getUser` / `deleteUser`). |
| `FakeFirebaseVerifier` | In-memory token verification and account lookup, including `getUserByEmail` and configurable `users`. |
| `authHeaders(token, opts?)` | Build interceptor-compatible auth headers for requests. |
| `registerFirebaseToken(firebase, uid, record?, token?)` | Register a token in a `FakeFirebaseVerifier` (no DB). |
| `provisionUser(pool, firebase, opts)` | Register a token and provision a conventional `users(id, firebase_uid, agree)` row; returns the user id (idempotent). |
| `configurableFake(impl, name?)` | Build a test double from a partial implementation; un-stubbed members throw `"${name}.${method} not configured"`. |
| `fakeApiList` / `fakePaymentIntent` / `fakeStripeEvent` / `fakeCheckoutSession` / `fakeCustomer` / `fakePrice` / `fakeSubscription` | Stripe object fixtures with sensible defaults, overridable per test. |
| `fakeKv()` / `fakeQueue()` / `FakeQueue` | In-memory Workers KV / Queues producer doubles (`sent` + `batchCount` on queues for subrequest-bound assertions). |

`FakeFirebaseVerifier.register()` seeds an account only when its UID is not already configured. Update `users` directly to model account changes without changing issued tokens. For compatibility, UID lookups return `{ uid }` for unconfigured UIDs; `deleteUser()` only records calls in `deleted`.

## Next step

Return to [Testing and Operations](https://docs.rdlabo.dev/projects/workers-hono-kit/docs/testing-operations), or see
Expand Down
4 changes: 3 additions & 1 deletion packages/hono-kit/docs/http-auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ app.get('/items', validate('query', querySchema), async (c) => {

## Authentication

`createAuthMiddleware()` reads a token header, verifies a Firebase ID token, optionally resolves the application user ID, and stores the result on the Hono context. Use `createRemoteFirebaseVerifier(projectId)` for cached remote JWKS verification or `createServiceAccountVerifier()` when Identity Toolkit `getUser` and `deleteUser` operations are required.
`createAuthMiddleware()` reads a token header, verifies a Firebase ID token, optionally resolves the application user ID, and stores the result on the Hono context. Use `createRemoteFirebaseVerifier(projectId)` for cached remote JWKS verification or `createServiceAccountVerifier()` when Identity Toolkit `getUser`, `getUsers`, `getUserByEmail`, and `deleteUser` operations are required.

`getUser`, `getUsers`, and `getUserByEmail` return `FirebaseUserRecord` (`uid`, optional `email` and `disabled`). The kit returns disabled accounts; callers decide whether to reject them. Account creation time is not fetched. Email lookup uses the primary email and shares the OAuth token cache with UID lookups. Missing users return `null` (or are omitted from batches), while provider HTTP failures throw, including UID lookups that previously returned an empty result on failure.

Keep identity, reauthentication, and feature credential failures distinct with the stable auth-failure body helpers.

Expand Down
17 changes: 15 additions & 2 deletions packages/hono-kit/src/firebase/firebase-verifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,16 @@ export interface DecodedIdToken {
[claim: string]: unknown;
}

/** Current account attributes returned by Firebase Authentication lookup. */
export interface FirebaseUserRecord {
/** Stable Firebase account identifier. */
uid: string;
/** Current primary email, when configured. */
email?: string;
/** Whether sign-in is disabled for this account. */
disabled?: boolean;
}

/**
* Abstract authentication boundary that replaces the `firebase-admin` Auth surface
* (`verifyIdToken` / `getUser` / `deleteUser`) for environments where the Node SDK cannot
Expand All @@ -41,6 +51,9 @@ export interface FirebaseVerifier {
* @throws If the token signature, issuer, audience, expiry, or other required claims are invalid.
*/
verifyIdToken(idToken: string): Promise<DecodedIdToken>;
/** Look up an account by primary email; return null if absent and throw on provider failures. */
getUserByEmail(email: string): Promise<FirebaseUserRecord | null>;

/**
* Look up a user record by uid.
*
Expand All @@ -50,7 +63,7 @@ export interface FirebaseVerifier {
* @returns The user's `uid` and optional `email`, or `null` when the user does not exist.
* @throws If the backing user-management service is not configured or the lookup fails.
*/
getUser(uid: string): Promise<{ uid: string; email?: string } | null>;
getUser(uid: string): Promise<FirebaseUserRecord | null>;
/**
* Look up multiple user records by uid in as few requests as the backing service allows.
*
Expand All @@ -62,7 +75,7 @@ export interface FirebaseVerifier {
* simply absent from the result (never `null` entries).
* @throws If the backing user-management service is not configured or the lookup fails.
*/
getUsers(uids: string[]): Promise<{ uid: string; email?: string }[]>;
getUsers(uids: string[]): Promise<FirebaseUserRecord[]>;
/**
* Delete a user by uid.
*
Expand Down
84 changes: 82 additions & 2 deletions packages/hono-kit/src/firebase/identity-toolkit.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,86 @@ describe('IdentityToolkit', () => {
vi.restoreAllMocks();
});

it('lookupByEmail uses the primary email and shares the OAuth token cache with UID lookups', async () => {
const request = stubFetch((url) =>
url === TOKEN_URL
? tokenResponse()
: Response.json({
users: [
{ localId: 'other', email: 'other@example.com' },
{ localId: 'uid1', email: 'User@example.com' },
],
}),
);
const client = new IdentityToolkit(await makeServiceAccount());
await expect(client.lookupByEmail(' user@example.com ', NOW)).resolves.toEqual({
uid: 'uid1',
email: 'User@example.com',
});
const call = request.mock.calls.find(([url]) => url.endsWith('accounts:lookup'));
expect(JSON.parse(call?.[1]?.body as string)).toEqual({ email: ['user@example.com'] });
await client.lookup('uid1', NOW + 1);
expect(request.mock.calls.filter(([url]) => url === TOKEN_URL)).toHaveLength(1);
});

it('lookupByEmail returns null for missing or invalid addresses', async () => {
const client = new IdentityToolkit(await makeServiceAccount());
const request = stubFetch((url) => (url === TOKEN_URL ? tokenResponse() : Response.json({ users: [] })));
await expect(client.lookupByEmail(' ', NOW)).resolves.toBeNull();
expect(request).not.toHaveBeenCalled();
await expect(client.lookupByEmail('missing@example.com', NOW)).resolves.toBeNull();
stubFetch(() => Response.json({ error: { message: 'INVALID_EMAIL' } }, { status: 400 }));
await expect(client.lookupByEmail('invalid', NOW)).resolves.toBeNull();
});

it.each([400, 403, 429, 500])('lookupByEmail throws for provider failures (%i)', async (status) => {
stubFetch((url) =>
url === TOKEN_URL
? tokenResponse()
: Response.json({ error: { message: 'CONFIGURATION_NOT_FOUND' } }, { status }),
);
const client = new IdentityToolkit(await makeServiceAccount());
await expect(client.lookupByEmail('user@example.com', NOW)).rejects.toThrow(
`Identity Toolkit lookup failed: ${status}`,
);
});

it('lookupByEmail rejects malformed records without including account values', async () => {
stubFetch((url) =>
url === TOKEN_URL ? tokenResponse() : Response.json({ users: [{ email: 'private@example.com' }] }),
);
const client = new IdentityToolkit(await makeServiceAccount());
await expect(client.lookupByEmail('private@example.com', NOW)).rejects.toThrow(
'Invalid Identity Toolkit lookup response',
);
});

it('returns disabled metadata for UID, batch and email lookups', async () => {
stubFetch((url) =>
url === TOKEN_URL
? tokenResponse()
: Response.json({
users: [{ localId: 'uid1', email: 'a@b.c', disabled: true }],
}),
);
const client = new IdentityToolkit(await makeServiceAccount());
const expected = { uid: 'uid1', email: 'a@b.c', disabled: true };
await expect(client.lookup('uid1', NOW)).resolves.toEqual(expected);
await expect(client.lookupMany(['uid1', 'uid1'], NOW)).resolves.toEqual([expected]);
await expect(client.lookupByEmail('a@b.c', NOW)).resolves.toEqual(expected);
});

it('uses global fetch without a client receiver in Workers', async () => {
vi.stubGlobal('fetch', async function (this: unknown, input: Parameters<typeof fetch>[0]) {
if (this !== undefined && this !== globalThis) {
throw new TypeError('Illegal invocation');
}
return input === TOKEN_URL ? tokenResponse() : Response.json({ users: [] });
});
const client = new IdentityToolkit(await makeServiceAccount());
await expect(client.lookup('missing', NOW)).resolves.toBeNull();
});

it('lookup はアクセストークンを取得してユーザーを返す', async () => {
const fetchMock = stubFetch((url) => {
if (url === TOKEN_URL) {
Expand All @@ -52,10 +132,10 @@ describe('IdentityToolkit', () => {
await expect(client.lookup('missing', NOW)).resolves.toBeNull();
});

it('lookup が 4xx なら null', async () => {
it('lookup throws on provider failure instead of treating it as absence', async () => {
stubFetch((url) => (url === TOKEN_URL ? tokenResponse() : new Response('forbidden', { status: 403 })));
const client = new IdentityToolkit(await makeServiceAccount());
await expect(client.lookup('uid1', NOW)).resolves.toBeNull();
await expect(client.lookup('uid1', NOW)).rejects.toThrow('Identity Toolkit lookup failed: 403');
});

it('アクセストークンを isolate 内でキャッシュする(token 交換は 1 回)', async () => {
Expand Down
Loading
Loading