@ultimat3/auth 25.1.0 → 26.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -76,13 +76,15 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
76
76
  `decodeJwtSegment` answers `null` for all three failures; each caller raises its own refusal.
77
77
  - Absolute and idle expiry are two separate computations in `sessionExpiry()`. Do not fold them.
78
78
  - PKCE is not provider-dependent: `OAuthProvider.usesPkce` is the literal `true`.
79
- - **Providers are a registry, `OAuthProviderId` is `string`.** The three built-ins seed it through
79
+ - **Providers are a registry, `OAuthProviderId` is `string`.** The two built-ins seed it through
80
80
  `registerOAuthProvider()`, the call an app makes. `providerFor(id)` throws
81
81
  `X_OAUTH_PROVIDER_UNKNOWN`, never `undefined`; a second claim is `X_OAUTH_PROVIDER_DUPLICATE`.
82
82
  - **`oauthProviderUnknown(provider, supported)` scopes its list to its reader**: the route passes
83
83
  `BUILTIN_OAUTH_PROVIDER_IDS` (an anonymous caller), `providerFor()` passes `oauthProviderIds()`.
84
84
  - **`verifyIdToken({ keys })` is required, with no default.** `'token-endpoint-tls'` is the OIDC
85
- Core 3.1.3.7 exemption and `exchangeOAuthCode` is its only entitled caller. `HS256` and
85
+ Core 3.1.3.7 exemption and `exchangeOAuthCode` is its only entitled caller — and since 26.0.0
86
+ it defaults to the provider's `providerJwks` whenever there is a `jwksUri`, using the exemption
87
+ only for a provider with none or when the caller names it (`idTokenKeys`). `HS256` and
86
88
  `alg: none` are refused in `decodeJwtHeader`. It checks `nbf` and `azp` too.
87
89
  - **`resolveGrants` is a seam, never a group-to-role table**, called on EVERY login. Absent leaves
88
90
  the row alone. A user created with no roles and no org logs a warning.
@@ -222,7 +224,7 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
222
224
  | `rate-limit-postgres.ts` | the SHARED limiter: one table, one row per key, a one-statement take, over a structural `PgExecutor` |
223
225
  | `limiter-install.ts` | the host's one install point for that limiter — the factory, what it built, and the purge over it |
224
226
  | `oauth.ts` | `OAuthProvider`, PKCE, `beginOAuth`, the callback gate. No I/O, no env |
225
- | `oauth-builtins.ts` | the three shipped IdPs, as data. Imports only the type, so no cycle |
227
+ | `oauth-builtins.ts` | the two shipped IdPs (`github`, `google`), as data. Imports only the type, so no cycle |
226
228
  | `oauth-registry.ts` | the registry: `registerOAuthProvider`, `providerFor`, `oauthProviderIds` |
227
229
  | `oauth-discovery.ts` | `/.well-known/openid-configuration` → an `OAuthProvider`. One `fetch` |
228
230
  | `jwks.ts` | `crypto.subtle` signature verification, cached by `kid`, one shared in-flight refresh. No dependency |
package/README.md CHANGED
@@ -207,7 +207,7 @@ that sweep at boot.
207
207
  ## Providers are a registry, not a union
208
208
 
209
209
  `OAuthProviderId` is `string`, and `registerOAuthProvider()` is the one way a provider gets in —
