@ultimat3/auth 30.9.0 → 31.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.
Files changed (46) hide show
  1. package/CLAUDE.md +55 -109
  2. package/README.md +100 -25
  3. package/package.json +5 -4
  4. package/src/adapter-subpath.ts +34 -0
  5. package/src/api-keys.ts +14 -1
  6. package/src/auth.ts +3 -3
  7. package/src/authorization-server-clients.ts +19 -1
  8. package/src/authorization-server-config.ts +13 -4
  9. package/src/authorization-server-installed.ts +34 -0
  10. package/src/authorization-server-postgres.ts +4 -2
  11. package/src/authorization-server-store.ts +14 -4
  12. package/src/authorization-server.ts +30 -10
  13. package/src/client-scope.ts +1 -1
  14. package/src/errors.ts +9 -1
  15. package/src/grant-authorization-code.ts +21 -16
  16. package/src/grant-device.ts +2 -2
  17. package/src/identity-provider-cookie.ts +89 -0
  18. package/src/identity-provider-oidc.ts +142 -0
  19. package/src/identity-provider-route-core.ts +253 -0
  20. package/src/identity-provider-routes.ts +78 -0
  21. package/src/identity-provider-status.ts +45 -0
  22. package/src/identity-provider.ts +112 -0
  23. package/src/index.ts +34 -54
  24. package/src/jwks.ts +2 -2
  25. package/src/kdf-gate.ts +24 -14
  26. package/src/limiter-install.ts +26 -16
  27. package/src/memory-adapter.ts +5 -5
  28. package/src/mfa.ts +1 -1
  29. package/src/oauth-discovery.ts +1 -1
  30. package/src/oauth-errors.ts +26 -2
  31. package/src/oauth-exchange.ts +2 -2
  32. package/src/oauth-paths.ts +5 -2
  33. package/src/oauth-route.ts +60 -297
  34. package/src/rate-limit.ts +7 -2
  35. package/src/saml-assertion.ts +156 -0
  36. package/src/saml-c14n.ts +118 -0
  37. package/src/saml-certificate.ts +47 -0
  38. package/src/saml-dom.ts +66 -0
  39. package/src/saml-dsig.ts +165 -0
  40. package/src/saml-email-trust.ts +49 -0
  41. package/src/saml-errors.ts +96 -0
  42. package/src/saml-metadata.ts +80 -0
  43. package/src/saml-provider.ts +179 -0
  44. package/src/saml-request.ts +51 -0
  45. package/src/saml-response.ts +131 -0
  46. package/src/saml-xml.ts +328 -0
@@ -1,7 +1,7 @@
1
- // Single responsibility: the two HTTP routes the OAuth library functions have always been
2
- // missing — the redirect out and the callback back — mounted at one fixed pair of paths so the
3
- // `fix:` lines that name them cannot go stale. Everything below composes `beginOAuth`,
4
- // `handshakeCookie` and `completeOAuthLogin`; no new protocol lives here.
1
+ // Single responsibility: `oauthLogin(auth)` — "log in with GitHub" in one call, as a front door
2
+ // over the ONE sign-in route implementation (`identity-provider-route-core.ts`): every registered
3
+ // OAuth provider this app enabled becomes an `oidcIdentityProvider` per request, so OAuth and SAML
4
+ // share the paths (`/auth/sso/:provider`), the sealed state cookie and the refusal body.
5
5
  //
6
6
  // A route DESCRIPTOR, never a mounted handler, for the reason `mcpHttpRoute()` is one:
7
7
  // `@ultimat3/http` is tier 2 like this package, so auth may not import it — and `defineRoute`
@@ -9,56 +9,28 @@
9
9
  // a test and mountable by any router that can match a `:param`.
10
10
 
11
11
  import {
12
- type Clock,
13
- EnvMissingError,
12
+ ConfigInvalidError,
14
13
  isUltimateError,
15
14
  logger,
16
- renderCauseValue,
17
- renderFixShellArg,
18
15
  renderThrowable,
19
16
  type UltimateError,
20
17
  } from '@ultimat3/core';
21
- import type { Auth, LoginResult } from './auth';
22
- import { AuthError } from './errors';
18
+ import type { Auth } from './auth';
19
+ import { oidcIdentityProvider } from './identity-provider-oidc';
20
+ import {
21
+ type SignInRouteDescriptor,
22
+ type SignInRouteMeta,
23
+ signInRoutes,
24
+ } from './identity-provider-route-core';
23
25
  import type { IdTokenKeys } from './jwks';
