@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
@@ -0,0 +1,142 @@
1
+ // Single responsibility: a registered OAuth2/OIDC provider as an `IdentityProvider` — the existing
2
+ // handshake (`beginOAuth`), exchange (`exchangeOAuthCode`) and profile read (`readOAuthProfile`)
3
+ // behind the port, no protocol of its own. The port adds one thing: the handshake's `state` is
4
+ // claimed ONCE in the shared replay store, keyed on when it was issued, before the code is spent.
5
+
6
+ import { timingSafeEqual } from '@ultimat3/core';
7
+ import {
8
+ type IdentityProvider,
9
+ type IdentityRedirect,
10
+ replayStepFor,
11
+ type VerifiedIdentity,
12
+ } from './identity-provider';
13
+ import { isRecord } from './json';
14
+ import type { IdTokenKeys } from './jwks';
15
+ import {
16
+ assertOAuthCallback,
17
+ beginOAuth,
18
+ type OAuthHandshake,
19
+ type OAuthProviderId,
20
+ } from './oauth';
21
+ import { DEFAULT_HANDSHAKE_TTL_MS } from './oauth-cookie';
22
+ import { oauthDenied, oauthStateInvalid } from './oauth-errors';
23
+ import {
24
+ exchangeOAuthCode,
25
+ type OAuthClientCredentials,
26
+ type OAuthFetch,
27
+ oauthCredentials,
28
+ } from './oauth-exchange';
29
+ import { oauthGrantContext } from './oauth-grant-context';
30
+ import { readOAuthProfile } from './oauth-profile';
31
+
32
+ export interface OidcIdentityProviderConfig {
33
+ /** A registered `OAuthProvider` id (`registerOAuthProvider`, or a built-in). */
34
+ readonly provider: OAuthProviderId;
35
+ /** Default: the provider's two env vars, read per call, never at import. */
36
+ readonly credentials?: OAuthClientCredentials | undefined;
37
+ readonly scopes?: readonly string[] | undefined;
38
+ readonly fetch?: OAuthFetch | undefined;
39
+ readonly timeoutMs?: number | undefined;
40
+ readonly idTokenKeys?: IdTokenKeys | undefined;
41
+ }
42
+
43
+ const FIELDS = ['state', 'nonce', 'verifier', 'redirectUri', 'authorizeUrl'] as const;
44
+
45
+ /** The handshake, and when it was issued — the instant its single-use claim is keyed on. */
46
+ interface StoredHandshake {
47
+ readonly handshake: OAuthHandshake;
48
+ readonly issuedAt: number;
49
+ }
50
+
51
+ function readHandshake(provider: OAuthProviderId, state: string): StoredHandshake {
52
+ let parsed: unknown;
53
+ try {
54
+ parsed = JSON.parse(state);
55
+ } catch {
56
+ parsed = null;
57
+ }
58
+ if (
59
+ !isRecord(parsed) ||
60
+ !FIELDS.every((field) => typeof parsed[field] === 'string') ||
61
+ typeof parsed['issuedAt'] !== 'number' ||
62
+ !Number.isFinite(parsed['issuedAt'])
63
+ ) {
64
+ throw oauthStateInvalid(provider, 'the stored handshake is not a handshake');
65
+ }
66
+ const field = (name: (typeof FIELDS)[number]): string => String(parsed[name]);
67
+ return {
68
+ issuedAt: parsed['issuedAt'],
69
+ handshake: {
70
+ provider,
71
+ state: field('state'),
72
+ nonce: field('nonce'),
73
+ verifier: field('verifier'),
74
+ redirectUri: field('redirectUri'),
75
+ authorizeUrl: field('authorizeUrl'),
76
+ },
77
+ };
78
+ }
79
+
80
+ export function oidcIdentityProvider(config: OidcIdentityProviderConfig): IdentityProvider {
81
+ const id = config.provider;
82
+ const credentials = (): OAuthClientCredentials => config.credentials ?? oauthCredentials(id);
83
+ const provider: IdentityProvider = {
84
+ id,
85
+ protocol: 'oidc',
86
+ callbackMethod: 'GET',
87
+ async begin({ callbackUrl, clock }): Promise<IdentityRedirect> {
88
+ const handshake = beginOAuth({
89
+ provider: id,
90
+ clientId: credentials().clientId,
91
+ redirectUri: callbackUrl,
92
+ scopes: config.scopes,
93
+ });
94
+ const state = {
95
+ ...Object.fromEntries(FIELDS.map((field) => [field, handshake[field]])),
96
+ issuedAt: clock.now().getTime(),
97
+ };
98
+ return { location: handshake.authorizeUrl, state: JSON.stringify(state) };
99
+ },
100
+ answers(state, params) {
101
+ try {
102
+ return timingSafeEqual(readHandshake(id, state).handshake.state, params['state'] ?? '');
103
+ } catch {
104
+ return false;
105
+ }
106
+ },
107
+ async complete({ state, params, callbackUrl, clock, replay }): Promise<VerifiedIdentity> {
108
+ const { handshake, issuedAt } = readHandshake(id, state);
109
+ if (handshake.redirectUri !== callbackUrl) {
110
+ throw oauthStateInvalid(id, 'the handshake was started for another callback URL');
111
+ }
112
+ const declined = params['error'];
113
+ if (declined !== undefined && declined !== '') {
114
+ throw oauthDenied(id, declined, params['error_description'] ?? null);
115
+ }
116
+ const callback = { state: params['state'] ?? '', code: params['code'] ?? '' };
117
+ // The state check first, so a forged callback cannot spend the real browser's handshake.
118
+ assertOAuthCallback(handshake, callback);
119
+ const now = clock.now();
120
+ // Keyed on when the handshake was ISSUED, the way SAML's claim is keyed on NotOnOrAfter: a
121
+ // horizon read off `now` moved with every retry, so each 30 s step was a fresh claim row.
122
+ const horizon = new Date(issuedAt + DEFAULT_HANDSHAKE_TTL_MS);
123
+ if (horizon.getTime() < now.getTime()) {
124
+ throw oauthStateInvalid(id, 'the handshake expired before the provider answered');
125
+ }
126
+ if (!(await replay.claim(`oidc:${id}:${handshake.state}`, replayStepFor(horizon), now))) {
127
+ throw oauthStateInvalid(id, 'this handshake was already completed');
128
+ }
129
+ const options = { fetch: config.fetch, timeoutMs: config.timeoutMs };
130
+ const tokens = await exchangeOAuthCode(handshake, callback, {
131
+ credentials: credentials(),
132
+ clock,
133
+ ...options,
134
+ keys: config.idTokenKeys,
135
+ });
136
+ const { profile, userinfo } = await readOAuthProfile(id, tokens, options);
137
+ const oauth = oauthGrantContext(profile, tokens, options, userinfo);
138
+ return { profile, expiresAt: tokens.expiresAt, claims: oauth.idTokenClaims ?? {}, oauth };
139
+ },
140
+ };
141
+ return Object.freeze(provider);
142
+ }
@@ -0,0 +1,253 @@
1
+ // Single responsibility: the ONE implementation of the two sign-in legs over any `IdentityProvider`
2
+ // — start (`begin`, seal the state, 302) and callback (open it, `complete`, `signInWithIdentity`,
3
+ // 303; coded JSON on refusal). `identityProviderRoutes` and `oauthLogin` are this function with two
4
+ // policies, so the state cookie, the refusal body and the redirect rules cannot drift apart.
5
+
6
+ import {
7
+ EnvMissingError,
8
+ isUltimateError,
9
+ logger,
10
+ renderFixShellArg,
11
+ type UltimateError,
12
+ } from '@ultimat3/core';
13
+ import type { Auth } from './auth';
14
+ import {
15
+ type IdentityProvider,
16
+ signInWithIdentity,
17
+ type VerifiedIdentity,
18
+ } from './identity-provider';
19
+ import {
20
+ clearIdentityStateCookie,
21
+ identityStateCookie,
22
+ readIdentityState,
23
+ } from './identity-provider-cookie';
24
+ import { oauthRouteStatus } from './identity-provider-status';
25
+ import { oauthStateInvalid } from './oauth-errors';
26
+ import type { OAuthGrants } from './oauth-login';
27
+ import {
28
+ OAUTH_CALLBACK_ROUTE_PATH,
29
+ OAUTH_START_ROUTE_PATH,
30
+ oauthCallbackPath,
31
+ } from './oauth-paths';
32
+ import { samlResponseInvalid } from './saml-errors';
33
+
34
+ export interface SignInRouteDescriptor {
35
+ readonly method: 'GET' | 'POST';
36
+ /** The mount pattern, `:provider` included. The handler re-reads it off the request itself. */
37
+ readonly path: string;
38
+ readonly name: string;
39
+ /** Both legs are public by definition — they are how an anonymous visitor stops being one. */
40
+ readonly auth: 'public';
41
+ /** What a router mounts as the route's `meta`, verbatim: `meta: leg.meta`. */
42
+ readonly meta: SignInRouteMeta;
43
+ handle(request: Request): Promise<Response>;
44
+ }
45
+
46
+ /**
47
+ * The route meta, structurally `@ultimat3/http`'s (`Route['meta']`): this package is its tier and
48
+ * cannot import it. The POST leg — SAML's ACS — is `csrf: 'cookieless'` with its own
49
+ * `authenticate: () => null`: the IdP answers with an auto-submitted CROSS-SITE form, which the csrf
50
+ * stage refuses on any other route. CSRF is still defended there, by the protocol: the answer must
51
+ * carry this browser's RelayState (`answers()`, timing-safe against the HMAC-sealed state cookie)
52
+ * and its assertion must name this request's ID as InResponseTo — an attacker's form has neither.
53
+ * And no session cookie decides anything on it.
54
+ */
55
+ export interface SignInRouteMeta {
56
+ readonly name: string;
57
+ readonly auth: 'public';
58
+ // No `| undefined`: http's `Route['meta']` takes these absent or set, never `undefined`.
59
+ readonly csrf?: 'cookieless';
60
+ readonly authenticate?: () => null;
61
+ }
62
+
63
+ /** What both front doors accept. */
64
+ export interface SignInRouteOptions {
65
+ /** The origin the IdP answers to. Defaults to `APP_URL`; never the request's `Host`. */
66
+ readonly baseUrl?: string | undefined;
67
+ /** Defaults to `SESSION_SECRET`. Seals the state across the two requests. */
68
+ readonly secret?: string | undefined;
69
+ /** A fixed landing path, never `?next=`: `nextAfterSignIn` in `@ultimat3/http` is the one check. */
70
+ readonly successPath?: string | undefined;
71
+ /** The client address recorded on the session; `@ultimat3/http` knows the proxy chain. */
72
+ readonly clientIp?: ((request: Request) => string | null | undefined) | undefined;
73
+ readonly env?: Readonly<Record<string, string | undefined>> | undefined;
74
+ }
75
+
76
+ /** What differs between the two front doors. */
77
+ export interface SignInRoutePolicy {
78
+ /** The provider a URL segment names and this app enabled, or `unknown(segment)` for both. */
79
+ resolve(segment: string, leg: 'start' | 'callback'): IdentityProvider;
80
+ /** The one 404: unknown, not enabled, or answered by the wrong method — never told apart. */
81
+ unknown(segment: string): UltimateError;
82
+ /** `auth.sso` / `auth.oauth`: the log event prefix (`<prefix>.refused`). */
83
+ readonly event: string;
84
+ readonly names: { readonly start: string; readonly callback: string; readonly acs: string };
85
+ /** A throw with no code, logged and turned into the refusal served. Its text never is. */
86
+ uncoded(error: unknown): UltimateError;
87
+ grants(identity: VerifiedIdentity): Promise<OAuthGrants | undefined> | OAuthGrants | undefined;
88
+ }
89
+
90
+ export interface SignInRoutes {
91
+ readonly start: SignInRouteDescriptor;
92
+ /** OIDC's redirect back. */
93
+ readonly callback: SignInRouteDescriptor;
94
+ /** SAML's Assertion Consumer Service: the same path, POSTed. */
95
+ readonly acs: SignInRouteDescriptor;
96
+ }
97
+
98
+ const MAX_FORM_BYTES = 512 * 1024;
99
+ const FRAMEWORK_CODE = /^X_[A-Z0-9_]+$/;
100
+
101
+ function problem(policy: SignInRoutePolicy, error: unknown, cookies: readonly string[]): Response {
102
+ const refusal = isUltimateError(error) ? error : policy.uncoded(error);
103
+ const status = oauthRouteStatus(refusal.code);
104
+ const fields = { code: refusal.code, status, cause: refusal.cause, fix: refusal.fix };
105
+ if (status >= 500) logger.error(`${policy.event}.refused`, fields);
106
+ else logger.info(`${policy.event}.refused`, fields);
107
+ const headers = new Headers({ 'content-type': 'application/json; charset=utf-8' });
108
+ for (const cookie of cookies) headers.append('set-cookie', cookie);
109
+ // An anonymous caller reads the code, its title and docs, and one fixed sentence; the authored
110
+ // cause and fix name env vars and what an IdP answered, so they go to the log only.
111
+ const body = {
112
+ code: refusal.code,
113
+ title: refusal.title,
114
+ cause: `the sign-in was refused; its cause and its fix are in this server’s log under ${policy.event}.refused`,
115
+ fix: FRAMEWORK_CODE.test(refusal.code)
116
+ ? `x errors explain ${renderFixShellArg(refusal.code, '<CODE>')} --json`
117
+ : 'x errors list --json',
118
+ docs: refusal.docs,
119
+ };
120
+ return new Response(JSON.stringify(body), { status, headers });
121
+ }
122
+
123
+ function segmentOf(request: Request, leg: 'start' | 'callback'): string {
124
+ const parts = new URL(request.url).pathname.split('/').filter((part) => part !== '');
125
+ return parts[leg === 'start' ? parts.length - 1 : parts.length - 2] ?? '';
126
+ }
127
+
128
+ function origin(options: SignInRouteOptions): string {
129
+ const declared = (options.baseUrl ?? (options.env ?? Bun.env)['APP_URL'] ?? '').trim();
130
+ if (declared === '') {
131
+ throw new EnvMissingError({
132
+ cause:
133
+ 'APP_URL is not set and the sign-in routes were given no baseUrl, so there is no declared origin to build the IdP callback URL from',
134
+ fix: 'export APP_URL=http://localhost:3000 # the origin this app is served from',
135
+ meta: { key: 'APP_URL' },
136
+ });
137
+ }
138
+ return declared.replace(/\/+$/, '');
139
+ }
140
+
141
+ /**
142
+ * The form body, never more than `MAX_FORM_BYTES` of it: a declared length over the cap refuses
143
+ * before one byte is read, and an undeclared or lying one is cut off at the cap as it streams.
144
+ */
145
+ async function formParams(request: Request, provider: string): Promise<Record<string, string>> {
146
+ const tooLarge = () => samlResponseInvalid(provider, 'encoding', 'the posted form is too large');
147
+ const declared = Number(request.headers.get('content-length') ?? '0');
148
+ if (!Number.isFinite(declared) || declared > MAX_FORM_BYTES) throw tooLarge();
149
+ const decoder = new TextDecoder();
150
+ let text = '';
151
+ let size = 0;
152
+ const reader = request.body?.getReader();
153
+ for (;;) {
154
+ const next = await reader?.read();
155
+ if (next === undefined || next.done) break;
156
+ size += next.value.byteLength;
157
+ if (size > MAX_FORM_BYTES) {
158
+ await reader?.cancel();
159
+ throw tooLarge();
160
+ }
161
+ text += decoder.decode(next.value, { stream: true });
162
+ }
163
+ text += decoder.decode();
164
+ return Object.fromEntries(new URLSearchParams(text));
165
+ }
166
+
167
+ export function signInRoutes(
168
+ auth: Auth,
169
+ options: SignInRouteOptions,
170
+ policy: SignInRoutePolicy,
171
+ ): SignInRoutes {
172
+ const seal = {
173
+ clock: auth.clock,
174
+ ...(options.secret === undefined ? {} : { secret: options.secret }),
175
+ };
176
+
177
+ const start = async (request: Request): Promise<Response> => {
178
+ try {
179
+ // Before the provider: asked after, a missing origin answered 500 for an enabled provider
180
+ // and 404 for one that is not — an oracle for the app's configuration.
181
+ const base = origin(options);
182
+ const provider = policy.resolve(segmentOf(request, 'start'), 'start');
183
+ const callbackUrl = `${base}${oauthCallbackPath(provider.id)}`;
184
+ const redirect = await provider.begin({ callbackUrl, clock: auth.clock });
185
+ const headers = new Headers({ location: redirect.location });
186
+ headers.append('set-cookie', identityStateCookie(provider, redirect.state, seal));
187
+ return new Response(null, { status: 302, headers });
188
+ } catch (error) {
189
+ return problem(policy, error, []);
190
+ }
191
+ };
192
+
193
+ const callback = async (request: Request): Promise<Response> => {
194
+ const segment = segmentOf(request, 'callback');
195
+ // Set only once the answer is bound to this browser's state; then cleared on every outcome,
196
+ // since that answer is spent either way. An unbound answer — a junk cross-site POST riding the
197
+ // SameSite=None cookie — clears nothing, so it cannot end a sign-in in flight.
198
+ const clear: string[] = [];
199
+ try {
200
+ const base = origin(options);
201
+ const provider = policy.resolve(segment, 'callback');
202
+ // A provider answers by one method: SAML's POST binding, or OIDC's redirect.
203
+ if (request.method !== provider.callbackMethod) throw policy.unknown(provider.id);
204
+ const params =
205
+ request.method === 'POST'
206
+ ? await formParams(request, provider.id)
207
+ : Object.fromEntries(new URL(request.url).searchParams);
208
+ const state = readIdentityState(request, provider, seal);
209
+ if (!provider.answers(state, params)) {
210
+ throw oauthStateInvalid(provider.id, 'the answer does not carry this browser\u2019s state');
211
+ }
212
+ clear.push(clearIdentityStateCookie(provider.id, provider.callbackMethod));
213
+ const identity = await provider.complete({
214
+ state,
215
+ params,
216
+ callbackUrl: `${base}${oauthCallbackPath(provider.id)}`,
217
+ clock: auth.clock,
218
+ replay: auth.totpReplay,
219
+ });
220
+ const grants = await policy.grants(identity);
221
+ const result = await signInWithIdentity(auth, identity, {
222
+ ...(grants === undefined ? {} : { grants }),
223
+ ip: options.clientIp?.(request) ?? null,
224
+ userAgent: request.headers.get('user-agent'),
225
+ });
226
+ const headers = new Headers({ location: options.successPath ?? '/' });
227
+ headers.append('set-cookie', result.cookie);
228
+ for (const cookie of clear) headers.append('set-cookie', cookie);
229
+ // 303: the answer may arrive as a POST, and the destination is a GET either way.
230
+ return new Response(null, { status: 303, headers });
231
+ } catch (error) {
232
+ return problem(policy, error, clear);
233
+ }
234
+ };
235
+
236
+ const route = (
237
+ method: 'GET' | 'POST',
238
+ path: string,
239
+ name: string,
240
+ handle: (request: Request) => Promise<Response>,
241
+ ): SignInRouteDescriptor => {
242
+ const meta: SignInRouteMeta =
243
+ method === 'POST'
244
+ ? { name, auth: 'public', csrf: 'cookieless', authenticate: () => null }
245
+ : { name, auth: 'public' };
246
+ return Object.freeze({ method, path, name, auth: 'public', meta: Object.freeze(meta), handle });
247
+ };
248
+ return Object.freeze({
249
+ start: route('GET', OAUTH_START_ROUTE_PATH, policy.names.start, start),
250
+ callback: route('GET', OAUTH_CALLBACK_ROUTE_PATH, policy.names.callback, callback),
251
+ acs: route('POST', OAUTH_CALLBACK_ROUTE_PATH, policy.names.acs, callback),
252
+ });
253
+ }
@@ -0,0 +1,78 @@
1
+ // Single responsibility: `identityProviderRoutes(auth, { providers })` — the sign-in routes over a
2
+ // fixed list of `IdentityProvider`s (SAML, OIDC, an app's own). The legs themselves are
3
+ // `identity-provider-route-core.ts`, shared with `oauthLogin`; this file is the list, its one 404,
4
+ // and the grant seam over a `VerifiedIdentity`.
5
+
6
+ import {
7
+ ConfigInvalidError,
8
+ logger,
9
+ renderCauseValue,
10
+ renderThrowable,
11
+ type UltimateError,
12
+ } from '@ultimat3/core';
13
+ import type { Auth } from './auth';
14
+ import { AuthError } from './errors';
15
+ import type { IdentityProvider, ResolveIdentityGrants } from './identity-provider';
16
+ import {
17
+ type SignInRouteDescriptor,
18
+ type SignInRouteOptions,
19
+ type SignInRoutes,
20
+ signInRoutes,
21
+ } from './identity-provider-route-core';
22
+
23
+ export type IdentityRouteDescriptor = SignInRouteDescriptor;
24
+ export type IdentityProviderRoutes = SignInRoutes;
25
+
26
+ export interface IdentityProviderRoutesOptions extends SignInRouteOptions {
27
+ readonly providers: readonly IdentityProvider[];
28
+ /** What a verified identity is entitled to — business convention, never shipped (axiom 8). */
29
+ readonly resolveGrants?: ResolveIdentityGrants | undefined;
30
+ }
31
+
32
+ export const identityProviderUnknown = (segment: string): AuthError =>
33
+ new AuthError({
34
+ code: 'X_IDENTITY_PROVIDER_UNKNOWN',
35
+ cause: `no identity provider ${renderCauseValue(segment)} is served by these routes`,
36
+ fix: 'identityProviderRoutes(auth, { providers: [samlIdentityProvider({ id, … })] }) // and add the id to defineAuth({ providers }), or correct the id in the link',
37
+ });
38
+
39
+ /** An uncoded throw's text goes to the log only — never into a public body. */
40
+ function uncoded(error: unknown): UltimateError {
41
+ logger.error('auth.sso.uncoded_failure', { detail: renderThrowable(error) });
42
+ return new ConfigInvalidError({
43
+ cause:
44
+ 'a provider, adapter or fetch this package does not own threw a value with no error code',
45
+ fix: 'new AuthError({ code, cause, fix }) // throw it from the provider or adapter that threw; its value is logged as auth.sso.uncoded_failure',
46
+ });
47
+ }
48
+
49
+ export function identityProviderRoutes(
50
+ auth: Auth,
51
+ options: IdentityProviderRoutesOptions,
52
+ ): IdentityProviderRoutes {
53
+ const byId = new Map<string, IdentityProvider>();
54
+ for (const provider of options.providers) {
55
+ if (byId.has(provider.id)) {
56
+ throw new ConfigInvalidError({
57
+ cause: `two identity providers claim the id ${renderCauseValue(provider.id)}`,
58
+ fix: 'identityProviderRoutes(auth, { providers: [first, second] }) // each with its own id',
59
+ });
60
+ }
61
+ byId.set(provider.id, provider);
62
+ }
63
+ return signInRoutes(auth, options, {
64
+ // Unknown and not-enabled are one 404, so the routes describe no configuration.
65
+ resolve(segment) {
66
+ const provider = byId.get(segment);
67
+ if (provider === undefined || !auth.providers.includes(segment)) {
68
+ throw identityProviderUnknown(segment);
69
+ }
70
+ return provider;
71
+ },
72
+ unknown: identityProviderUnknown,
73
+ event: 'auth.sso',
74
+ names: { start: 'auth.sso.start', callback: 'auth.sso.callback', acs: 'auth.sso.acs' },
75
+ uncoded,
76
+ grants: (identity) => options.resolveGrants?.(identity),
77
+ });
78
+ }
@@ -0,0 +1,45 @@
1
+ // Single responsibility: the code → HTTP status table the sign-in route descriptors answer with
2
+ // when driven outside a pipeline — one table for every IdentityProvider route, OIDC and SAML alike,
3
+ // pinned to `@ultimat3/http`'s `statusFor` by `scripts/oauth-route-status.test.ts` and
4
+ // `scripts/identity-route-status.test.ts`.
5
+
6
+ /**
7
+ * HTTP status per code for the sign-in descriptors (`identityProviderRoutes`, and `oauthLogin` over
8
+ * it), driven OUTSIDE a pipeline — a bare `Request` in, a
9
+ * `Response` out, which is the whole point of a descriptor. `@ultimat3/http`'s `error-map.ts` OWNS
10
+ * these numbers and is where a new one is declared; this package is the same tier and can never
11
+ * import it, so the table is a copy the pin `scripts/oauth-route-status.test.ts` holds identical to
12
+ * `statusFor()`. Everything absent is the provider's fault until proven otherwise: 502.
13
+ */
14
+ export const OAUTH_ROUTE_STATUS: Readonly<Record<string, number>> = {
15
+ X_OAUTH_PROVIDER_UNKNOWN: 404,
16
+ X_OAUTH_DENIED: 403,
17
+ X_OAUTH_STATE_INVALID: 400,
18
+ X_OAUTH_TOKEN_INVALID: 400,
19
+ X_UNAUTHENTICATED: 401,
20
+ X_MFA_REQUIRED: 401,
21
+ X_ACCOUNT_LOCKED: 429,
22
+ X_ENV_MISSING: 500,
23
+ X_CONFIG_INVALID: 500,
24
+ X_SAML_RESPONSE_INVALID: 400,
25
+ X_SAML_METADATA_INVALID: 500,
26
+ X_IDENTITY_PROVIDER_UNKNOWN: 404,
27
+ };
28
+
29
+ /**
30
+ * The status for a code, read as an OWN key of the table above.
31
+ *
32
+ * `OAUTH_ROUTE_STATUS[code] ?? 502` is an index read on a plain object, so `Object.prototype`
33
+ * answered for `toString`, `constructor`, `hasOwnProperty` and `__proto__` — and `toString`
34
+ * yields a FUNCTION, which was handed straight to `ResponseInit.status`. `code` is a plain
35
+ * `string` on every `UltimateError`, so no cast makes that unreachable. Fourth instance of the
36
+ * class in the framework, after i18n's catalog lookup, schema's `coerce` and mcp's `validate-args`.
37
+ *
38
+ * Exported so a test can drive it without building a `Response`; `OAUTH_ROUTE_STATUS` stays
39
+ * exported unchanged, because `scripts/oauth-route-status.test.ts` pins it against
40
+ * `@ultimat3/http`'s `statusFor`.
41
+ */
42
+ export function oauthRouteStatus(code: string): number {
43
+ // 502 is the default: an uncoded throw on this path came out of the provider conversation.
44
+ return Object.hasOwn(OAUTH_ROUTE_STATUS, code) ? (OAUTH_ROUTE_STATUS[code] ?? 502) : 502;
45
+ }
@@ -0,0 +1,112 @@
1
+ // Single responsibility: the IdentityProvider port — the one shape every federated sign-in
2
+ // protocol takes: send the browser out (`begin`), take its answer back (`complete`), hand over one
3
+ // proven `OAuthProfile`. OIDC (`oidcIdentityProvider`) and SAML (`samlIdentityProvider`) are its two
4
+ // adapters; `signInWithIdentity` is the one way either becomes a session.
5
+
6
+ import type { Clock } from '@ultimat3/core';
7
+ import type { Auth, LoginResult } from './auth';
8
+ import { type TotpReplayGuard, totpStep } from './mfa';
9
+ import type { OAuthGrantContext } from './oauth-grant-context';
10
+ import { type OAuthGrants, signInWithOAuth } from './oauth-login';
11
+ import type { OAuthProfile } from './oauth-profile';
12
+
13
+ export interface IdentityBeginInput {
14
+ /** The absolute URL the IdP answers to: the callback route for this provider. */
15
+ readonly callbackUrl: string;
16
+ readonly clock: Clock;
17
+ }
18
+
19
+ export interface IdentityRedirect {
20
+ /** Where the browser is sent. */
21
+ readonly location: string;
22
+ /**
23
+ * What the second leg must be handed back, opaque to the caller. Not secret from the browser
24
+ * that started the flow, but it must not be FORGEABLE: the routes seal it in a signed cookie.
25
+ */
26
+ readonly state: string;
27
+ }
28
+
29
+ export interface IdentityCompleteInput {
30
+ /** `IdentityRedirect.state`, as the caller sealed and re-opened it. */
31
+ readonly state: string;
32
+ /** The IdP's answer: the callback's query (GET) or form body (POST). */
33
+ readonly params: Readonly<Record<string, string>>;
34
+ /** The same URL `begin` was given. */
35
+ readonly callbackUrl: string;
36
+ readonly clock: Clock;
37
+ /**
38
+ * The single-use claim store: an answer completes ONCE. The routes pass `auth.totpReplay`,
39
+ * the store a framework boot installs shared across replicas (`installTotpReplay`).
40
+ */
41
+ readonly replay: TotpReplayGuard;
42
+ }
43
+
44
+ export interface VerifiedIdentity {
45
+ readonly profile: OAuthProfile;
46
+ /** When the IdP says the federated session ends, if it says. */
47
+ readonly expiresAt: Date | null;
48
+ /** Everything else the IdP asserted — id-token claims or SAML attributes — for the grant seam. */
49
+ readonly claims: Readonly<Record<string, unknown>>;
50
+ /** OIDC only: the token set and a lazy, subject-checked `userinfo()` (`oauthLogin`'s grant seam). */
51
+ readonly oauth?: OAuthGrantContext | undefined;
52
+ }
53
+
54
+ export interface IdentityProvider {
55
+ /** Stable id: the route segment, `OAuthProfile.provider`, and the `x_accounts.provider` value. */
56
+ readonly id: string;
57
+ readonly protocol: 'oidc' | 'saml';
58
+ /** How the IdP returns: SAML's HTTP-POST binding is a cross-site POST, OIDC's a GET. */
59
+ readonly callbackMethod: 'GET' | 'POST';
60
+ begin(input: IdentityBeginInput): Promise<IdentityRedirect>;
61
+ /**
62
+ * Whether `params` answer THIS state — RelayState for SAML, `state` for OIDC — compared in
63
+ * constant time, nothing verified beyond it. The routes clear a browser's state cookie only on an
64
+ * answer bound to it: the ACS cookie rides any cross-site POST, so clearing on every outcome let
65
+ * any site end a sign-in in flight with one junk form.
66
+ */
67
+ answers(state: string, params: Readonly<Record<string, string>>): boolean;
68
+ /** Every refusal is a coded `AuthError`; nothing here creates a session. */
69
+ complete(input: IdentityCompleteInput): Promise<VerifiedIdentity>;
70
+ }
71
+
72
+ /** The app's grant seam over a verified identity — business convention, never shipped (axiom 8). */
73
+ export type ResolveIdentityGrants = (
74
+ identity: VerifiedIdentity,
75
+ ) => Promise<OAuthGrants> | OAuthGrants;
76
+
77
+ export interface IdentitySignInOptions {
78
+ readonly grants?: OAuthGrants | undefined;
79
+ readonly ip?: string | null | undefined;
80
+ readonly userAgent?: string | null | undefined;
81
+ }
82
+
83
+ /**
84
+ * A verified identity → a session, through the same account-link, MFA and session rules an OAuth
85
+ * login runs (`signInWithOAuth`). The provider id must be in `defineAuth({ providers })`.
86
+ */
87
+ export async function signInWithIdentity(
88
+ auth: Auth,
89
+ identity: VerifiedIdentity,
90
+ options: IdentitySignInOptions = {},
91
+ ): Promise<LoginResult> {
92
+ return await signInWithOAuth(auth, {
93
+ profile: identity.profile,
94
+ // The framework stores no provider credential (`accountFor`); only the expiry is carried.
95
+ tokens: {
96
+ accessToken: '',
97
+ refreshToken: null,
98
+ expiresAt: identity.expiresAt,
99
+ idToken: null,
100
+ claims: null,
101
+ },
102
+ ip: options.ip,
103
+ userAgent: options.userAgent,
104
+ ...(options.grants === undefined ? {} : { grants: options.grants }),
105
+ });
106
+ }
107
+
108
+ /**
109
+ * The replay store forgets a claim once its step falls behind the clock, so a claim is filed
110
+ * under the step of the LAST instant the answer is acceptable: it outlives its own window.
111
+ */
112
+ export const replayStepFor = (until: Date): number => totpStep(until);