210
- the three built-ins go through the same call. Before 1.3.0 the id was `keyof typeof
210
+ the two built-ins go through the same call. Before 1.3.0 the id was `keyof typeof
211
211
  OAUTH_PROVIDERS` over `github | google | apple`, so an enterprise OP was **unrepresentable**: the
212
212
  constraint was a type, there was no runtime escape, and the only ways out were forking the package
213
213
  or bypassing OAuth entirely and losing PKCE, the sealed handshake, issuer pinning and account
@@ -241,7 +241,7 @@ registerOAuthProvider(await discoverOAuthProvider({ id: 'bigco-sso', issuer: 'ht
241
241
  | `registerOAuthProvider(provider)` | the frozen provider; `X_OAUTH_PROVIDER_DUPLICATE` on a second claim of one id |
242
242
  | `providerFor(id)` | the provider, or throws `X_OAUTH_PROVIDER_UNKNOWN` — never `undefined` |
243
243
  | `hasOAuthProvider(id)` | whether the id is registered |
244
- | `BUILTIN_OAUTH_PROVIDER_IDS` | the three shipped ids — the only list an **anonymous** refusal is built from, and it is named in the `auth.oauth.refused` log line, never in the response body |
244
+ | `BUILTIN_OAUTH_PROVIDER_IDS` | the two shipped ids — the only list an **anonymous** refusal is built from, and it is named in the `auth.oauth.refused` log line, never in the response body |
245
245
  | `oauthProviderIds()` | every registered id, live. **NOT** what `defineAuth({ providers })` defaults to — that is `[]`, so an app names what it enabled |
246
246
 
247
247
  `discoverOAuthProvider` refuses a document with no `jwks_uri`: without a key set there is nothing
@@ -262,9 +262,35 @@ because a default is what silently makes a second door as trusting as the first.
262
262
 
263
263
  | `keys` | Means |
264
264
  |---|---|
265
- | `'token-endpoint-tls'` | this token came off a TLS response from the provider's own token endpoint — the one case OIDC Core 3.1.3.7 exempts. `exchangeOAuthCode` passes it, and that is the only shipped call site that may |
265
+ | `'token-endpoint-tls'` | this token came off a TLS response from the provider's own token endpoint — the one case OIDC Core 3.1.3.7 exempts. Since 26.0.0 the login flow uses it only for a provider with no `jwksUri`, or when the app names it |
266
266
  | a `JwksKeySource` | the signature is checked. `providerJwks(providerFor(id))`, or `jwksClient({ provider, jwksUri })` |
267
267
 
268
+ **The login flow verifies the signature by default** (26.0.0). `exchangeOAuthCode`, and with it
269
+ `completeOAuthLogin` and `oauthLogin`'s callback, checks the id token against the provider's
270
+ published key set (`providerJwks`, cached per provider, one fetch per TTL) whenever the provider has
271
+ a `jwksUri` — Google and every discovered provider do; GitHub issues no id token. The token-endpoint
272
+ exemption trusts every hop the response crossed (a TLS-inspecting proxy, a bad DNS answer); a
273
+ signature trusts only the provider. A bad signature is `X_OAUTH_TOKEN_INVALID` (400) and no session.
274
+
275
+ | Where | Option | Default |
276
+ |---|---|---|
277
+ | `exchangeOAuthCode(handshake, callback, { keys })` | `keys?: IdTokenKeys` | the provider's key set, else `'token-endpoint-tls'` |
278
+ | `completeOAuthLogin(auth, { idTokenKeys })` | `idTokenKeys?: IdTokenKeys` | same |
279
+ | `oauthLogin(auth, { idTokenKeys })` | `idTokenKeys?: (provider) => IdTokenKeys \| undefined` — asked per callback; `undefined` = the default | same |
280
+
281
+ ```ts
282
+ import { type Auth, oauthLogin } from '@ultimat3/auth';
283
+
284
+ declare const auth: Auth;
285
+
286
+ // An egress that cannot reach one provider's jwks_uri: name the exemption for that provider only.
287
+ // `undefined` keeps every other provider on the default — never `providerJwks(providerFor(id))`
288
+ // here, which throws for GitHub: it publishes no jwks_uri.
289
+ export const login = oauthLogin(auth, {
290
+ idTokenKeys: (provider) => (provider === 'bigco-sso' ? 'token-endpoint-tls' : undefined),
291
+ });
292
+ ```
293
+
268
294
  ```ts
269
295
  const keys = providerJwks(providerFor('bigco-sso'));
270
296
  const claims = await verifyIdToken({ provider: 'bigco-sso', idToken, clientId, nonce, clock, keys });
@@ -743,14 +769,13 @@ override all three at once.
743
769
  |---|---|---|---|
744
770
  | `github` | S256 | — profile + verified-emails call | `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` |
