@ultimat3/auth 25.2.0 → 26.1.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 +5 -3
- package/README.md +33 -8
- package/package.json +4 -4
- package/src/auth.ts +1 -1
- package/src/index.ts +0 -1
- package/src/oauth-builtins.ts +4 -28
- package/src/oauth-cookie.ts +1 -1
- package/src/oauth-discovery.ts +1 -1
- package/src/oauth-errors.ts +1 -1
- package/src/oauth-exchange.ts +26 -6
- package/src/oauth-login.ts +7 -0
- package/src/oauth-registry.ts +1 -1
- package/src/oauth-route.ts +22 -2
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
749
|
-
|
|
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
|
|
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": "
|
|
3
|
+
"version": "26.1.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": "
|
|
36
|
-
"@ultimat3/db": "
|
|
37
|
-
"@ultimat3/schema": "
|
|
35
|
+
"@ultimat3/core": "26.1.0",
|
|
36
|
+
"@ultimat3/db": "26.1.0",
|
|
37
|
+
"@ultimat3/schema": "26.1.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
|
|
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
package/src/oauth-builtins.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
// Single responsibility: the
|
|
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
|
|
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
|
|
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),
|
package/src/oauth-cookie.ts
CHANGED
|
@@ -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;
|
|
189
|
+
* would not reach it; neither built-in provider is configured that way.
|
|
190
190
|
*/
|
|
191
191
|
export function handshakeCookie(
|
|
192
192
|
handshake: OAuthHandshake,
|
package/src/oauth-discovery.ts
CHANGED
|
@@ -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
|
|
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
|
/**
|
package/src/oauth-errors.ts
CHANGED
|
@@ -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
|
|
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
|
package/src/oauth-exchange.ts
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
64
|
-
*
|
|
65
|
-
* endpoint
|
|
66
|
-
*
|
|
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 ??
|
|
364
|
+
keys: options.keys ?? defaultIdTokenKeys(provider, options),
|
|
345
365
|
}),
|
|
346
366
|
};
|
|
347
367
|
}
|
package/src/oauth-login.ts
CHANGED
|
@@ -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);
|
package/src/oauth-registry.ts
CHANGED
|
@@ -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
|
|
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';
|
package/src/oauth-route.ts
CHANGED
|
@@ -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
|
|
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
|
});
|