24
- import { beginOAuth, type OAuthProviderId } from './oauth';
26
+ import type { OAuthProviderId } from './oauth';
25
27
  import { BUILTIN_OAUTH_PROVIDER_IDS } from './oauth-builtins';
26
- import { clearHandshakeCookie, handshakeCookie, readHandshakeCookie } from './oauth-cookie';
27
- import { oauthExchangeFailed, oauthProviderUnknown, restartAt } from './oauth-errors';
28
+ import { oauthExchangeFailed, oauthProviderUnknown } from './oauth-errors';
28
29
  import type { OAuthClientCredentials, OAuthFetch } from './oauth-exchange';
29
30
  import { oauthCredentials } from './oauth-exchange';
30
- import { completeOAuthLogin, type ResolveOAuthGrants } from './oauth-login';
31
- import {
32
- OAUTH_CALLBACK_ROUTE_PATH,
33
- OAUTH_START_ROUTE_PATH,
34
- oauthCallbackPath,
35
- } from './oauth-paths';
31
+ import type { ResolveOAuthGrants } from './oauth-login';
36
32
  import { hasOAuthProvider } from './oauth-registry';
37
33
 
38
- /**
39
- * The provider came back with `error=` and no code — almost always the user pressing Cancel.
40
- * A separate code from `X_OAUTH_EXCHANGE_FAILED` on purpose: nothing was exchanged, nothing is
41
- * misconfigured, and folding the single commonest non-success outcome of a login into the code
42
- * that means "the client secret is wrong" makes both unreadable in a log and pages the wrong person.
43
- */
44
- export const oauthDenied = (
45
- provider: string,
46
- reason: string,
47
- description: string | null,
48
- ): AuthError =>
49
- new AuthError({
50
- code: 'X_OAUTH_DENIED',
51
- // `reason` and `description` are query parameters off the callback URL — whatever the browser
52
- // was redirected with, newlines and quotes included. `renderCauseValue` renders them as JSON
53
- // string literals, so a forged `error_description` cannot forge a second log line or break the
54
- // sentence around it. Both are already `string` by type: this is escaping, not throw-safety.
55
- cause: `${provider} declined the authorization: ${renderCauseValue(reason)}${
56
- description === null ? '' : ` (${renderCauseValue(description)})`
57
- }`,
58
- fix: `${restartAt(provider)} and approve the ${provider} consent screen`,
59
- meta: { provider, reason },
60
- });
61
-
62
34
  /**
63
35
  * What a router needs to mount one of these. Structural, like `RequestLike` in `session.ts`:
64
36
  * `@ultimat3/http` binds to this shape, this package never binds to `@ultimat3/http`.
@@ -71,6 +43,8 @@ export interface AuthRouteDescriptor {
71
43
  readonly name: string;
72
44
  /** Both legs are public by definition — they are how an anonymous visitor stops being one. */
73
45
  readonly auth: 'public';
46
+ /** What a router mounts as the route's `meta`, verbatim: `meta: leg.meta`. */
47
+ readonly meta: SignInRouteMeta;
74
48
  handle(request: Request): Promise<Response>;
75
49
  }
76
50
 
@@ -126,97 +100,10 @@ export interface OAuthLoginOptions {
126
100
  readonly env?: Readonly<Record<string, string | undefined>> | undefined;
127
101
  }
128
102
 
