@aooth/idp 0.1.8

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.
@@ -0,0 +1,671 @@
1
+ import { Clock } from "@aooth/auth";
2
+ import { JWTPayload, JWTVerifyGetKey } from "jose";
3
+ import { FederatedIdentity, FederatedIdentityStore, FederatedProfileSnapshot, UserService } from "@aooth/user";
4
+
5
+ //#region src/errors.d.ts
6
+ /**
7
+ * Federated-login failure taxonomy (RFC IDP.md §8). Mirrors `AuthError` /
8
+ * `UserAuthError` — a typed `Error` with a stable `type` code, an optional
9
+ * override `message`, and structured `details`.
10
+ *
11
+ * The HTTP/redirect mapping (§8 "fail soft — 302, never 500") is the phase-3
12
+ * `OAuthController`'s job; this layer only classifies. Messages are deliberately
13
+ * benign so the controller can surface them without leaking CSRF-vs-expiry.
14
+ */
15
+ type OAuthErrorType = /** `:provider` did not resolve in the registry (→ HTTP 404 in phase 3). */"UNKNOWN_PROVIDER" /** Misconfigured provider/registry (missing clientId, issuer, baseUrl, secret). */ | "INVALID_CONFIG" /** Signed `state` failed signature/binding verification (CSRF). */ | "STATE_INVALID" /** Signed `state` is well-formed but past its TTL — restart `/start`. */ | "STATE_EXPIRED" /** Provider returned `?error=` or the user denied consent. */ | "PROVIDER_DENIED" /** Token-endpoint exchange failed: network, 5xx, malformed body, or `code` reuse. */ | "EXCHANGE_FAILED" /** JWKS / discovery document fetch failed — verification fails CLOSED (§7). */ | "JWKS_FAILED" /** OIDC ID-token failed the OIDC Core 3.1.3.7 validation list (§7). */ | "ID_TOKEN_INVALID" /** Policy needed a verified email but the provider returned none. */ | "EMAIL_UNAVAILABLE";
16
+ declare class OAuthError extends Error {
17
+ readonly type: OAuthErrorType;
18
+ readonly details?: Record<string, unknown> | undefined;
19
+ readonly name = "OAuthError";
20
+ constructor(type: OAuthErrorType, message?: string, details?: Record<string, unknown> | undefined);
21
+ }
22
+ //#endregion
23
+ //#region src/types.d.ts
24
+ /**
25
+ * A provider profile normalized to a single shape across Google / OIDC / etc.
26
+ * Structural superset of `@aooth/user`'s {@link FederatedProfileSnapshot} (the
27
+ * display fields stored on the federated row) plus the join keys and the
28
+ * transient `raw` claims.
29
+ */
30
+ interface NormalizedProfile extends FederatedProfileSnapshot {
31
+ /** Provider id — `'google'`, `'oidc:<issuer>'`, … (matches `IdentityProvider.id`). */
32
+ provider: string;
33
+ /** The IdP's stable subject id (`sub`) — the durable join key. */
34
+ subject: string;
35
+ /**
36
+ * The raw verified claims / userinfo. **Transient** — handed to a (future)
37
+ * claims-mapper hook, NEVER persisted on the federated row (RFC §7). Typed
38
+ * `unknown` so consumers narrow it explicitly.
39
+ */
40
+ raw: unknown;
41
+ }
42
+ interface AuthorizationUrlArgs {
43
+ /**
44
+ * FIXED per provider (`baseUrl` + the constant callback path); exact-match
45
+ * registered at the provider. The `exchange()` `redirectUri` MUST byte-equal
46
+ * this value.
47
+ */
48
+ redirectUri: string;
49
+ /** Opaque CSRF/binding token (a signed-state JWT — see `signState`). */
50
+ state: string;
51
+ /** PKCE S256 code challenge (base64url of SHA-256(verifier)). */
52
+ codeChallenge: string;
53
+ /** OIDC nonce minted at `/start`, asserted in `exchange()`. Ignored by pure OAuth2. */
54
+ nonce?: string;
55
+ /** Override the provider's default scope set. */
56
+ scopes?: string[];
57
+ }
58
+ interface ExchangeArgs {
59
+ /** Authorization `code` returned on the callback. */
60
+ code: string;
61
+ /** MUST byte-equal the `redirectUri` used to build the authorization URL. */
62
+ redirectUri: string;
63
+ /** The PKCE code verifier matching the challenge sent at `/start`. */
64
+ codeVerifier: string;
65
+ /** OIDC: assert `id_token.nonce === expectedNonce`. Omit for pure OAuth2. */
66
+ expectedNonce?: string;
67
+ }
68
+ /**
69
+ * A pluggable identity provider. Three members (RFC §3.4); built-ins
70
+ * additionally implement {@link ConfigurableProvider.applyDefaults} so the
71
+ * registry can inject shared verification config.
72
+ */
73
+ interface IdentityProvider {
74
+ /** Stable id used in `:provider` routes, the federated `provider` column, and state binding. */
75
+ readonly id: string;
76
+ /** Build the `302`-target authorization URL. Async because OIDC discovers its endpoints. */
77
+ authorizationUrl(args: AuthorizationUrlArgs): Promise<string>;
78
+ /**
79
+ * Exchange the `code` for tokens, fully verify them (OIDC: §7 list), and
80
+ * normalize. Throws {@link OAuthError} (`EXCHANGE_FAILED` / `ID_TOKEN_INVALID`
81
+ * / `JWKS_FAILED` / `EMAIL_UNAVAILABLE`) on failure.
82
+ */
83
+ exchange(args: ExchangeArgs): Promise<NormalizedProfile>;
84
+ }
85
+ /**
86
+ * Minimal structural `fetch` — the global `fetch` satisfies it, and tests
87
+ * inject a deterministic fake. Only the members the OIDC client uses are
88
+ * declared.
89
+ */
90
+ type FetchLike = (url: string, init?: {
91
+ method?: string;
92
+ headers?: Record<string, string>;
93
+ body?: string;
94
+ }) => Promise<FetchResponseLike>;
95
+ interface FetchResponseLike {
96
+ ok: boolean;
97
+ status: number;
98
+ json(): Promise<unknown>;
99
+ text(): Promise<string>;
100
+ }
101
+ /**
102
+ * Verification config the {@link OAuthProviderRegistry} sets once and injects
103
+ * into each provider that opts in via {@link ConfigurableProvider.applyDefaults}
104
+ * (decision #2 — registry holds the shared config, providers stay standalone).
105
+ * A provider's own constructor value always wins over an injected default.
106
+ */
107
+ interface SharedProviderConfig {
108
+ /** Bounded clock skew for OIDC `exp`/`iat`/`nbf`, seconds. Default `5`. */
109
+ clockToleranceSec?: number;
110
+ /** JWKS caching/rotation knobs. */
111
+ jwks?: {
112
+ cacheTtlMs?: number;
113
+ refreshOnUnknownKid?: boolean;
114
+ };
115
+ /** Shared injectable clock (deterministic tests). */
116
+ clock?: Clock;
117
+ /** Shared injectable fetch (deterministic tests / custom agent). */
118
+ fetch?: FetchLike;
119
+ }
120
+ /** A provider that accepts registry-injected {@link SharedProviderConfig}. */
121
+ interface ConfigurableProvider extends IdentityProvider {
122
+ applyDefaults(shared: SharedProviderConfig): void;
123
+ }
124
+ /** Structural check used by the registry to feature-detect `applyDefaults`. */
125
+ declare function isConfigurableProvider(p: IdentityProvider): p is ConfigurableProvider;
126
+ /**
127
+ * How a federated login that matches an existing local account **by email**
128
+ * is handled (RFC §4 — the account-takeover-sensitive knob).
129
+ */
130
+ type EmailMatchPolicy = /** Never match by email — always create a fresh account. */"create-separate"
131
+ /**
132
+ * Auto-link only when the provider's `email_verified === true` AND the
133
+ * provider is in `trustEmailVerifiedFrom`. A deliberate security downgrade.
134
+ */
135
+ | "auto-link-if-verified" /** Default & safest: surface the candidate; require interactive proof-of-control to link. */ | "require-interactive-link";
136
+ interface FederatedPolicy {
137
+ /** Default `'require-interactive-link'`. */
138
+ emailMatch?: EmailMatchPolicy;
139
+ /** `false` → unknown subjects with no link are rejected (invite-only). Default `true`. */
140
+ allowSignup?: boolean;
141
+ /** Derives the new account's username. Default: `email`, else `${provider}:${subject}`. */
142
+ usernameStrategy?: (p: NormalizedProfile) => string;
143
+ /** Providers whose `email_verified` we trust for `auto-link-if-verified`. Default `[]`. */
144
+ trustEmailVerifiedFrom?: string[];
145
+ }
146
+ type ResolvedFederatedPolicy = Required<FederatedPolicy>;
147
+ /** Default username strategy: the verified email, else the stable `provider:subject`. */
148
+ declare function defaultUsernameStrategy(p: NormalizedProfile): string;
149
+ /** Apply RFC §4 safe defaults over a partial policy. */
150
+ declare function resolveFederatedPolicy(policy?: FederatedPolicy): ResolvedFederatedPolicy;
151
+ /**
152
+ * The outcome of {@link FederatedLoginService.resolveUser} (decision #1). A
153
+ * discriminated union — richer than the RFC's `{ userId, isNew }` sketch — so
154
+ * the phase-3 `oauth-exchange` step can branch: proceed into the gates
155
+ * (`linked`/`created`/`auto-linked`), divert to an interactive link sub-flow
156
+ * (`needs-link`), or fail soft (`denied`).
157
+ */
158
+ type ResolveOutcome = /** Known `(provider, subject)` → its owning user. */{
159
+ kind: "linked";
160
+ userId: string;
161
+ isNew: false;
162
+ } /** No match → a fresh account was created and linked. */ | {
163
+ kind: "created";
164
+ userId: string;
165
+ isNew: true;
166
+ } /** Email matched an existing account and policy auto-linked it. */ | {
167
+ kind: "auto-linked";
168
+ userId: string;
169
+ isNew: false;
170
+ } /** Email matched an existing account; interactive proof-of-control required to link. */ | {
171
+ kind: "needs-link";
172
+ candidateUserId: string;
173
+ } /** Refused: signup disabled with no match, or policy needed an email the provider lacked. */ | {
174
+ kind: "denied";
175
+ reason: "signup-disabled" | "email-unavailable";
176
+ };
177
+ //#endregion
178
+ //#region src/pkce.d.ts
179
+ interface PkcePair {
180
+ /** The high-entropy secret kept server-side (cookie / wf state). */
181
+ verifier: string;
182
+ /** `base64url(SHA-256(verifier))` — sent in the authorization request. */
183
+ challenge: string;
184
+ /** Always `S256` — the plain method is intentionally not offered. */
185
+ method: "S256";
186
+ }
187
+ interface SeededPkce {
188
+ /** PKCE code verifier — `HMAC(secret, "…verifier:" + seed)`, re-derivable, never stored. */
189
+ verifier: string;
190
+ /** OIDC nonce — `HMAC(secret, "…nonce:" + seed)`, independent of the verifier. */
191
+ nonce: string;
192
+ /** `base64url(SHA-256(verifier))` — sent in the authorization request. */
193
+ challenge: string;
194
+ /** Always `S256`. */
195
+ method: "S256";
196
+ }
197
+ /**
198
+ * Mint a fresh PKCE pair. The 32-byte verifier base64url-encodes to 43 chars,
199
+ * comfortably inside RFC 7636's 43–128 range.
200
+ */
201
+ declare function createPkcePair(): PkcePair;
202
+ /** Recompute the S256 challenge for a known verifier (verification side). */
203
+ declare function pkceChallengeFor(verifier: string): string;
204
+ /** OIDC `nonce` — minted at `/start`, bound into state, asserted in `exchange()`. */
205
+ declare function generateNonce(bytes?: number): string;
206
+ /** Random component bound into the signed `state` (anti-CSRF / anti-replay). */
207
+ declare function generateRandomState(bytes?: number): string;
208
+ /**
209
+ * STATELESS PKCE: deterministically DERIVE the code verifier + OIDC nonce from
210
+ * a non-secret `seed` and the server's HMAC `secret`, instead of minting them
211
+ * with {@link createPkcePair}/{@link generateNonce} and persisting the secret
212
+ * half server-side.
213
+ *
214
+ * The `seed` is the same high-entropy value carried (in the clear) by the
215
+ * signed-state `random` and the double-submit CSRF cookie — so the round-trip
216
+ * needs NO server-side flow store: `/start` derives `{ verifier, nonce }` to
217
+ * build the authorize request, and the callback re-derives the identical pair
218
+ * from `state.random` to redeem the `code`. Because the verifier is
219
+ * `HMAC(secret, seed)` and the secret never leaves the server, exposing the
220
+ * seed in the URL discloses nothing — an attacker cannot recover the verifier.
221
+ * Distinct domain-separation prefixes keep the verifier and the nonce
222
+ * independent (neither is recoverable from the other).
223
+ *
224
+ * Same output range as {@link createPkcePair}: a 32-byte HMAC-SHA256 digest →
225
+ * a 43-char base64url verifier, inside RFC 7636's 43–128.
226
+ */
227
+ declare function deriveSeededPkce(secret: string | Uint8Array, seed: string): SeededPkce;
228
+ //#endregion
229
+ //#region src/state.d.ts
230
+ /**
231
+ * The CSRF/replay binding carried across the OAuth bounce (RFC §7). Signed into
232
+ * a compact HS256 JWT by {@link signState} so it is tamper-evident in the URL
233
+ * `state` param. `verifier`/`nonce` are optional: a stateless deployment puts
234
+ * them inside the signed state; a server-side-store deployment (the RFC's
235
+ * chosen design, §3.6) keeps them server-side and binds only `handle` here.
236
+ */
237
+ interface OAuthStatePayload {
238
+ /** High-entropy anti-CSRF random; also double-submitted in a cookie (phase 3). */
239
+ random: string;
240
+ /** Bound provider id — a `google` state can't be replayed against `github`. */
241
+ provider: string;
242
+ /** Post-login app redirect target (validated as a same-origin relative path in phase 3). */
243
+ redirect: string;
244
+ /** PKCE code verifier — present only in the stateless mode. */
245
+ verifier?: string;
246
+ /** OIDC nonce — present only in the stateless mode. */
247
+ nonce?: string;
248
+ /** Opaque server-side wf-state handle (`wfs`) — present in the server-store mode. */
249
+ handle?: string;
250
+ /** Initiating user id — bound for `/link` to defeat the confused deputy (§4). */
251
+ userId?: string;
252
+ }
253
+ interface SignStateOptions {
254
+ /** State lifetime in seconds. Default `600` (10 min) — OAuth round-trips are short. */
255
+ ttlSec?: number;
256
+ clock?: Clock;
257
+ }
258
+ interface VerifyStateOptions {
259
+ clock?: Clock;
260
+ }
261
+ /**
262
+ * Sign the binding into a compact HS256 JWT. Field names are abbreviated to
263
+ * keep the URL `state` short; {@link verifyState} maps them back.
264
+ */
265
+ declare function signState(payload: OAuthStatePayload, secret: string | Uint8Array, opts?: SignStateOptions): Promise<string>;
266
+ /**
267
+ * Verify + decode the signed state. Pins `HS256` (rejects `alg:none` / key
268
+ * confusion). Throws {@link OAuthError} `STATE_EXPIRED` past TTL,
269
+ * `STATE_INVALID` on any other signature/shape failure.
270
+ */
271
+ declare function verifyState(token: string, secret: string | Uint8Array, opts?: VerifyStateOptions): Promise<OAuthStatePayload>;
272
+ //#endregion
273
+ //#region src/providers/oidc.d.ts
274
+ /** The four endpoints the OIDC client needs — discovered or supplied explicitly. */
275
+ interface OidcDiscoveryDocument {
276
+ issuer: string;
277
+ authorization_endpoint: string;
278
+ token_endpoint: string;
279
+ jwks_uri: string;
280
+ }
281
+ /** Context handed to a {@link ClientSecretFactory} at exchange time. */
282
+ interface ClientSecretContext {
283
+ /**
284
+ * The provider's resolved clock (honors a registry-injected clock). Time-based
285
+ * secrets — Apple's ES256 JWT — stamp `iat`/`exp` from it.
286
+ */
287
+ clock: Clock;
288
+ }
289
+ /**
290
+ * A dynamic `client_secret` source. Most IdPs use a static string; a provider
291
+ * whose secret is minted per request (Apple's short-lived ES256 `.p8` JWT)
292
+ * supplies a factory instead. Called lazily at each token exchange — after the
293
+ * registry's `applyDefaults` — so it sees the resolved {@link ClientSecretContext.clock}.
294
+ */
295
+ type ClientSecretFactory = (ctx: ClientSecretContext) => string | Promise<string>;
296
+ interface OidcProviderOptions {
297
+ /** Provider id. Default `oidc:<issuer>`; `GoogleProvider` pins `'google'`. */
298
+ id?: string;
299
+ /** The exact issuer — used for discovery AND `iss` validation (must match). */
300
+ issuer: string;
301
+ clientId: string;
302
+ /**
303
+ * The OAuth `client_secret`: a static string, or a {@link ClientSecretFactory}
304
+ * for providers (Apple) that mint a short-lived secret per request.
305
+ */
306
+ clientSecret: string | ClientSecretFactory;
307
+ /** Requested scopes. Default `['openid', 'email', 'profile']`. */
308
+ scopes?: string[];
309
+ /**
310
+ * ID-token signing algs to accept (RFC §7: pin to asymmetric, reject
311
+ * `none`/HMAC). Default `['RS256', 'ES256']`. `none`/HS* are rejected purely
312
+ * by never appearing in this list.
313
+ */
314
+ idTokenSigningAlgs?: string[];
315
+ authorizationEndpoint?: string;
316
+ tokenEndpoint?: string;
317
+ jwksUri?: string;
318
+ /** Inject a discovery document (skips the `.well-known` fetch). */
319
+ discovery?: OidcDiscoveryDocument;
320
+ /** Inject a JWKS key resolver (e.g. `createLocalJWKSet`) — skips the remote JWKS fetch. */
321
+ jwks?: JWTVerifyGetKey;
322
+ /** Bounded clock skew for `exp`/`iat`/`nbf`, seconds. Default `5`. */
323
+ clockToleranceSec?: number;
324
+ /** Remote-JWKS cache lifetime, ms. Default `3_600_000` (1h). */
325
+ jwksCacheTtlMs?: number;
326
+ clock?: Clock;
327
+ fetch?: FetchLike;
328
+ }
329
+ /**
330
+ * Generic OpenID Connect provider: authorization-code + PKCE, full discovery,
331
+ * remote JWKS, and the OIDC Core 3.1.3.7 ID-token validation list (RFC §7).
332
+ * Verification fails CLOSED — a JWKS/discovery fetch failure is an
333
+ * `OAuthError('JWKS_FAILED')`, never a silent accept.
334
+ *
335
+ * `GoogleProvider` is a thin subclass pinning the issuer + algs.
336
+ */
337
+ declare class OidcProvider implements ConfigurableProvider {
338
+ readonly id: string;
339
+ protected readonly issuer: string;
340
+ protected readonly clientId: string;
341
+ protected readonly clientSecret: string | ClientSecretFactory;
342
+ protected readonly scopes: string[];
343
+ protected readonly signingAlgs: string[];
344
+ private readonly explicitEndpoints?;
345
+ private readonly injectedDiscovery?;
346
+ private readonly injectedJwks?;
347
+ private clockToleranceSec?;
348
+ private jwksCacheTtlMs?;
349
+ private clockImpl?;
350
+ private fetchImpl?;
351
+ private discoveryCache?;
352
+ private jwksCache?;
353
+ constructor(opts: OidcProviderOptions);
354
+ /** Registry-injected shared config; a ctor value always wins (decision #2). */
355
+ applyDefaults(shared: SharedProviderConfig): void;
356
+ private get clock();
357
+ private get fetchFn();
358
+ /**
359
+ * The `client_secret` sent to the token endpoint: a static string as-is, or a
360
+ * {@link ClientSecretFactory}'s per-request value (Apple's minted ES256 JWT).
361
+ * Resolved lazily here so a factory sees the registry-injected clock.
362
+ */
363
+ private resolveClientSecret;
364
+ /**
365
+ * Extra authorization-request query params merged into {@link authorizationUrl}.
366
+ * Default none; AppleProvider returns `{ response_mode: 'form_post' }` (required
367
+ * by Apple whenever `email`/`name` scope is requested).
368
+ */
369
+ protected extraAuthorizationParams(_args: AuthorizationUrlArgs): Record<string, string>;
370
+ authorizationUrl(args: AuthorizationUrlArgs): Promise<string>;
371
+ exchange(args: ExchangeArgs): Promise<NormalizedProfile>;
372
+ private postToken;
373
+ private verifyIdToken;
374
+ /**
375
+ * Map verified claims → {@link NormalizedProfile}. `protected` so a provider
376
+ * (Apple) can post-process — e.g. coerce Apple's spec-violating STRING
377
+ * `email_verified` after the base has applied the strict boolean-only rule.
378
+ */
379
+ protected normalize(payload: JWTPayload): NormalizedProfile;
380
+ private resolveEndpoints;
381
+ /** OIDC discovery MUST echo the requested issuer (guards a swapped doc). */
382
+ private validateDiscovery;
383
+ private getJwks;
384
+ }
385
+ //#endregion
386
+ //#region src/providers/google.d.ts
387
+ /** Google options = OIDC options minus the pinned `id`/`issuer`. */
388
+ type GoogleProviderOptions = Omit<OidcProviderOptions, "id" | "issuer">;
389
+ /**
390
+ * Google Sign-In (OIDC). Pins `id: 'google'`, the Google issuer, and `RS256`
391
+ * (Google's only ID-token alg). Everything else — discovery, JWKS rotation,
392
+ * the full §7 validation — is inherited from {@link OidcProvider}.
393
+ */
394
+ declare class GoogleProvider extends OidcProvider {
395
+ constructor(opts: GoogleProviderOptions);
396
+ }
397
+ //#endregion
398
+ //#region src/providers/github.d.ts
399
+ interface GithubProviderOptions {
400
+ /** Provider id. Default `'github'`. */
401
+ id?: string;
402
+ clientId: string;
403
+ clientSecret: string;
404
+ /** Requested scopes. Default `['read:user', 'user:email']` (email needs `user:email`). */
405
+ scopes?: string[];
406
+ /** `User-Agent` sent on the GitHub API calls (GitHub REQUIRES one). Default `'aoothjs'`. */
407
+ userAgent?: string;
408
+ authorizationEndpoint?: string;
409
+ tokenEndpoint?: string;
410
+ userEndpoint?: string;
411
+ emailsEndpoint?: string;
412
+ /** OAuth2 only — no clock is needed (no token-time validation). */
413
+ fetch?: FetchLike;
414
+ }
415
+ /**
416
+ * Sign in with GitHub — pure **OAuth2** (no OpenID Connect): there is no
417
+ * `id_token`, no JWKS, and no nonce. After the authorization-code + PKCE
418
+ * exchange yields an access token, the profile is read from GitHub's REST API
419
+ * (`GET /user` + `GET /user/emails`).
420
+ *
421
+ * **`emailVerified` is strict** (RFC IDP.md §3.4): it is `true` ONLY when the
422
+ * user's PRIMARY email is GitHub-verified. A non-primary or unverified address
423
+ * yields `false` — never trust a GitHub email as proof-of-control unless it is
424
+ * the verified primary.
425
+ *
426
+ * Implements {@link ConfigurableProvider} so the registry can inject a shared
427
+ * `fetch` (deterministic tests) / `clock`; a constructor value always wins.
428
+ */
429
+ declare class GithubProvider implements ConfigurableProvider {
430
+ readonly id: string;
431
+ private readonly clientId;
432
+ private readonly clientSecret;
433
+ private readonly scopes;
434
+ private readonly userAgent;
435
+ private readonly authorizationEndpoint;
436
+ private readonly tokenEndpoint;
437
+ private readonly userEndpoint;
438
+ private readonly emailsEndpoint;
439
+ private fetchImpl?;
440
+ constructor(opts: GithubProviderOptions);
441
+ /** Registry-injected shared config; a ctor value always wins (decision #2). */
442
+ applyDefaults(shared: SharedProviderConfig): void;
443
+ private get fetchFn();
444
+ authorizationUrl(args: AuthorizationUrlArgs): Promise<string>;
445
+ exchange(args: ExchangeArgs): Promise<NormalizedProfile>;
446
+ /** POST the authorization `code` → access token (GitHub returns JSON when asked). */
447
+ private redeemCode;
448
+ private fetchUser;
449
+ /**
450
+ * Resolve the verified PRIMARY `/user/emails` entry, `emailVerified` strictly
451
+ * from its `verified` flag. Returns `undefined` when the `user:email` scope is
452
+ * absent (call fails), the body isn't an array, or there's no primary entry —
453
+ * the caller then degrades to the public `/user` email (unverified).
454
+ */
455
+ private fetchPrimaryEmail;
456
+ private apiGet;
457
+ }
458
+ //#endregion
459
+ //#region src/providers/apple.d.ts
460
+ interface AppleProviderOptions extends Omit<OidcProviderOptions, "id" | "issuer" | "clientSecret" | "idTokenSigningAlgs" | "scopes"> {
461
+ /** The Services ID — Sign in with Apple's OAuth `client_id` (and the id_token `aud`). */
462
+ clientId: string;
463
+ /** Apple Developer Team ID (10 chars) — the client-secret JWT `iss`. */
464
+ teamId: string;
465
+ /** The `.p8` private key's Key ID — the client-secret JWT header `kid`. */
466
+ keyId: string;
467
+ /** The `.p8` EC P-256 private key in PKCS#8 PEM form — mints the ES256 client secret. */
468
+ privateKey: string;
469
+ /** Requested scopes. Default `['openid', 'email']`. */
470
+ scopes?: string[];
471
+ /** Client-secret JWT lifetime, seconds (Apple max ≈ 6 months). Default `3600`. */
472
+ clientSecretTtlSec?: number;
473
+ }
474
+ /**
475
+ * Sign in with Apple (OIDC). A thin subclass of {@link OidcProvider} — Apple IS
476
+ * an OpenID Connect provider (issuer `https://appleid.apple.com`, discoverable
477
+ * endpoints + JWKS), so the whole §7 ID-token validation, JWKS rotation, PKCE,
478
+ * and discovery are inherited. Only the things Apple does differently are
479
+ * wired/overridden here:
480
+ *
481
+ * 1. **No static client secret.** Apple's `client_secret` is a short-lived
482
+ * **ES256 JWT** signed with the developer's `.p8` key. The constructor wires
483
+ * a {@link ClientSecretFactory} ({@link makeAppleClientSecretFactory}) that
484
+ * mints + caches it per token exchange — no base seam to override.
485
+ * 2. **`response_mode=form_post`.** Apple requires it whenever `email`/`name`
486
+ * scope is requested, so the callback is a cross-site **POST**. The
487
+ * `@aooth/auth-moost` `OAuthController` bounces that POST back to the normal
488
+ * GET callback path, so everything downstream (state, CSRF, exchange) is
489
+ * byte-identical to Google/GitHub.
490
+ * 3. **String `email_verified`.** Apple violates OIDC by sending
491
+ * `email_verified` (and `is_private_email`) as the STRING `"true"`/`"false"`;
492
+ * {@link normalize} coerces it after the base's strict boolean-only pass.
493
+ *
494
+ * NOTE: the user's NAME arrives only on the FIRST authorization, in the
495
+ * form_post `user` field (never in the id_token) — v1 deliberately does not
496
+ * capture it, so `displayName` is undefined for Apple. Collect a display name
497
+ * post-signup if you need one.
498
+ */
499
+ declare class AppleProvider extends OidcProvider {
500
+ constructor(opts: AppleProviderOptions);
501
+ /** `email`/`name` scope makes Apple POST the callback — declare `form_post`. */
502
+ protected extraAuthorizationParams(_args: AuthorizationUrlArgs): Record<string, string>;
503
+ /** Coerce Apple's STRING `email_verified` (the base sets only a real boolean). */
504
+ protected normalize(payload: JWTPayload): NormalizedProfile;
505
+ }
506
+ //#endregion
507
+ //#region src/providers/fake.d.ts
508
+ interface FakeIdentityProviderOptions {
509
+ /** Provider id. Default `'fake'`. */
510
+ id?: string;
511
+ /** The fake authorization endpoint a phase-3 e2e route bounces through. */
512
+ authorizationEndpoint?: string;
513
+ /** Profile returned for any `code` with no specific registration. */
514
+ defaultProfile?: Omit<NormalizedProfile, "provider">;
515
+ }
516
+ /**
517
+ * Deterministic, network-free {@link IdentityProvider} for unit tests and the
518
+ * `DEMO_MODE=test` fake-IdP e2e route (RFC §9). `exchange` resolves a `code`
519
+ * to a pre-registered profile (or the default), so the whole
520
+ * `start → callback → resolveUser → gates` round trip runs offline.
521
+ *
522
+ * It does NOT verify a nonce — it is the trusted test double; the real §7
523
+ * nonce/JWKS assertions are exercised against {@link OidcProvider} with `jose`.
524
+ */
525
+ declare class FakeIdentityProvider implements IdentityProvider {
526
+ readonly id: string;
527
+ private readonly authorizationEndpoint;
528
+ private readonly defaultProfile?;
529
+ private readonly profiles;
530
+ constructor(opts?: FakeIdentityProviderOptions);
531
+ /** Register the profile a given `code` resolves to. Returns `this` for chaining. */
532
+ setProfile(code: string, profile: Omit<NormalizedProfile, "provider">): this;
533
+ authorizationUrl(args: AuthorizationUrlArgs): Promise<string>;
534
+ exchange(args: ExchangeArgs): Promise<NormalizedProfile>;
535
+ }
536
+ //#endregion
537
+ //#region src/registry.d.ts
538
+ interface OAuthProviderRegistryOptions {
539
+ /** Public origin — `redirect_uri` is built as `baseUrl` + the callback path (§3.7). */
540
+ baseUrl: string;
541
+ /** HMAC secret that signs the `state` JWT (and, in phase 3, the Lax PKCE cookie). */
542
+ stateSecret: string;
543
+ /** The configured providers. Duplicate ids are rejected. */
544
+ providers: IdentityProvider[];
545
+ /** Account-matching policy (RFC §4). Safe defaults applied. */
546
+ policy?: FederatedPolicy;
547
+ /**
548
+ * Callback path template; `:provider` is substituted per provider. Default
549
+ * `/auth/oauth/:provider/callback` (matches the phase-3 `OAuthController`).
550
+ */
551
+ callbackPathTemplate?: string;
552
+ clockToleranceSec?: number;
553
+ jwks?: {
554
+ cacheTtlMs?: number;
555
+ refreshOnUnknownKid?: boolean;
556
+ };
557
+ clock?: Clock;
558
+ fetch?: FetchLike;
559
+ }
560
+ /**
561
+ * Holds the configured {@link IdentityProvider}s + {@link FederatedPolicy} +
562
+ * the shared signing/verification config (decision #2). Framework-agnostic —
563
+ * phase 3 only DI-binds it. On construction it injects the shared config into
564
+ * each provider that implements `applyDefaults`, and builds the per-provider
565
+ * fixed redirect URIs.
566
+ */
567
+ declare class OAuthProviderRegistry {
568
+ readonly baseUrl: string;
569
+ readonly stateSecret: string;
570
+ readonly policy: ResolvedFederatedPolicy;
571
+ private readonly providers;
572
+ private readonly callbackPathTemplate;
573
+ constructor(opts: OAuthProviderRegistryOptions);
574
+ /** Is a provider with this id registered? */
575
+ has(id: string): boolean;
576
+ /** Resolve a provider, or `undefined`. */
577
+ get(id: string): IdentityProvider | undefined;
578
+ /** Resolve a provider, or throw `OAuthError('UNKNOWN_PROVIDER')` (→ HTTP 404 in phase 3). */
579
+ require(id: string): IdentityProvider;
580
+ /** Registered provider ids, in insertion order. */
581
+ ids(): string[];
582
+ /** All registered providers, in insertion order. */
583
+ list(): IdentityProvider[];
584
+ /** The callback path for a provider (template with `:provider` substituted). */
585
+ callbackPath(providerId: string): string;
586
+ /** The FIXED, exact-match-registered `redirect_uri` for a provider (`baseUrl` + callback path). */
587
+ redirectUri(providerId: string): string;
588
+ /** Sign a state payload with the registry's `stateSecret`. */
589
+ signState(payload: OAuthStatePayload, opts?: SignStateOptions): Promise<string>;
590
+ /** Verify + decode a state token against the registry's `stateSecret`. */
591
+ verifyState(token: string, opts?: VerifyStateOptions): Promise<OAuthStatePayload>;
592
+ /**
593
+ * Derive the stateless PKCE verifier + OIDC nonce from the signed-state
594
+ * `random` seed, using the registry's `stateSecret`. `/start` and the
595
+ * callback call this with the SAME seed to obtain the SAME pair without any
596
+ * server-side flow store. See {@link deriveSeededPkce}.
597
+ */
598
+ deriveSeededPkce(seed: string): SeededPkce;
599
+ }
600
+ //#endregion
601
+ //#region src/federated-login-service.d.ts
602
+ interface FederatedLoginServiceDeps<T extends object = object> {
603
+ /** Concrete user orchestrator (RFC: idp depends on the concrete class). */
604
+ users: UserService<T>;
605
+ /** The account-linking store from `@aooth/user` (memory or atscript-db). */
606
+ federated: FederatedIdentityStore;
607
+ /** Account-matching policy (RFC §4). Safe defaults applied. */
608
+ policy?: FederatedPolicy;
609
+ }
610
+ /**
611
+ * The core federated-login logic (RFC §3.5): map a verified provider profile to
612
+ * an aooth user id. Pure orchestration over `UserService` + `FederatedIdentityStore`
613
+ * — no HTTP, no workflow, no token issuance (those are phase 3). Account-state
614
+ * gating, MFA, and consent are NOT done here; they run as the existing login
615
+ * workflow tail after the phase-3 `oauth-exchange` step sets the subject.
616
+ *
617
+ * Generic over the user shape `T` so a consumer's `UserService<DemoUser>` plugs
618
+ * in directly; `resolveUser` only reads base `UserCredentials` fields.
619
+ */
620
+ declare class FederatedLoginService<T extends object = object> {
621
+ private readonly users;
622
+ private readonly federated;
623
+ readonly policy: ResolvedFederatedPolicy;
624
+ constructor(deps: FederatedLoginServiceDeps<T>);
625
+ /**
626
+ * Resolve a verified {@link NormalizedProfile} to an outcome (decision #1):
627
+ *
628
+ * 1. **Known** `(provider, subject)` → `linked`.
629
+ * 2. **Email match** (fresh verified email via `findByHandle`):
630
+ * - `auto-link-if-verified` + trusted + verified → link → `auto-linked`;
631
+ * - otherwise (incl. `require-interactive-link`, or auto-link conditions
632
+ * unmet) → `needs-link` (caller completes via {@link linkIdentity}).
633
+ * - `create-separate` ignores the match and falls to (3).
634
+ * 3. **New** → `denied` if `allowSignup === false`; else create + activate +
635
+ * link → `created`.
636
+ *
637
+ * Every resolved outcome (`linked`/`auto-linked`/`created`) stamps
638
+ * `lastLoginAt` + refreshes the row's display snapshot via `touchLogin`.
639
+ */
640
+ resolveUser(profile: NormalizedProfile): Promise<ResolveOutcome>;
641
+ /**
642
+ * Complete an interactive link (the `needs-link` outcome): attach
643
+ * `(provider, subject)` to an **already-authenticated** `userId`. Idempotent
644
+ * when it is already that user's. Throws `UserAuthError('ALREADY_EXISTS')`
645
+ * when the identity is already linked to a DIFFERENT user — the
646
+ * confused-deputy / account-injection guard (RFC §4).
647
+ *
648
+ * The CSRF / state↔session-userId binding that proves the request truly
649
+ * speaks for `userId` is the phase-3 controller's responsibility.
650
+ */
651
+ linkIdentity(params: {
652
+ provider: string;
653
+ subject: string;
654
+ userId: string;
655
+ profile?: FederatedProfileSnapshot;
656
+ }): Promise<FederatedIdentity>;
657
+ /** Email-verified AND the provider is explicitly trusted (RFC §4). */
658
+ private canAutoLink;
659
+ /**
660
+ * Create a fresh user for an unmatched identity. Tries the policy username;
661
+ * on an `ALREADY_EXISTS` conflict falls back to the federated-unique
662
+ * `${provider}:${subject}`. Deliberately does NOT set the account `email`
663
+ * handle — promoting a provider email to the unique login handle is a gated
664
+ * phase-3 concern; the verified email is kept on the federated row instead.
665
+ */
666
+ private createFederatedUser;
667
+ private newIdentity;
668
+ private touch;
669
+ }
670
+ //#endregion
671
+ export { AppleProvider, type AppleProviderOptions, type AuthorizationUrlArgs, type ConfigurableProvider, type EmailMatchPolicy, type ExchangeArgs, FakeIdentityProvider, type FakeIdentityProviderOptions, FederatedLoginService, type FederatedLoginServiceDeps, type FederatedPolicy, type FetchLike, type FetchResponseLike, GithubProvider, type GithubProviderOptions, GoogleProvider, type GoogleProviderOptions, type IdentityProvider, type NormalizedProfile, OAuthError, type OAuthErrorType, OAuthProviderRegistry, type OAuthProviderRegistryOptions, type OAuthStatePayload, type OidcDiscoveryDocument, OidcProvider, type OidcProviderOptions, type PkcePair, type ResolveOutcome, type ResolvedFederatedPolicy, type SeededPkce, type SharedProviderConfig, type SignStateOptions, type VerifyStateOptions, createPkcePair, defaultUsernameStrategy, deriveSeededPkce, generateNonce, generateRandomState, isConfigurableProvider, pkceChallengeFor, resolveFederatedPolicy, signState, verifyState };