@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.
- package/LICENSE +21 -0
- package/README.md +40 -0
- package/dist/index.cjs +961 -0
- package/dist/index.d.cts +671 -0
- package/dist/index.d.mts +671 -0
- package/dist/index.mjs +943 -0
- package/package.json +58 -0
package/dist/index.d.mts
ADDED
|
@@ -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 };
|