129
- /**
130
- * HTTP status per code, for a descriptor driven OUTSIDE a pipeline — a bare `Request` in, a
131
- * `Response` out, which is the whole point of a descriptor. `@ultimat3/http`'s `error-map.ts` OWNS
132
- * these numbers and is where a new one is declared; this package is the same tier and can never
133
- * import it, so the table is a copy the pin `scripts/oauth-route-status.test.ts` holds identical to
134
- * `statusFor()`. Everything absent is the provider's fault until proven otherwise: 502.
135
- */
136
- export const OAUTH_ROUTE_STATUS: Readonly<Record<string, number>> = {
137
- X_OAUTH_PROVIDER_UNKNOWN: 404,
138
- X_OAUTH_DENIED: 403,
139
- X_OAUTH_STATE_INVALID: 400,
140
- X_OAUTH_TOKEN_INVALID: 400,
141
- X_UNAUTHENTICATED: 401,
142
- X_MFA_REQUIRED: 401,
143
- X_ACCOUNT_LOCKED: 429,
144
- X_ENV_MISSING: 500,
145
- X_CONFIG_INVALID: 500,
146
- };
147
-
148
- /**
149
- * The status for a code, read as an OWN key of the table above.
150
- *
151
- * `OAUTH_ROUTE_STATUS[code] ?? 502` is an index read on a plain object, so `Object.prototype`
152
- * answered for `toString`, `constructor`, `hasOwnProperty` and `__proto__` — and `toString`
153
- * yields a FUNCTION, which was handed straight to `ResponseInit.status`. `code` is a plain
154
- * `string` on every `UltimateError`, so no cast makes that unreachable. Fourth instance of the
155
- * class in the framework, after i18n's catalog lookup, schema's `coerce` and mcp's `validate-args`.
156
- *
157
- * Exported so a test can drive it without building a `Response`; `OAUTH_ROUTE_STATUS` stays
158
- * exported unchanged, because `scripts/oauth-route-status.test.ts` pins it against
159
- * `@ultimat3/http`'s `statusFor`.
160
- */
161
- export function oauthRouteStatus(code: string): number {
162
- // 502 is the default: an uncoded throw on this path came out of the provider conversation.
163
- return Object.hasOwn(OAUTH_ROUTE_STATUS, code) ? (OAUTH_ROUTE_STATUS[code] ?? 502) : 502;
164
- }
165
-
166
- /** The one sentence every refusal carries outward. The event name is where the rest went. */
167
- const PUBLIC_CAUSE =
168
- 'the sign-in was refused; its cause and its fix are in this server\u2019s log under auth.oauth.refused';
169
-
170
- /** A code this framework would have minted — the only kind spliced into a command. */
171
- const FRAMEWORK_CODE = /^X_[A-Z0-9_]+$/;
172
-
173
- /**
174
- * What an anonymous caller is allowed to read: the code, its registered title and docs link, and
175
- * one fixed sentence. Both legs are public by definition, and an authored `cause`/`fix` is written
176
- * for the developer — it names env vars, endpoints and what a provider answered — so neither is
177
- * served, on any code: no per-code judgement about which sentence is safe today.
178
- */
179
- const publicBody = (coded: UltimateError): Record<string, string> => ({
180
- code: coded.code,
181
- title: coded.title,
182
- cause: PUBLIC_CAUSE,
183
- fix: FRAMEWORK_CODE.test(coded.code)
184
- ? `x errors explain ${renderFixShellArg(coded.code, '<CODE>')} --json`
185
- : 'x errors list --json',
186
- docs: coded.docs,
187
- });
188
-
189
- /**
190
- * Failure is JSON, never a redirect to a login page carrying `?error=`: the status and the code
191
- * are the answer a client branches on. The developer's half — the cause and the fix line — is
192
- * logged as `auth.oauth.refused`, where only an operator reads it. An app that wants a rendered
193
- * page wraps these two descriptors.
194
- */
195
- function problem(error: unknown, extraCookies: readonly string[]): Response {
196
- const coded = isUltimateError(error) ? error : uncoded(error);
197
- const status = oauthRouteStatus(coded.code);
198
- const fields = { code: coded.code, status, cause: coded.cause, fix: coded.fix };
199
- if (status >= 500) logger.error('auth.oauth.refused', fields);
200
- else logger.info('auth.oauth.refused', fields);
201
- const headers = new Headers({ 'content-type': 'application/json; charset=utf-8' });
202
- for (const cookie of extraCookies) headers.append('set-cookie', cookie);
203
- return new Response(JSON.stringify(publicBody(coded)), { status, headers });
204
- }
205
-
206
103
  /**
207
104
  * A throw with no code: a bug in an `AuthAdapter` or an `OAuthFetch` this package does not own.
208
- *
209
- * Its text goes to the LOG and never into the response. `publicBody` publishes `cause`, and the
210
- * rendered value went straight into it — so a bare
211
- * `Error('connect ECONNREFUSED 10.4.2.17:5432 (postgres://app:hunter2@db.internal/prod)')` from a
212
- * pool inside a callback was served to whoever typed the URL, as a 502 with the connection string
213
- * in the body. The comment above `publicBody` says a stack and `meta` must never reach that
214
- * reader; the field carrying the message was the one exception nobody meant to make.
215
- *
216
- * `renderThrowable`, never `error.message`: the value came from code this package does not own,
217
- * and a getter on `message` — or a `Proxy` trapping `getPrototypeOf` — would make the callback's
218
- * last answer throw instead of send. Same shape `rate-limit.ts`'s `loginFailed()` uses: one fixed
219
- * sentence outward, the discriminating detail kept where only an operator can read it.
105
+ * Its text goes to the LOG and never into the response — a pool error carrying a connection
106
+ * string was once served to whoever typed the URL. `renderThrowable`, never `error.message`.
220
107
  */