745
771
  | `google` | S256 | required, nonce-bound | `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` |
746
- | `apple` | S256 | required, nonce-bound | `APPLE_CLIENT_ID` / `APPLE_CLIENT_SECRET` |
747
772
 
748
- Apple alone rejects a static secret: `APPLE_CLIENT_SECRET` must hold the ES256 client-secret
749
- JWT signed with the `.p8` key, which Apple expires every six months.
773
+ Apple is not a built-in (removed in 26.0.0): it POSTs its callback (`response_mode=form_post`) and
774
+ both callback routes are GET. An app that needs it registers its own with `registerOAuthProvider`.
750
775
 
751
776
  | Step | Does | Fails with |
752
777
  |---|---|---|
753
- | `oauthLogin(auth)` | the two routes: redirect out, session back | `X_OAUTH_PROVIDER_UNKNOWN`, `X_OAUTH_DENIED` |
778
+ | `oauthLogin(auth)` | the two routes: redirect out, session back — the id token's signature verified against the provider's key set (`idTokenKeys` overrides) | `X_OAUTH_PROVIDER_UNKNOWN`, `X_OAUTH_DENIED`, `X_OAUTH_TOKEN_INVALID` |
754
779
  | `handshakeCookie` / `readHandshakeCookie` | seals the handshake onto the redirect, opens it on the callback | `X_OAUTH_STATE_INVALID`, `X_ENV_MISSING` |
755
780
  | `exchangeOAuthCode` | POSTs the code + PKCE verifier, verifies the id token | `X_OAUTH_EXCHANGE_FAILED`, `X_OAUTH_TOKEN_INVALID` |
756
781
  | `oauthProfile` | id-token claims, else userinfo → one normalised identity | `X_OAUTH_EXCHANGE_FAILED` |
@@ -856,7 +881,7 @@ later. In a test the store is `memoryAuthAdapter()` — every `AuthAdapter` is a
856
881
  | `X_OAUTH_STATE_INVALID` | state, nonce or PKCE verifier did not match |
857
882
  | `X_OAUTH_EXCHANGE_FAILED` | the provider refused the exchange, or returned no usable identity |
858
883
  | `X_OAUTH_TOKEN_INVALID` | the id token failed its signature, issuer, audience or expiry check, or no key in the published set matched its `kid` |
859
- | `X_OAUTH_PROVIDER_UNKNOWN` | the URL named a provider nothing registered, one `defineAuth({ providers })` did not enable, or one whose `*_CLIENT_ID`/`*_CLIENT_SECRET` are unset — all three answer 404 with the same body, because telling an anonymous caller which is which describes this deployment for free. The real reason is logged. The refusal lists only the three built-ins, never your registry |
884
+ | `X_OAUTH_PROVIDER_UNKNOWN` | the URL named a provider nothing registered, one `defineAuth({ providers })` did not enable, or one whose `*_CLIENT_ID`/`*_CLIENT_SECRET` are unset — all three answer 404 with the same body, because telling an anonymous caller which is which describes this deployment for free. The real reason is logged. The refusal lists only the two built-ins, never your registry |
860
885
  | `X_OAUTH_PROVIDER_DUPLICATE` | two `registerOAuthProvider` calls claimed one id — at boot, never at a login |
861
886
  | `X_OAUTH_DENIED` | the user pressed Cancel, or the provider declined — `403`, never a `502` |
862
887
  | `X_PASSWORD_WEAK` | strength check rejected the password |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/auth",
3
- "version": "25.1.0",
3
+ "version": "26.0.0",
4
4
  "description": "Sessions, passwords, OAuth, MFA and api keys — resolved to one Actor",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -32,8 +32,8 @@
32
32
  "test": "bun test"
33
33
  },
34
34
  "dependencies": {
35
- "@ultimat3/core": "25.1.0",
36
- "@ultimat3/db": "25.1.0",
37
- "@ultimat3/schema": "25.1.0"
35
+ "@ultimat3/core": "26.0.0",
36
+ "@ultimat3/db": "26.0.0",
37
+ "@ultimat3/schema": "26.0.0"
38
38
  }