221
108
  function uncoded(error: unknown): UltimateError {
222
109
  logger.error('auth.oauth.uncoded_failure', { detail: renderThrowable(error) });
@@ -230,45 +117,12 @@ function uncoded(error: unknown): UltimateError {
230
117
  });
231
118
  }
232
119
 
233
- /** `/auth/oauth/github` → `github`; `/auth/oauth/github/callback` → `github`. */
234
- function providerSegment(request: Request, leg: 'start' | 'callback'): string {
235
- const segments = new URL(request.url).pathname.split('/').filter((s) => s.length > 0);
236
- const index = leg === 'start' ? segments.length - 1 : segments.length - 2;
237
- return segments[index] ?? '';
238
- }
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
-
249
120
  /**
250
- * Both halves of "is this a provider", in one refusal. An unknown segment and a known provider
251
- * the app left out of `defineAuth({ providers })` are the same 404 on purpose — telling an
252
- * anonymous caller which of the two it hit describes the app's configuration for free.
253
- *
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
256
- * this caller is an anonymous stranger who typed a URL: an app that registered an internal OP has
257
- * put its own vocabulary into the registry, and echoing it back names a system the stranger had no
258
- * way to know exists. The built-in list is a framework constant already in the public docs, so it
259
- * discloses nothing while still making the fix executable — and `registerOAuthProvider` in the
260
- * same sentence covers the other branch without enumerating anything.
261
- *
262
- * `providerFor()` keeps the full registered list for the same reason in reverse: its reader is a
263
- * developer holding a stack trace, and there the list is exactly what makes the fix runnable.
121
+ * Unknown and not-enabled are one 404, listing the BUILT-INS only: the caller is an anonymous
122
+ * stranger, and the live registry is this deployment's own vocabulary.
264
123
  */
265
- function assertEnabled(auth: Auth, segment: string): OAuthProviderId {
266
- const supported = BUILTIN_OAUTH_PROVIDER_IDS;
267
- if (!hasOAuthProvider(segment)) throw oauthProviderUnknown(segment, supported);
268
- const provider: OAuthProviderId = segment;
269
- if (!auth.providers.includes(provider)) throw oauthProviderUnknown(segment, supported);
270
- return provider;
271
- }
124
+ const unknown = (segment: string): UltimateError =>
125
+ oauthProviderUnknown(segment, BUILTIN_OAUTH_PROVIDER_IDS);
272
126
 
273
127
  /**
274
128
  * The credentials for a provider, or the SAME 404 an unknown provider gets.
@@ -297,145 +151,54 @@ function credentialsFor(
297
151
  cause: error.cause,
298
152
  fix: error.fix,
299
153
  });
300
- throw oauthProviderUnknown(provider, BUILTIN_OAUTH_PROVIDER_IDS);
301
- }
302
- }
303
-
304
- /**
305
- * The declared origin, or a refusal. The request's own origin is the `Host` header, so a
306
- * `redirect_uri` built from it is one the caller chose.
307
- */
308
- function declaredOrigin(options: OAuthLoginOptions): string {
309
- const env = options.env ?? Bun.env;
310
- const declared = (options.baseUrl ?? env['APP_URL'] ?? '').trim();
311
- if (declared === '') {
312
- throw new EnvMissingError({
313
- cause:
314
- 'APP_URL is not set and oauthLogin() was given no baseUrl, so there is no declared origin to build the provider redirect_uri from',
315
- fix: 'export APP_URL=http://localhost:3000 # the origin this app is served from',
316
- meta: { key: 'APP_URL' },
317
- });
318
- }
319
- return declared.replace(/\/+$/, '');
320
- }
321
-
322
- /** The handshake's own options, assembled once so both legs seal and open it identically. */
323
- const sealOptions = (
324
- clock: Clock,
325
- options: OAuthLoginOptions,
326
- ): { clock: Clock; secret?: string | undefined } => ({
327
- clock,
328
- ...(options.secret === undefined ? {} : { secret: options.secret }),
329
- });
330
-
331
- async function startHandler(
332
- auth: Auth,
333
- options: OAuthLoginOptions,
334
- request: Request,
335
- ): Promise<Response> {
336
- try {
337
- // Before the provider is looked at: asked after, a missing origin answered 500 for a provider
338
- // this app enabled and 404 for one it did not.
339
- const origin = declaredOrigin(options);
340
- const provider = assertEnabled(auth, providerSegment(request, 'start'));
341
- const credentials = credentialsFor(provider, options);
342
- const handshake = beginOAuth({
343
- provider,
344
- clientId: credentials.clientId,
345
- redirectUri: `${origin}${oauthCallbackPath(provider)}`,
346
- scopes: options.scopes,
347
- });
348
- return new Response(null, {
349
- // 302, the status every OAuth client already expects on this hop.
350
- status: 302,
351
- headers: {
352
- location: handshake.authorizeUrl,
353
- 'set-cookie': handshakeCookie(handshake, sealOptions(auth.clock, options)),
354
- },
355
- });
356
- } catch (error) {
357
- return problem(error, []);
154
+ throw unknown(provider);
358
155
  }
359
156
  }
360
157
 
361
- /** The provider declining is `error=` on the redirect, and there is no code to exchange. */
362
- function assertNoProviderError(url: URL, provider: string): void {
363
- const declined = url.searchParams.get('error');
364
- if (declined === null || declined === '') return;
365
- throw oauthDenied(provider, declined, url.searchParams.get('error_description'));
366
- }
367
-
368
- async function callbackHandler(
369
- auth: Auth,
370
- options: OAuthLoginOptions,
371
- request: Request,
372
- ): Promise<Response> {
373
- const segment = providerSegment(request, 'callback');
374
- // Cleared on every outcome, success and failure alike: the code it authorised is spent either
375
- // way, so a handshake that outlives its own callback is a replay window and nothing else.
376
- const clear = hasOAuthProvider(segment) ? [clearHandshakeCookie(segment)] : [];
377
- try {
378
- const provider = assertEnabled(auth, segment);
379
- // A provider with no credentials answers 404 on THIS leg too: one oracle, closed on both halves
380
- // — a 404 on start and a 500 on callback is the same enumeration one request later. And the
381
- // answer is what the exchange uses, or `options.env` would answer the start leg alone.
382
- const credentials = credentialsFor(provider, options);
383
- const url = new URL(request.url);
384
- assertNoProviderError(url, provider);
385
- const result: LoginResult = await completeOAuthLogin(auth, {
386
- handshake: readHandshakeCookie(request, provider, sealOptions(auth.clock, options)),
387
- callback: {
388
- state: url.searchParams.get('state') ?? '',
389
- code: url.searchParams.get('code') ?? '',
390
- },
391
- credentials,
392
- ...(options.fetch === undefined ? {} : { fetch: options.fetch }),
393
- ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
394
- ...(options.resolveGrants === undefined ? {} : { resolveGrants: options.resolveGrants }),
395
- ...idTokenKeysFor(options, provider),
396
- ip: options.clientIp?.(request) ?? null,
397
- userAgent: request.headers.get('user-agent'),
398
- });
399
- const headers = new Headers({
400
- // A fixed path, never `?next=`: an attacker-supplied return target on the one endpoint whose
401
- // job is to hand out a session is the classic open redirect, and `@ultimat3/http`'s
402
- // `nextAfterSignIn` is the one implementation of that check. Two copies is one that drifts.
403
- location: options.successPath ?? '/',
404
- });
405
- // 303: the callback may arrive as a `form_post`, and the destination is a GET either way.
406
- headers.append('set-cookie', result.cookie);
407
- for (const cookie of clear) headers.append('set-cookie', cookie);
408
- return new Response(null, { status: 303, headers });
409
- } catch (error) {
410
- return problem(error, clear);
411
- }
412
- }
158
+ /** A descriptor of a GET leg, narrowed to the `AuthRouteDescriptor` the type has always promised. */
159
+ const asGet = (descriptor: SignInRouteDescriptor): AuthRouteDescriptor =>
160
+ Object.freeze({ ...descriptor, method: 'GET' as const });
413
161
 