39
39
  }
package/src/auth.ts CHANGED
@@ -72,7 +72,7 @@ export const SessionSchema = t.object({
72
72
  export const AccountSchema = t.object({
73
73
  id: t.uuid,
74
74
  userId: t.uuid,
75
- // Any registered provider id, not the three built-ins: an app that registers its own OP has
75
+ // Any registered provider id, not the two built-ins: an app that registers its own OP has
76
76
  // account rows carrying that id, and an enum of three would refuse to parse its own data.
77
77
  provider: t.string,
78
78
  providerAccountId: t.string,
package/src/index.ts CHANGED
@@ -176,7 +176,6 @@ export type {
176
176
  } from './oauth';
177
177
  export { assertOAuthCallback, beginOAuth, pkcePair } from './oauth';
178
178
  export {
179
- APPLE_PROVIDER,
180
179
  BUILTIN_OAUTH_PROVIDER_IDS,
181
180
  BUILTIN_OAUTH_PROVIDERS,
182
181
  GITHUB_PROVIDER,
@@ -1,4 +1,5 @@
1
- // Single responsibility: the three consumer IdPs Ultimate ships with, as plain data. They live
1
+ // Single responsibility: the two consumer IdPs Ultimate ships with, as plain data. Apple left in
2
+ // 26.0.0: it POSTs its callback and both callback routes are GET, so it could never complete. They live
2
3
  // apart from `oauth-registry.ts` so the registry can be seeded from them while `oauth.ts` reads
3
4
  // the registry — the only edge back to `oauth.ts` is the type, and a type is erased, so there is
4
5
  // no runtime cycle between the three files.
@@ -39,44 +40,19 @@ export const GOOGLE_PROVIDER: OAuthProvider = {
39
40
  clientSecretEnv: 'GOOGLE_CLIENT_SECRET',
40
41
  };
41
42
 
42
- /**
43
- * UNPROVEN: this flow has never completed a sign-in against Apple, in a test or anywhere else.
44
- * Apple answers a request that asks for a scope with `response_mode=form_post` — a POST to the
45
- * callback — and both route descriptors in `oauth-route.ts` are GET and read the query string.
46
- * Whether this entry ships or is removed is an open owner decision; do not rely on it.
47
- */
48
- export const APPLE_PROVIDER: OAuthProvider = {
49
- id: 'apple',
50
- authorizeUrl: 'https://appleid.apple.com/auth/authorize',
51
- tokenUrl: 'https://appleid.apple.com/auth/token',
52
- // Apple returns claims in the id token only; there is no userinfo endpoint to call.
53
- userInfoUrl: null,
54
- userEmailsUrl: null,
55
- issuers: ['https://appleid.apple.com'],
56
- jwksUri: 'https://appleid.apple.com/auth/keys',
57
- scopes: ['name', 'email'],
58
- usesPkce: true,
59
- usesNonce: true,
60
- clientIdEnv: 'APPLE_CLIENT_ID',
61
- // Apple alone does not accept a static secret: `APPLE_CLIENT_SECRET` must hold the ES256
62
- // client-secret JWT signed with the .p8 key, which Apple expires every six months.
63
- clientSecretEnv: 'APPLE_CLIENT_SECRET',
64
- };
65
-
66
43
  /** Seeded into the registry at import, through the same call an app registers its own IdP with. */
67
44
  export const BUILTIN_OAUTH_PROVIDERS: readonly OAuthProvider[] = Object.freeze([
68
45
  GITHUB_PROVIDER,
69
46
  GOOGLE_PROVIDER,
70
- APPLE_PROVIDER,
71
47
  ]);
72
48
 
73
49
  /**
74
- * The three ids, and the only list an ANONYMOUS caller is ever shown. It is a framework constant
50
+ * The two ids, and the only list an ANONYMOUS caller is ever shown. It is a framework constant
75
51
  * already in the docs, so naming it discloses nothing; the live registry is not, because an app
76
52
  * that registered `acme-internal-sso` has put its own vocabulary in there. `oauth-route.ts` is the
77
53
  * one reader in this package — every other refusal reaches a developer and gets
78
54
  * `oauthProviderIds()` instead. Off `index.ts` too, because an app writing its own login route
79
- * owes an anonymous caller the same narrow list and must not have to restate the three ids.
55
+ * owes an anonymous caller the same narrow list and must not have to restate the two ids.
80
56
  */
81
57
  export const BUILTIN_OAUTH_PROVIDER_IDS: readonly string[] = Object.freeze(
82
58
  BUILTIN_OAUTH_PROVIDERS.map((provider) => provider.id),
@@ -186,7 +186,7 @@ export function openHandshake(
186
186
  * session cookie's: the callback is a top-level cross-site GET from the provider, which `Lax`
187
187
  * still attaches the cookie to and `Strict` would strip — leaving every login to fail its state
188
188
  * check. A provider answering with `response_mode=form_post` POSTs instead, and this cookie
189
- * would not reach it; none of the three built-in providers is configured that way.
189
+ * would not reach it; neither built-in provider is configured that way.
190
190
  */
191
191
  export function handshakeCookie(
192
192
  handshake: OAuthHandshake,
@@ -46,7 +46,7 @@ const stringOrNull = (body: Record<string, unknown>, key: string): string | null
46
46
  return typeof value === 'string' && value !== '' ? value : null;
47
47
  };
48
48
 
49
- /** `bigco-sso` → `BIGCO_SSO_CLIENT_ID`, the same shape the three built-ins use. */
49
+ /** `bigco-sso` → `BIGCO_SSO_CLIENT_ID`, the same shape the two built-ins use. */
50
50
  const envPrefix = (id: string): string => id.toUpperCase().replace(/[^A-Z0-9]+/g, '_');
51
51
 
52
52
  /**
@@ -33,7 +33,7 @@ export const restartAt = (provider: string): string =>
33
33
  *
34
34
  * **`supported` is the CALLER's to scope, because the two callers have two audiences.**
35
35
  * `oauth-route.ts` passes `BUILTIN_OAUTH_PROVIDER_IDS` — its reader is an anonymous stranger who
36
- * typed a URL, and the three built-ins are a framework constant already in the public docs, while
36
+ * typed a URL, and the two built-ins are a framework constant already in the public docs, while
37
37
  * the live registry holds whatever internal OP this deployment registered. `providerFor()` passes
38
38
  * `oauthProviderIds()` — its reader is a developer holding a stack trace, and there the full list
39
39
  * is exactly what makes the fix runnable. Neither ever passes `defineAuth({ providers })`: naming
@@ -14,11 +14,12 @@ import {
14
14
  } from '@ultimat3/core';
15
15
  import { type IdTokenClaims, verifyIdToken } from './id-token';
16
16
  import { isRecord } from './json';
17
- import type { IdTokenKeys } from './jwks';
17
+ import { type IdTokenKeys, providerJwks } from './jwks';
18
18
  import {
19
19
  assertOAuthCallback,
20
20
  type OAuthCallback,
21
21
  type OAuthHandshake,
22
+ type OAuthProvider,
22
23
  type OAuthProviderId,
23
24
  } from './oauth';
24
25
  import { oauthExchangeFailed, restartAt } from './oauth-errors';
@@ -60,10 +61,12 @@ export interface OAuthExchangeOptions {
60
61
  readonly fetch?: OAuthFetch | undefined;
61
62
  readonly timeoutMs?: number | undefined;
62
63
  /**
63
- * Defaults to `'token-endpoint-tls'`, and this is the one call site where that default is
64
- * correct: the id token below was read off a TLS response from the provider's own token
65
- * endpoint, which is the channel OIDC Core 3.1.3.7 exempts. An app that pins the key set anyway
66
- * — a corporate egress proxy, a compliance requirement — passes `providerJwks(providerFor(id))`.
64
+ * Where the id token's signature is checked against. Default (26.0.0): the provider's own
65
+ * published key set (`providerJwks`, cached per provider) whenever it has a `jwksUri`, and
66
+ * token-endpoint TLS only for a provider that publishes none. OIDC Core 3.1.3.7 exempts the
67
+ * token endpoint, but that exemption trusts every hop the response crossed — a TLS-inspecting
68
+ * egress proxy, a misrouted DNS answer — and a verified signature trusts only the provider.
69
+ * `'token-endpoint-tls'` is the explicit opt-out; a `jwksClient({ jwksUri })` pins another set.
67
70
  */
68
71
  readonly keys?: IdTokenKeys | undefined;
69
72
  }
@@ -273,6 +276,23 @@ async function postForm(
273
276
  return parsed;
274
277
  }
275
278
 
279
+ /**
280
+ * The provider's key set when it publishes one, else the token-endpoint exemption. An injected
281
+ * `fetch` (a test, an egress proxy) gets a client of its own, so the key set travels the same
282
+ * path as the token; the global one shares `providerJwks`'s memo, so a key set is fetched once
283
+ * per TTL per process, not once per login.
284
+ */
285
+ function defaultIdTokenKeys(provider: OAuthProvider, options: OAuthExchangeOptions): IdTokenKeys {
286
+ if (provider.jwksUri === null) return 'token-endpoint-tls';
287
+ return options.fetch === undefined
288
+ ? providerJwks(provider)
289
+ : providerJwks(provider, {
290
+ fetch: options.fetch,
291
+ ...(options.clock === undefined ? {} : { clock: options.clock }),
292
+ ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
293
+ });
294
+ }
295
+
276
296
  /**
277
297
  * Validates the callback, then trades the code for tokens. PKCE's verifier travels here and
278
298
  * nowhere else — it is what proves this exchange belongs to the browser that started the flow.
@@ -341,7 +361,7 @@ export async function exchangeOAuthCode(
341
361
  clientId: options.credentials.clientId,
342
362
  nonce: handshake.nonce,
343
363
  clock,
344
- keys: options.keys ?? 'token-endpoint-tls',
364
+ keys: options.keys ?? defaultIdTokenKeys(provider, options),
345
365
  }),
346
366
  };
347
367
  }
@@ -8,6 +8,7 @@ import type { AuthAccount, AuthUser } from './adapter';
8
8
  import type { Auth, LoginResult } from './auth';
9
9
  import { normaliseEmail } from './email';
10
10
  import { AuthError, authWriteFailed } from './errors';
11
+ import type { IdTokenKeys } from './jwks';
11
12
  import { mfaChallengeRequired } from './mfa-challenge';
12
13
  import type { OAuthCallback, OAuthHandshake } from './oauth';
13
14
  import { oauthExchangeFailed, restartAt } from './oauth-errors';
@@ -326,6 +327,11 @@ export interface CompleteOAuthLoginInput {
326
327
  readonly userAgent?: string | null | undefined;
327
328
  /** The app's grant seam. Called once, after the profile is proven. */
328
329
  readonly resolveGrants?: ResolveOAuthGrants | undefined;
330
+ /**
331
+ * What the id token's signature is checked against — `exchangeOAuthCode`'s `keys`. Default:
332
+ * the provider's published key set when it has a `jwksUri`, token-endpoint TLS when it has none.
333
+ */
334
+ readonly idTokenKeys?: IdTokenKeys | undefined;
329
335
  }
330
336
 
331
337
  /** Callback → session, in one call: exchange, identify, sign in. */
@@ -339,6 +345,7 @@ export async function completeOAuthLogin(
339
345
  clock: auth.clock,
340
346
  fetch: input.fetch,
341
347
  timeoutMs: input.timeoutMs,
348
+ keys: input.idTokenKeys,
342
349
  });
343
350
  const options = { fetch: input.fetch, timeoutMs: input.timeoutMs };
344
351
  const { profile, userinfo } = await readOAuthProfile(provider, tokens, options);
@@ -2,7 +2,7 @@
2
2
  // consumer IdPs made an enterprise OP — Okta, Entra, Ping, an in-house OP — *unrepresentable*: the
3
3
  // constraint was a type, so there was no runtime escape and the only ways out were forking the
4
4
  // package or bypassing the whole subsystem, losing PKCE, the sealed handshake, issuer pinning and
5
- // account linking with it. The three built-ins register through the same call an app uses, so the
5
+ // account linking with it. The two built-ins register through the same call an app uses, so the
6
6
  // opening does not create a second path.
7
7
 
8
8
  import { renderCauseValue, renderFixLiteral } from '@ultimat3/core';
@@ -20,6 +20,7 @@ import {
20
20
  } from '@ultimat3/core';
21
21
  import type { Auth, LoginResult } from './auth';
22
22
  import { AuthError } from './errors';
23
+ import type { IdTokenKeys } from './jwks';
23
24
  import { beginOAuth, type OAuthProviderId } from './oauth';
24
25
  import { BUILTIN_OAUTH_PROVIDER_IDS } from './oauth-builtins';
25
26
  import { clearHandshakeCookie, handshakeCookie, readHandshakeCookie } from './oauth-cookie';
@@ -113,6 +114,15 @@ export interface OAuthLoginOptions {
113
114
  * sign-in rather than never.
114
115
  */
115
116
  readonly resolveGrants?: ResolveOAuthGrants | undefined;
117
+ /**
118
+ * What each provider's id token signature is checked against, asked per callback. Omit it and
119
+ * the callback verifies against the provider's published key set (`providerJwks`) whenever it
120
+ * has a `jwksUri` — token-endpoint TLS only for one that publishes none. Return
121
+ * `'token-endpoint-tls'` to opt a provider out, a `jwksClient({ jwksUri })` to pin another set,
122
+ * or `undefined` to leave that provider on the default. `undefined` is the safe "everyone else"
123
+ * branch: `providerJwks(providerFor('github'))` throws, because GitHub publishes no `jwks_uri`.
124
+ */
125
+ readonly idTokenKeys?: ((provider: OAuthProviderId) => IdTokenKeys | undefined) | undefined;
116
126
  readonly env?: Readonly<Record<string, string | undefined>> | undefined;
117
127
  }
118
128
 
@@ -227,13 +237,22 @@ function providerSegment(request: Request, leg: 'start' | 'callback'): string {
227
237
  return segments[index] ?? '';
228
238
  }
229
239
 
240
+ /** The app's answer for this provider, or nothing — so `undefined` falls through to the default. */
241
+ function idTokenKeysFor(
242
+ options: OAuthLoginOptions,
243
+ provider: OAuthProviderId,
244
+ ): { readonly idTokenKeys?: IdTokenKeys } {
245
+ const keys = options.idTokenKeys?.(provider);
246
+ return keys === undefined ? {} : { idTokenKeys: keys };
247
+ }
248
+
230
249
  /**
231
250
  * Both halves of "is this a provider", in one refusal. An unknown segment and a known provider
232
251
  * the app left out of `defineAuth({ providers })` are the same 404 on purpose — telling an
233
252
  * anonymous caller which of the two it hit describes the app's configuration for free.
234
253
  *
235
- * The list in the refusal is the THREE BUILT-INS, never the live registry and never
236
- * `defineAuth({ providers })`. Both of the latter are this deployment's own configuration, and
254
+ * The list in the refusal is the BUILT-INS (`BUILTIN_OAUTH_PROVIDER_IDS`), never the live
255
+ * registry and never `defineAuth({ providers })`. Both of the latter are this deployment's own configuration, and
237
256
  * this caller is an anonymous stranger who typed a URL: an app that registered an internal OP has
238
257
  * put its own vocabulary into the registry, and echoing it back names a system the stranger had no
239
258
  * way to know exists. The built-in list is a framework constant already in the public docs, so it
@@ -373,6 +392,7 @@ async function callbackHandler(
373
392
  ...(options.fetch === undefined ? {} : { fetch: options.fetch }),
374
393
  ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
375
394
  ...(options.resolveGrants === undefined ? {} : { resolveGrants: options.resolveGrants }),
395
+ ...idTokenKeysFor(options, provider),
376
396
  ip: options.clientIp?.(request) ?? null,
377
397
  userAgent: request.headers.get('user-agent'),
378
398
  });