414
162
  /**
415
163
  * The two routes. Mount them and "log in with GitHub" is a button pointing at
416
- * `/auth/oauth/github` — no handshake store, no PKCE bookkeeping, no state check to forget.
164
+ * `/auth/sso/github` — no handshake store, no PKCE bookkeeping, no state check to forget.
417
165
  *
418
166
  * ```ts
419
167
  * const { start, callback } = oauthLogin(auth);
420
- * // start.path → '/auth/oauth/:provider'
421
- * // callback.path → '/auth/oauth/:provider/callback'
168
+ * // start.path → '/auth/sso/:provider'
169
+ * // callback.path → '/auth/sso/:provider/callback'
422
170
  * ```
423
171
  */
424
172
  export function oauthLogin(auth: Auth, options: OAuthLoginOptions = {}): OAuthLoginRoutes {
425
- return Object.freeze({
426
- start: Object.freeze({
427
- method: 'GET',
428
- path: OAUTH_START_ROUTE_PATH,
429
- name: 'auth.oauth.start',
430
- auth: 'public',
431
- handle: (request: Request) => startHandler(auth, options, request),
432
- } as const),
433
- callback: Object.freeze({
434
- method: 'GET',
435
- path: OAUTH_CALLBACK_ROUTE_PATH,
436
- name: 'auth.oauth.callback',
437
- auth: 'public',
438
- handle: (request: Request) => callbackHandler(auth, options, request),
439
- } as const),
173
+ const routes = signInRoutes(auth, options, {
174
+ resolve(segment, leg) {
175
+ if (!hasOAuthProvider(segment) || !auth.providers.includes(segment)) throw unknown(segment);
176
+ // Asked on the callback only: the start leg verifies nothing.
177
+ const keys = leg === 'callback' ? options.idTokenKeys?.(segment) : undefined;
178
+ return oidcIdentityProvider({
179
+ provider: segment,
180
+ credentials: credentialsFor(segment, options),
181
+ scopes: options.scopes,
182
+ fetch: options.fetch,
183
+ timeoutMs: options.timeoutMs,
184
+ ...(keys === undefined ? {} : { idTokenKeys: keys }),
185
+ });
186
+ },
187
+ unknown,
188
+ event: 'auth.oauth',
189
+ names: { start: 'auth.oauth.start', callback: 'auth.oauth.callback', acs: 'auth.oauth.acs' },
190
+ uncoded,
191
+ grants(identity) {
192
+ const resolve = options.resolveGrants;
193
+ if (resolve === undefined) return undefined;
194
+ if (identity.oauth === undefined) {
195
+ throw new ConfigInvalidError({
196
+ cause: `${identity.profile.provider} returned no OAuth grant context, so resolveGrants(profile, context) cannot be called`,
197
+ fix: 'identityProviderRoutes(auth, { providers, resolveGrants: (identity) => ({ roles }) }) // for a provider that is not OAuth',
198
+ });
199
+ }
200
+ return resolve(identity.profile, identity.oauth);
201
+ },
440
202
  });
203
+ return Object.freeze({ start: asGet(routes.start), callback: asGet(routes.callback) });
441
204
  }
package/src/rate-limit.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // many accounts) and per account (stops a spray against one) — because either alone is
4
4
  // bypassable. `loginFailed()` lives here so the throttle and the message can never drift apart.
5
5
 
6
- import type { Clock } from '@ultimat3/core';
6
+ import { addressNetwork, type Clock } from '@ultimat3/core';
7
7
  import { normaliseEmail } from './email';
8
8
  import {
9
9
  AuthError,
@@ -127,7 +127,12 @@ export interface MemoryAuthLimiter extends AuthLimiter {
127
127
  */
128
128
  export const accountKey = (email: string): string => `account:${normaliseEmail(email)}`;
129
129
 
130
- export const ipKey = (ip: string): string => `ip:${ip}`;
130
+ /**
131
+ * The bucket counts a NETWORK, by core's `addressNetwork`: IPv4 whole, an IPv4-mapped IPv6 as that
132
+ * IPv4, any other IPv6 as its /64. One subscriber is handed a /64, so keying on the full address
133
+ * gave every holder 2^64 fresh budgets. Text that is no address literal stays as it is.
134
+ */
135
+ export const ipKey = (ip: string): string => `ip:${addressNetwork(ip)}`;
131
136
 
132
137
  /**
133
138
  * The tenant bucket's key shape, declared here even though the general noisy-neighbour case lives
@@ -0,0 +1,156 @@
1
+ // Single responsibility: what a VERIFIED assertion says, checked — issuer, the bearer subject
2
+ // confirmation (recipient, InResponseTo, its window), the conditions (window, audience) — and read
3
+ // out as one `SamlAssertion`. It only ever sees the node whose signature `saml-response.ts` just
4
+ // verified: it is handed that node, never a document to search.
5
+
6
+ import { attribute, BEARER, childElements, NAMEID_TRANSIENT, SAML_NS, textOf } from './saml-dom';
7
+ import type { SamlCheck } from './saml-errors';
8
+ import type { XmlElement } from './saml-xml';
9
+
10
+ export interface SamlAssertion {
11
+ readonly id: string;
12
+ readonly issuer: string;
13
+ readonly nameId: string;
14
+ readonly nameIdFormat: string | null;
15
+ /** Every Attribute by `Name`, values in document order. */
16
+ readonly attributes: Readonly<Record<string, readonly string[]>>;
17
+ /** The last instant this assertion is acceptable: the earlier of the two NotOnOrAfter. */
18
+ readonly notOnOrAfter: Date;
19
+ /** `SessionNotOnOrAfter`, when the IdP bounds the session. */
20
+ readonly sessionNotOnOrAfter: Date | null;
21
+ readonly sessionIndex: string | null;
22
+ }
23
+
24
+ export interface AssertionExpectations {
25
+ readonly issuer: string;
26
+ readonly audience: string;
27
+ readonly recipient: string;
28
+ readonly requestId: string;
29
+ readonly now: Date;
30
+ readonly skewMs: number;
31
+ }
32
+
33
+ export class AssertionRefusal {
34
+ constructor(
35
+ readonly check: SamlCheck,
36
+ readonly detail: string,
37
+ ) {}
38
+ }
39
+ const refuse = (check: SamlCheck, detail: string): never => {
40
+ throw new AssertionRefusal(check, detail);
41
+ };
42
+
43
+ /** xs:dateTime, which SAML requires in UTC; anything unparseable is absent-and-wrong. */
44
+ const instant = (value: string | undefined): Date | null => {
45
+ if (value === undefined || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/.test(value))
46
+ return null;
47
+ const parsed = new Date(value);
48
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
49
+ };
50
+
51
+ const one = (parent: XmlElement, local: string, check: SamlCheck): XmlElement => {
52
+ const found = childElements(parent, SAML_NS, local);
53
+ const first = found[0];
54
+ return found.length === 1 && first !== undefined
55
+ ? first
56
+ : refuse(check, `expected one saml:${local}`);
57
+ };
58
+
59
+ const leaf = (element: XmlElement, check: SamlCheck): string =>
60
+ textOf(element) ?? refuse(check, `saml:${element.local} holds an element`);
61
+
62
+ function window(element: XmlElement, expect: AssertionExpectations, required: boolean): Date {
63
+ const now = expect.now.getTime();
64
+ const notBefore = attribute(element, 'NotBefore');
65
+ if (notBefore !== undefined) {
66
+ const at = instant(notBefore) ?? refuse('not-before', 'NotBefore is not a UTC instant');
67
+ if (at.getTime() - expect.skewMs > now)
68
+ refuse('not-before', `${element.local} is not yet valid`);
69
+ }
70
+ const raw = attribute(element, 'NotOnOrAfter');
71
+ if (raw === undefined && !required) return new Date(Number.POSITIVE_INFINITY);
72
+ const end = instant(raw) ?? refuse('expired', `${element.local} has no UTC NotOnOrAfter`);
73
+ if (end.getTime() + expect.skewMs <= now) refuse('expired', `${element.local} has expired`);
74
+ return end;
75
+ }
76
+
77
+ /** At least one bearer confirmation must hold — SAML Profiles §4.1.4.2. */
78
+ function confirmation(subject: XmlElement, expect: AssertionExpectations): Date {
79
+ let last: AssertionRefusal = new AssertionRefusal('subject', 'no bearer SubjectConfirmation');
80
+ for (const confirmation of childElements(subject, SAML_NS, 'SubjectConfirmation')) {
81
+ if (attribute(confirmation, 'Method') !== BEARER) continue;
82
+ try {
83
+ const data = one(confirmation, 'SubjectConfirmationData', 'subject');
84
+ if (attribute(data, 'Recipient') !== expect.recipient) {
85
+ refuse('recipient', 'the Recipient is not this ACS URL');
86
+ }
87
+ if (attribute(data, 'InResponseTo') !== expect.requestId) {
88
+ refuse('in-response-to', 'the confirmation answers another request');
89
+ }
90
+ return window(data, expect, true);
91
+ } catch (error) {
92
+ if (!(error instanceof AssertionRefusal)) throw error;
93
+ last = error;
94
+ }
95
+ }
96
+ throw last;
97
+ }
98
+
99
+ function conditions(assertion: XmlElement, expect: AssertionExpectations): Date {
100
+ const element = one(assertion, 'Conditions', 'audience');
101
+ const end = window(element, expect, false);
102
+ const restrictions = childElements(element, SAML_NS, 'AudienceRestriction');
103
+ if (restrictions.length === 0) refuse('audience', 'the assertion names no audience');
104
+ for (const restriction of restrictions) {
105
+ const audiences = childElements(restriction, SAML_NS, 'Audience').map((a) =>
106
+ leaf(a, 'audience'),
107
+ );
108
+ if (!audiences.includes(expect.audience)) refuse('audience', 'this SP is not an audience');
109
+ }
110
+ return end;
111
+ }
112
+
113
+ function attributes(assertion: XmlElement): Record<string, string[]> {
114
+ const out: Record<string, string[]> = Object.create(null);
115
+ for (const statement of childElements(assertion, SAML_NS, 'AttributeStatement')) {
116
+ for (const item of childElements(statement, SAML_NS, 'Attribute')) {
117
+ const name = attribute(item, 'Name');
118
+ if (name === undefined) continue;
119
+ const values = childElements(item, SAML_NS, 'AttributeValue').map((v) => leaf(v, 'subject'));
120
+ out[name] = [...(out[name] ?? []), ...values];
121
+ }
122
+ }
123
+ return out;
124
+ }
125
+
126
+ /** Check and read. Throws `AssertionRefusal`, which the caller turns into the coded error. */
127
+ export function readAssertion(assertion: XmlElement, expect: AssertionExpectations): SamlAssertion {
128
+ const issuer = leaf(one(assertion, 'Issuer', 'issuer'), 'issuer');
129
+ if (issuer !== expect.issuer)
130
+ refuse('issuer', 'the assertion Issuer is not the metadata entityID');
131
+ const subject = one(assertion, 'Subject', 'subject');
132
+ const nameIdElement = one(subject, 'NameID', 'subject');
133
+ const nameId = leaf(nameIdElement, 'subject').trim();
134
+ const nameIdFormat = attribute(nameIdElement, 'Format') ?? null;
135
+ if (nameId === '') refuse('subject', 'the NameID is empty');
136
+ // A transient NameID is a new id per login: linked by it, every sign-in is a stranger.
137
+ if (nameIdFormat === NAMEID_TRANSIENT) refuse('subject', 'the NameID is transient');
138
+ const confirmed = confirmation(subject, expect);
139
+ const conditioned = conditions(assertion, expect);
140
+ const authn = childElements(assertion, SAML_NS, 'AuthnStatement');
141
+ const statement = authn[0] ?? refuse('structure', 'the assertion carries no AuthnStatement');
142
+ const session = attribute(statement, 'SessionNotOnOrAfter');
143
+ return {
144
+ id: attribute(assertion, 'ID') ?? '',
145
+ issuer,
146
+ nameId,
147
+ nameIdFormat,
148
+ attributes: attributes(assertion),
149
+ notOnOrAfter: new Date(Math.min(confirmed.getTime(), conditioned.getTime())),
150
+ sessionNotOnOrAfter:
151
+ session === undefined
152
+ ? null
153
+ : (instant(session) ?? refuse('expired', 'bad SessionNotOnOrAfter')),
154
+ sessionIndex: attribute(statement, 'SessionIndex') ?? null,
155
+ };
156
+ }