@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.cjs
ADDED
|
@@ -0,0 +1,961 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
let node_crypto = require("node:crypto");
|
|
3
|
+
let _aooth_auth = require("@aooth/auth");
|
|
4
|
+
let jose = require("jose");
|
|
5
|
+
let _aooth_user = require("@aooth/user");
|
|
6
|
+
//#region src/errors.ts
|
|
7
|
+
const defaultMessages = {
|
|
8
|
+
UNKNOWN_PROVIDER: "Unknown identity provider",
|
|
9
|
+
INVALID_CONFIG: "Invalid OAuth provider configuration",
|
|
10
|
+
STATE_INVALID: "Sign-in could not be verified",
|
|
11
|
+
STATE_EXPIRED: "Sign-in expired — please try again",
|
|
12
|
+
PROVIDER_DENIED: "Sign-in was cancelled",
|
|
13
|
+
EXCHANGE_FAILED: "Could not complete sign-in with the provider",
|
|
14
|
+
JWKS_FAILED: "Could not verify the provider's signing keys",
|
|
15
|
+
ID_TOKEN_INVALID: "The provider's identity token could not be validated",
|
|
16
|
+
EMAIL_UNAVAILABLE: "The provider did not supply a usable email address"
|
|
17
|
+
};
|
|
18
|
+
var OAuthError = class extends Error {
|
|
19
|
+
type;
|
|
20
|
+
details;
|
|
21
|
+
name = "OAuthError";
|
|
22
|
+
constructor(type, message, details) {
|
|
23
|
+
super(message ?? defaultMessages[type]);
|
|
24
|
+
this.type = type;
|
|
25
|
+
this.details = details;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
//#endregion
|
|
29
|
+
//#region src/types.ts
|
|
30
|
+
/** Structural check used by the registry to feature-detect `applyDefaults`. */
|
|
31
|
+
function isConfigurableProvider(p) {
|
|
32
|
+
return typeof p.applyDefaults === "function";
|
|
33
|
+
}
|
|
34
|
+
/** Default username strategy: the verified email, else the stable `provider:subject`. */
|
|
35
|
+
function defaultUsernameStrategy(p) {
|
|
36
|
+
return p.email ?? `${p.provider}:${p.subject}`;
|
|
37
|
+
}
|
|
38
|
+
/** Apply RFC §4 safe defaults over a partial policy. */
|
|
39
|
+
function resolveFederatedPolicy(policy = {}) {
|
|
40
|
+
return {
|
|
41
|
+
emailMatch: policy.emailMatch ?? "require-interactive-link",
|
|
42
|
+
allowSignup: policy.allowSignup ?? true,
|
|
43
|
+
usernameStrategy: policy.usernameStrategy ?? defaultUsernameStrategy,
|
|
44
|
+
trustEmailVerifiedFrom: policy.trustEmailVerifiedFrom ?? []
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
//#endregion
|
|
48
|
+
//#region src/pkce.ts
|
|
49
|
+
/**
|
|
50
|
+
* PKCE / CSRF primitives (RFC 7636 + OIDC nonce). All values are URL-safe
|
|
51
|
+
* base64url with no padding, so they ride in query params and cookies without
|
|
52
|
+
* escaping. Pure `node:crypto` — no I/O, fully deterministic-testable by
|
|
53
|
+
* stubbing nothing (only the randomness varies).
|
|
54
|
+
*/
|
|
55
|
+
function base64url(buf) {
|
|
56
|
+
return buf.toString("base64url");
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Mint a fresh PKCE pair. The 32-byte verifier base64url-encodes to 43 chars,
|
|
60
|
+
* comfortably inside RFC 7636's 43–128 range.
|
|
61
|
+
*/
|
|
62
|
+
function createPkcePair() {
|
|
63
|
+
const verifier = base64url((0, node_crypto.randomBytes)(32));
|
|
64
|
+
return {
|
|
65
|
+
verifier,
|
|
66
|
+
challenge: pkceChallengeFor(verifier),
|
|
67
|
+
method: "S256"
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/** Recompute the S256 challenge for a known verifier (verification side). */
|
|
71
|
+
function pkceChallengeFor(verifier) {
|
|
72
|
+
return base64url((0, node_crypto.createHash)("sha256").update(verifier).digest());
|
|
73
|
+
}
|
|
74
|
+
/** OIDC `nonce` — minted at `/start`, bound into state, asserted in `exchange()`. */
|
|
75
|
+
function generateNonce(bytes = 32) {
|
|
76
|
+
return base64url((0, node_crypto.randomBytes)(bytes));
|
|
77
|
+
}
|
|
78
|
+
/** Random component bound into the signed `state` (anti-CSRF / anti-replay). */
|
|
79
|
+
function generateRandomState(bytes = 32) {
|
|
80
|
+
return base64url((0, node_crypto.randomBytes)(bytes));
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* STATELESS PKCE: deterministically DERIVE the code verifier + OIDC nonce from
|
|
84
|
+
* a non-secret `seed` and the server's HMAC `secret`, instead of minting them
|
|
85
|
+
* with {@link createPkcePair}/{@link generateNonce} and persisting the secret
|
|
86
|
+
* half server-side.
|
|
87
|
+
*
|
|
88
|
+
* The `seed` is the same high-entropy value carried (in the clear) by the
|
|
89
|
+
* signed-state `random` and the double-submit CSRF cookie — so the round-trip
|
|
90
|
+
* needs NO server-side flow store: `/start` derives `{ verifier, nonce }` to
|
|
91
|
+
* build the authorize request, and the callback re-derives the identical pair
|
|
92
|
+
* from `state.random` to redeem the `code`. Because the verifier is
|
|
93
|
+
* `HMAC(secret, seed)` and the secret never leaves the server, exposing the
|
|
94
|
+
* seed in the URL discloses nothing — an attacker cannot recover the verifier.
|
|
95
|
+
* Distinct domain-separation prefixes keep the verifier and the nonce
|
|
96
|
+
* independent (neither is recoverable from the other).
|
|
97
|
+
*
|
|
98
|
+
* Same output range as {@link createPkcePair}: a 32-byte HMAC-SHA256 digest →
|
|
99
|
+
* a 43-char base64url verifier, inside RFC 7636's 43–128.
|
|
100
|
+
*/
|
|
101
|
+
function deriveSeededPkce(secret, seed) {
|
|
102
|
+
const verifier = base64url((0, node_crypto.createHmac)("sha256", secret).update(`aooth/pkce-verifier:${seed}`).digest());
|
|
103
|
+
return {
|
|
104
|
+
verifier,
|
|
105
|
+
nonce: base64url((0, node_crypto.createHmac)("sha256", secret).update(`aooth/oidc-nonce:${seed}`).digest()),
|
|
106
|
+
challenge: pkceChallengeFor(verifier),
|
|
107
|
+
method: "S256"
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
//#endregion
|
|
111
|
+
//#region src/state.ts
|
|
112
|
+
const DEFAULT_TTL_SEC = 600;
|
|
113
|
+
function toKey(secret) {
|
|
114
|
+
return typeof secret === "string" ? new TextEncoder().encode(secret) : secret;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Sign the binding into a compact HS256 JWT. Field names are abbreviated to
|
|
118
|
+
* keep the URL `state` short; {@link verifyState} maps them back.
|
|
119
|
+
*/
|
|
120
|
+
async function signState(payload, secret, opts = {}) {
|
|
121
|
+
const clock = opts.clock ?? _aooth_auth.defaultClock;
|
|
122
|
+
const nowSec = Math.floor(clock.now() / 1e3);
|
|
123
|
+
const claims = {
|
|
124
|
+
rnd: payload.random,
|
|
125
|
+
prv: payload.provider,
|
|
126
|
+
rdr: payload.redirect
|
|
127
|
+
};
|
|
128
|
+
if (payload.verifier !== void 0) claims.vrf = payload.verifier;
|
|
129
|
+
if (payload.nonce !== void 0) claims.non = payload.nonce;
|
|
130
|
+
if (payload.handle !== void 0) claims.hdl = payload.handle;
|
|
131
|
+
if (payload.userId !== void 0) claims.uid = payload.userId;
|
|
132
|
+
return new jose.SignJWT(claims).setProtectedHeader({ alg: "HS256" }).setIssuedAt(nowSec).setExpirationTime(nowSec + (opts.ttlSec ?? DEFAULT_TTL_SEC)).sign(toKey(secret));
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Verify + decode the signed state. Pins `HS256` (rejects `alg:none` / key
|
|
136
|
+
* confusion). Throws {@link OAuthError} `STATE_EXPIRED` past TTL,
|
|
137
|
+
* `STATE_INVALID` on any other signature/shape failure.
|
|
138
|
+
*/
|
|
139
|
+
async function verifyState(token, secret, opts = {}) {
|
|
140
|
+
const clock = opts.clock ?? _aooth_auth.defaultClock;
|
|
141
|
+
let payload;
|
|
142
|
+
try {
|
|
143
|
+
payload = (await (0, jose.jwtVerify)(token, toKey(secret), {
|
|
144
|
+
algorithms: ["HS256"],
|
|
145
|
+
currentDate: new Date(clock.now())
|
|
146
|
+
})).payload;
|
|
147
|
+
} catch (err) {
|
|
148
|
+
if (err instanceof jose.errors.JWTExpired) throw new OAuthError("STATE_EXPIRED");
|
|
149
|
+
throw new OAuthError("STATE_INVALID");
|
|
150
|
+
}
|
|
151
|
+
const random = payload.rnd;
|
|
152
|
+
const provider = payload.prv;
|
|
153
|
+
const redirect = payload.rdr;
|
|
154
|
+
if (typeof random !== "string" || typeof provider !== "string" || typeof redirect !== "string") throw new OAuthError("STATE_INVALID", "Malformed state payload");
|
|
155
|
+
const out = {
|
|
156
|
+
random,
|
|
157
|
+
provider,
|
|
158
|
+
redirect
|
|
159
|
+
};
|
|
160
|
+
if (typeof payload.vrf === "string") out.verifier = payload.vrf;
|
|
161
|
+
if (typeof payload.non === "string") out.nonce = payload.non;
|
|
162
|
+
if (typeof payload.hdl === "string") out.handle = payload.hdl;
|
|
163
|
+
if (typeof payload.uid === "string") out.userId = payload.uid;
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
//#endregion
|
|
167
|
+
//#region src/providers/oauth2-shared.ts
|
|
168
|
+
/** Resolve the effective `fetch`: an injected impl, else the global `fetch`. */
|
|
169
|
+
function resolveFetch(impl) {
|
|
170
|
+
return impl ?? globalThis.fetch;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* `fetch` a JSON endpoint, failing CLOSED as an {@link OAuthError} on a network
|
|
174
|
+
* error, a non-2xx status, or a non-JSON body. `label` names the call in the
|
|
175
|
+
* thrown message; `errorType` selects the error class — token / provider-API
|
|
176
|
+
* failures are `EXCHANGE_FAILED`, OIDC discovery fails as `JWKS_FAILED`.
|
|
177
|
+
*
|
|
178
|
+
* Returns the parsed body as `unknown` — the caller narrows / applies any
|
|
179
|
+
* provider-specific post-checks (GitHub's HTTP-200 `{ error }`, an
|
|
180
|
+
* `access_token` presence guard, the OIDC discovery issuer match, …).
|
|
181
|
+
*/
|
|
182
|
+
async function fetchJson(fetchFn, url, init, opts) {
|
|
183
|
+
const errorType = opts.errorType ?? "EXCHANGE_FAILED";
|
|
184
|
+
let res;
|
|
185
|
+
try {
|
|
186
|
+
res = await fetchFn(url, init);
|
|
187
|
+
} catch (err) {
|
|
188
|
+
throw new OAuthError(errorType, `${opts.label} request failed`, { cause: String(err) });
|
|
189
|
+
}
|
|
190
|
+
if (!res.ok) throw new OAuthError(errorType, `${opts.label} returned ${res.status}`, { status: res.status });
|
|
191
|
+
try {
|
|
192
|
+
return await res.json();
|
|
193
|
+
} catch {
|
|
194
|
+
throw new OAuthError(errorType, `${opts.label} returned a non-JSON body`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Build the `302`-target authorization URL shared by every provider:
|
|
199
|
+
* `redirect_uri` + `state` + the PKCE S256 `code_challenge`. The OAuth2/OIDC
|
|
200
|
+
* extras (`response_type`, `client_id`, `scope`, `nonce`, provider params) are
|
|
201
|
+
* layered on via {@link BuildAuthorizeUrlOptions}.
|
|
202
|
+
*/
|
|
203
|
+
function buildAuthorizeUrl(endpoint, args, opts = {}) {
|
|
204
|
+
const url = new URL(endpoint);
|
|
205
|
+
if (opts.responseType) url.searchParams.set("response_type", "code");
|
|
206
|
+
if (opts.clientId) url.searchParams.set("client_id", opts.clientId);
|
|
207
|
+
url.searchParams.set("redirect_uri", args.redirectUri);
|
|
208
|
+
if (opts.scopes) url.searchParams.set("scope", (args.scopes ?? opts.scopes).join(" "));
|
|
209
|
+
url.searchParams.set("state", args.state);
|
|
210
|
+
url.searchParams.set("code_challenge", args.codeChallenge);
|
|
211
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
212
|
+
if (opts.nonce && args.nonce) url.searchParams.set("nonce", args.nonce);
|
|
213
|
+
for (const [k, v] of Object.entries(opts.extraParams ?? {})) url.searchParams.set(k, v);
|
|
214
|
+
return url.toString();
|
|
215
|
+
}
|
|
216
|
+
//#endregion
|
|
217
|
+
//#region src/providers/oidc.ts
|
|
218
|
+
const DEFAULT_SCOPES$1 = [
|
|
219
|
+
"openid",
|
|
220
|
+
"email",
|
|
221
|
+
"profile"
|
|
222
|
+
];
|
|
223
|
+
const DEFAULT_SIGNING_ALGS = ["RS256", "ES256"];
|
|
224
|
+
const DEFAULT_CLOCK_TOLERANCE_SEC = 5;
|
|
225
|
+
const DEFAULT_JWKS_CACHE_MS = 36e5;
|
|
226
|
+
/**
|
|
227
|
+
* Generic OpenID Connect provider: authorization-code + PKCE, full discovery,
|
|
228
|
+
* remote JWKS, and the OIDC Core 3.1.3.7 ID-token validation list (RFC §7).
|
|
229
|
+
* Verification fails CLOSED — a JWKS/discovery fetch failure is an
|
|
230
|
+
* `OAuthError('JWKS_FAILED')`, never a silent accept.
|
|
231
|
+
*
|
|
232
|
+
* `GoogleProvider` is a thin subclass pinning the issuer + algs.
|
|
233
|
+
*/
|
|
234
|
+
var OidcProvider = class {
|
|
235
|
+
id;
|
|
236
|
+
issuer;
|
|
237
|
+
clientId;
|
|
238
|
+
clientSecret;
|
|
239
|
+
scopes;
|
|
240
|
+
signingAlgs;
|
|
241
|
+
explicitEndpoints;
|
|
242
|
+
injectedDiscovery;
|
|
243
|
+
injectedJwks;
|
|
244
|
+
clockToleranceSec;
|
|
245
|
+
jwksCacheTtlMs;
|
|
246
|
+
clockImpl;
|
|
247
|
+
fetchImpl;
|
|
248
|
+
discoveryCache;
|
|
249
|
+
jwksCache;
|
|
250
|
+
constructor(opts) {
|
|
251
|
+
if (!opts.issuer) throw new OAuthError("INVALID_CONFIG", "OIDC provider requires an 'issuer'");
|
|
252
|
+
if (!opts.clientId) throw new OAuthError("INVALID_CONFIG", "OIDC provider requires a 'clientId'");
|
|
253
|
+
if (!opts.clientSecret) throw new OAuthError("INVALID_CONFIG", "OIDC provider requires a 'clientSecret'");
|
|
254
|
+
this.id = opts.id ?? `oidc:${opts.issuer}`;
|
|
255
|
+
this.issuer = opts.issuer;
|
|
256
|
+
this.clientId = opts.clientId;
|
|
257
|
+
this.clientSecret = opts.clientSecret;
|
|
258
|
+
this.scopes = opts.scopes ?? DEFAULT_SCOPES$1;
|
|
259
|
+
this.signingAlgs = opts.idTokenSigningAlgs ?? DEFAULT_SIGNING_ALGS;
|
|
260
|
+
if (opts.authorizationEndpoint && opts.tokenEndpoint && opts.jwksUri) this.explicitEndpoints = {
|
|
261
|
+
issuer: opts.issuer,
|
|
262
|
+
authorization_endpoint: opts.authorizationEndpoint,
|
|
263
|
+
token_endpoint: opts.tokenEndpoint,
|
|
264
|
+
jwks_uri: opts.jwksUri
|
|
265
|
+
};
|
|
266
|
+
this.injectedDiscovery = opts.discovery;
|
|
267
|
+
this.injectedJwks = opts.jwks;
|
|
268
|
+
this.clockToleranceSec = opts.clockToleranceSec;
|
|
269
|
+
this.jwksCacheTtlMs = opts.jwksCacheTtlMs;
|
|
270
|
+
this.clockImpl = opts.clock;
|
|
271
|
+
this.fetchImpl = opts.fetch;
|
|
272
|
+
}
|
|
273
|
+
/** Registry-injected shared config; a ctor value always wins (decision #2). */
|
|
274
|
+
applyDefaults(shared) {
|
|
275
|
+
this.clockToleranceSec ??= shared.clockToleranceSec;
|
|
276
|
+
this.jwksCacheTtlMs ??= shared.jwks?.cacheTtlMs;
|
|
277
|
+
this.clockImpl ??= shared.clock;
|
|
278
|
+
this.fetchImpl ??= shared.fetch;
|
|
279
|
+
}
|
|
280
|
+
get clock() {
|
|
281
|
+
return this.clockImpl ?? _aooth_auth.defaultClock;
|
|
282
|
+
}
|
|
283
|
+
get fetchFn() {
|
|
284
|
+
return resolveFetch(this.fetchImpl);
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* The `client_secret` sent to the token endpoint: a static string as-is, or a
|
|
288
|
+
* {@link ClientSecretFactory}'s per-request value (Apple's minted ES256 JWT).
|
|
289
|
+
* Resolved lazily here so a factory sees the registry-injected clock.
|
|
290
|
+
*/
|
|
291
|
+
resolveClientSecret() {
|
|
292
|
+
return typeof this.clientSecret === "function" ? this.clientSecret({ clock: this.clock }) : this.clientSecret;
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Extra authorization-request query params merged into {@link authorizationUrl}.
|
|
296
|
+
* Default none; AppleProvider returns `{ response_mode: 'form_post' }` (required
|
|
297
|
+
* by Apple whenever `email`/`name` scope is requested).
|
|
298
|
+
*/
|
|
299
|
+
extraAuthorizationParams(_args) {
|
|
300
|
+
return {};
|
|
301
|
+
}
|
|
302
|
+
async authorizationUrl(args) {
|
|
303
|
+
const { authorization_endpoint } = await this.resolveEndpoints();
|
|
304
|
+
return buildAuthorizeUrl(authorization_endpoint, args, {
|
|
305
|
+
responseType: true,
|
|
306
|
+
clientId: this.clientId,
|
|
307
|
+
scopes: this.scopes,
|
|
308
|
+
nonce: true,
|
|
309
|
+
extraParams: this.extraAuthorizationParams(args)
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
async exchange(args) {
|
|
313
|
+
const { token_endpoint, jwks_uri } = await this.resolveEndpoints();
|
|
314
|
+
const tokens = await this.postToken(token_endpoint, args);
|
|
315
|
+
const idToken = tokens.id_token;
|
|
316
|
+
if (typeof idToken !== "string") throw new OAuthError("ID_TOKEN_INVALID", "Token response carried no id_token");
|
|
317
|
+
const accessToken = typeof tokens.access_token === "string" ? tokens.access_token : void 0;
|
|
318
|
+
return this.verifyIdToken(idToken, accessToken, jwks_uri, args.expectedNonce);
|
|
319
|
+
}
|
|
320
|
+
async postToken(tokenEndpoint, args) {
|
|
321
|
+
const body = new URLSearchParams({
|
|
322
|
+
grant_type: "authorization_code",
|
|
323
|
+
code: args.code,
|
|
324
|
+
redirect_uri: args.redirectUri,
|
|
325
|
+
client_id: this.clientId,
|
|
326
|
+
client_secret: await this.resolveClientSecret(),
|
|
327
|
+
code_verifier: args.codeVerifier
|
|
328
|
+
});
|
|
329
|
+
return await fetchJson(this.fetchFn, tokenEndpoint, {
|
|
330
|
+
method: "POST",
|
|
331
|
+
headers: {
|
|
332
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
333
|
+
accept: "application/json"
|
|
334
|
+
},
|
|
335
|
+
body: body.toString()
|
|
336
|
+
}, { label: "Token endpoint" });
|
|
337
|
+
}
|
|
338
|
+
async verifyIdToken(idToken, accessToken, jwksUri, expectedNonce) {
|
|
339
|
+
const keyResolver = await this.getJwks(jwksUri);
|
|
340
|
+
let payload;
|
|
341
|
+
let alg;
|
|
342
|
+
try {
|
|
343
|
+
const result = await (0, jose.jwtVerify)(idToken, keyResolver, {
|
|
344
|
+
issuer: this.issuer,
|
|
345
|
+
audience: this.clientId,
|
|
346
|
+
algorithms: this.signingAlgs,
|
|
347
|
+
clockTolerance: this.clockToleranceSec ?? DEFAULT_CLOCK_TOLERANCE_SEC,
|
|
348
|
+
currentDate: new Date(this.clock.now())
|
|
349
|
+
});
|
|
350
|
+
payload = result.payload;
|
|
351
|
+
alg = result.protectedHeader.alg;
|
|
352
|
+
} catch (err) {
|
|
353
|
+
const code = err?.code ?? "";
|
|
354
|
+
if (code.startsWith("ERR_JWKS")) throw new OAuthError("JWKS_FAILED", "Could not resolve the provider's signing key", { code });
|
|
355
|
+
throw new OAuthError("ID_TOKEN_INVALID", "ID token failed verification", { code });
|
|
356
|
+
}
|
|
357
|
+
if (typeof payload.sub !== "string" || payload.sub.length === 0) throw new OAuthError("ID_TOKEN_INVALID", "ID token has no subject");
|
|
358
|
+
if (expectedNonce !== void 0 && payload.nonce !== expectedNonce) throw new OAuthError("ID_TOKEN_INVALID", "ID token nonce mismatch");
|
|
359
|
+
if (Array.isArray(payload.aud) && payload.aud.length > 1 && payload.azp !== this.clientId) throw new OAuthError("ID_TOKEN_INVALID", "ID token azp does not match client");
|
|
360
|
+
if (accessToken !== void 0 && typeof payload.at_hash === "string") {
|
|
361
|
+
if (payload.at_hash !== atHash(accessToken, alg)) throw new OAuthError("ID_TOKEN_INVALID", "ID token at_hash mismatch");
|
|
362
|
+
}
|
|
363
|
+
return this.normalize(payload);
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Map verified claims → {@link NormalizedProfile}. `protected` so a provider
|
|
367
|
+
* (Apple) can post-process — e.g. coerce Apple's spec-violating STRING
|
|
368
|
+
* `email_verified` after the base has applied the strict boolean-only rule.
|
|
369
|
+
*/
|
|
370
|
+
normalize(payload) {
|
|
371
|
+
const profile = {
|
|
372
|
+
provider: this.id,
|
|
373
|
+
subject: payload.sub,
|
|
374
|
+
raw: payload
|
|
375
|
+
};
|
|
376
|
+
if (typeof payload.email === "string") profile.email = payload.email;
|
|
377
|
+
if (typeof payload.email_verified === "boolean") profile.emailVerified = payload.email_verified;
|
|
378
|
+
if (typeof payload.name === "string") profile.displayName = payload.name;
|
|
379
|
+
if (typeof payload.picture === "string") profile.avatarUrl = payload.picture;
|
|
380
|
+
return profile;
|
|
381
|
+
}
|
|
382
|
+
async resolveEndpoints() {
|
|
383
|
+
if (this.explicitEndpoints) return this.explicitEndpoints;
|
|
384
|
+
if (this.injectedDiscovery) return this.validateDiscovery(this.injectedDiscovery);
|
|
385
|
+
if (this.discoveryCache) return this.discoveryCache;
|
|
386
|
+
const wellKnown = `${this.issuer.replace(/\/$/, "")}/.well-known/openid-configuration`;
|
|
387
|
+
const doc = await fetchJson(this.fetchFn, wellKnown, { headers: { accept: "application/json" } }, {
|
|
388
|
+
label: "OIDC discovery",
|
|
389
|
+
errorType: "JWKS_FAILED"
|
|
390
|
+
});
|
|
391
|
+
this.discoveryCache = this.validateDiscovery(doc);
|
|
392
|
+
return this.discoveryCache;
|
|
393
|
+
}
|
|
394
|
+
/** OIDC discovery MUST echo the requested issuer (guards a swapped doc). */
|
|
395
|
+
validateDiscovery(doc) {
|
|
396
|
+
if (doc.issuer !== this.issuer) throw new OAuthError("INVALID_CONFIG", "Discovery issuer does not match configured issuer", {
|
|
397
|
+
expected: this.issuer,
|
|
398
|
+
got: doc.issuer
|
|
399
|
+
});
|
|
400
|
+
if (!doc.authorization_endpoint || !doc.token_endpoint || !doc.jwks_uri) throw new OAuthError("INVALID_CONFIG", "Discovery document is missing required endpoints");
|
|
401
|
+
return doc;
|
|
402
|
+
}
|
|
403
|
+
async getJwks(jwksUri) {
|
|
404
|
+
if (this.injectedJwks) return this.injectedJwks;
|
|
405
|
+
if (this.jwksCache?.uri === jwksUri) return this.jwksCache.resolver;
|
|
406
|
+
const resolver = (0, jose.createRemoteJWKSet)(new URL(jwksUri), { cacheMaxAge: this.jwksCacheTtlMs ?? DEFAULT_JWKS_CACHE_MS });
|
|
407
|
+
this.jwksCache = {
|
|
408
|
+
uri: jwksUri,
|
|
409
|
+
resolver
|
|
410
|
+
};
|
|
411
|
+
return resolver;
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
/**
|
|
415
|
+
* OIDC at_hash (Core 3.1.3.6): base64url of the left-most half of the access
|
|
416
|
+
* token's hash, the hash chosen by the id_token's `alg` (RS256 → SHA-256).
|
|
417
|
+
*/
|
|
418
|
+
function atHash(accessToken, alg) {
|
|
419
|
+
const digest = (0, node_crypto.createHash)(alg.endsWith("384") ? "sha384" : alg.endsWith("512") ? "sha512" : "sha256").update(accessToken).digest();
|
|
420
|
+
return digest.subarray(0, digest.length / 2).toString("base64url");
|
|
421
|
+
}
|
|
422
|
+
//#endregion
|
|
423
|
+
//#region src/providers/google.ts
|
|
424
|
+
/** Google's stable OIDC issuer — endpoints + JWKS are discovered from it. */
|
|
425
|
+
const GOOGLE_ISSUER = "https://accounts.google.com";
|
|
426
|
+
/**
|
|
427
|
+
* Google Sign-In (OIDC). Pins `id: 'google'`, the Google issuer, and `RS256`
|
|
428
|
+
* (Google's only ID-token alg). Everything else — discovery, JWKS rotation,
|
|
429
|
+
* the full §7 validation — is inherited from {@link OidcProvider}.
|
|
430
|
+
*/
|
|
431
|
+
var GoogleProvider = class extends OidcProvider {
|
|
432
|
+
constructor(opts) {
|
|
433
|
+
super({
|
|
434
|
+
...opts,
|
|
435
|
+
id: "google",
|
|
436
|
+
issuer: GOOGLE_ISSUER,
|
|
437
|
+
idTokenSigningAlgs: opts.idTokenSigningAlgs ?? ["RS256"]
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
//#endregion
|
|
442
|
+
//#region src/providers/github.ts
|
|
443
|
+
const GITHUB_AUTHORIZE = "https://github.com/login/oauth/authorize";
|
|
444
|
+
const GITHUB_TOKEN = "https://github.com/login/oauth/access_token";
|
|
445
|
+
const GITHUB_USER = "https://api.github.com/user";
|
|
446
|
+
const GITHUB_EMAILS = "https://api.github.com/user/emails";
|
|
447
|
+
const DEFAULT_SCOPES = ["read:user", "user:email"];
|
|
448
|
+
const DEFAULT_USER_AGENT = "aoothjs";
|
|
449
|
+
/**
|
|
450
|
+
* Sign in with GitHub — pure **OAuth2** (no OpenID Connect): there is no
|
|
451
|
+
* `id_token`, no JWKS, and no nonce. After the authorization-code + PKCE
|
|
452
|
+
* exchange yields an access token, the profile is read from GitHub's REST API
|
|
453
|
+
* (`GET /user` + `GET /user/emails`).
|
|
454
|
+
*
|
|
455
|
+
* **`emailVerified` is strict** (RFC IDP.md §3.4): it is `true` ONLY when the
|
|
456
|
+
* user's PRIMARY email is GitHub-verified. A non-primary or unverified address
|
|
457
|
+
* yields `false` — never trust a GitHub email as proof-of-control unless it is
|
|
458
|
+
* the verified primary.
|
|
459
|
+
*
|
|
460
|
+
* Implements {@link ConfigurableProvider} so the registry can inject a shared
|
|
461
|
+
* `fetch` (deterministic tests) / `clock`; a constructor value always wins.
|
|
462
|
+
*/
|
|
463
|
+
var GithubProvider = class {
|
|
464
|
+
id;
|
|
465
|
+
clientId;
|
|
466
|
+
clientSecret;
|
|
467
|
+
scopes;
|
|
468
|
+
userAgent;
|
|
469
|
+
authorizationEndpoint;
|
|
470
|
+
tokenEndpoint;
|
|
471
|
+
userEndpoint;
|
|
472
|
+
emailsEndpoint;
|
|
473
|
+
fetchImpl;
|
|
474
|
+
constructor(opts) {
|
|
475
|
+
if (!opts.clientId || !opts.clientSecret) throw new OAuthError("INVALID_CONFIG", "GithubProvider requires 'clientId' and 'clientSecret'");
|
|
476
|
+
this.id = opts.id ?? "github";
|
|
477
|
+
this.clientId = opts.clientId;
|
|
478
|
+
this.clientSecret = opts.clientSecret;
|
|
479
|
+
this.scopes = opts.scopes ?? DEFAULT_SCOPES;
|
|
480
|
+
this.userAgent = opts.userAgent ?? DEFAULT_USER_AGENT;
|
|
481
|
+
this.authorizationEndpoint = opts.authorizationEndpoint ?? GITHUB_AUTHORIZE;
|
|
482
|
+
this.tokenEndpoint = opts.tokenEndpoint ?? GITHUB_TOKEN;
|
|
483
|
+
this.userEndpoint = opts.userEndpoint ?? GITHUB_USER;
|
|
484
|
+
this.emailsEndpoint = opts.emailsEndpoint ?? GITHUB_EMAILS;
|
|
485
|
+
this.fetchImpl = opts.fetch;
|
|
486
|
+
}
|
|
487
|
+
/** Registry-injected shared config; a ctor value always wins (decision #2). */
|
|
488
|
+
applyDefaults(shared) {
|
|
489
|
+
this.fetchImpl ??= shared.fetch;
|
|
490
|
+
}
|
|
491
|
+
get fetchFn() {
|
|
492
|
+
return resolveFetch(this.fetchImpl);
|
|
493
|
+
}
|
|
494
|
+
authorizationUrl(args) {
|
|
495
|
+
return Promise.resolve(buildAuthorizeUrl(this.authorizationEndpoint, args, {
|
|
496
|
+
responseType: true,
|
|
497
|
+
clientId: this.clientId,
|
|
498
|
+
scopes: this.scopes
|
|
499
|
+
}));
|
|
500
|
+
}
|
|
501
|
+
async exchange(args) {
|
|
502
|
+
const accessToken = await this.redeemCode(args);
|
|
503
|
+
const [user, primary] = await Promise.all([this.fetchUser(accessToken), this.fetchPrimaryEmail(accessToken)]);
|
|
504
|
+
const { email, emailVerified } = primary ?? (user.email ? {
|
|
505
|
+
email: user.email,
|
|
506
|
+
emailVerified: false
|
|
507
|
+
} : {});
|
|
508
|
+
const profile = {
|
|
509
|
+
provider: this.id,
|
|
510
|
+
subject: String(user.id),
|
|
511
|
+
raw: user
|
|
512
|
+
};
|
|
513
|
+
if (email !== void 0) profile.email = email;
|
|
514
|
+
if (emailVerified !== void 0) profile.emailVerified = emailVerified;
|
|
515
|
+
const displayName = user.name ?? user.login;
|
|
516
|
+
if (displayName) profile.displayName = displayName;
|
|
517
|
+
if (user.avatar_url) profile.avatarUrl = user.avatar_url;
|
|
518
|
+
return profile;
|
|
519
|
+
}
|
|
520
|
+
/** POST the authorization `code` → access token (GitHub returns JSON when asked). */
|
|
521
|
+
async redeemCode(args) {
|
|
522
|
+
const body = new URLSearchParams({
|
|
523
|
+
grant_type: "authorization_code",
|
|
524
|
+
code: args.code,
|
|
525
|
+
redirect_uri: args.redirectUri,
|
|
526
|
+
client_id: this.clientId,
|
|
527
|
+
client_secret: this.clientSecret,
|
|
528
|
+
code_verifier: args.codeVerifier
|
|
529
|
+
});
|
|
530
|
+
const json = await fetchJson(this.fetchFn, this.tokenEndpoint, {
|
|
531
|
+
method: "POST",
|
|
532
|
+
headers: {
|
|
533
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
534
|
+
accept: "application/json",
|
|
535
|
+
"user-agent": this.userAgent
|
|
536
|
+
},
|
|
537
|
+
body: body.toString()
|
|
538
|
+
}, { label: "GitHub token endpoint" });
|
|
539
|
+
if (typeof json.error === "string") throw new OAuthError("EXCHANGE_FAILED", "GitHub rejected the authorization code", { error: json.error });
|
|
540
|
+
if (typeof json.access_token !== "string" || json.access_token.length === 0) throw new OAuthError("EXCHANGE_FAILED", "GitHub token response carried no access_token");
|
|
541
|
+
return json.access_token;
|
|
542
|
+
}
|
|
543
|
+
async fetchUser(accessToken) {
|
|
544
|
+
const user = await this.apiGet(this.userEndpoint, accessToken);
|
|
545
|
+
if (user.id === void 0 || user.id === null || typeof user.login !== "string") throw new OAuthError("EXCHANGE_FAILED", "GitHub /user response was missing id/login");
|
|
546
|
+
return user;
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Resolve the verified PRIMARY `/user/emails` entry, `emailVerified` strictly
|
|
550
|
+
* from its `verified` flag. Returns `undefined` when the `user:email` scope is
|
|
551
|
+
* absent (call fails), the body isn't an array, or there's no primary entry —
|
|
552
|
+
* the caller then degrades to the public `/user` email (unverified).
|
|
553
|
+
*/
|
|
554
|
+
async fetchPrimaryEmail(accessToken) {
|
|
555
|
+
let entries;
|
|
556
|
+
try {
|
|
557
|
+
entries = await this.apiGet(this.emailsEndpoint, accessToken);
|
|
558
|
+
} catch {
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
if (!Array.isArray(entries)) return void 0;
|
|
562
|
+
const primary = entries.find((e) => e && e.primary);
|
|
563
|
+
if (primary && typeof primary.email === "string") return {
|
|
564
|
+
email: primary.email,
|
|
565
|
+
emailVerified: primary.verified
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
apiGet(url, accessToken) {
|
|
569
|
+
return fetchJson(this.fetchFn, url, {
|
|
570
|
+
method: "GET",
|
|
571
|
+
headers: {
|
|
572
|
+
authorization: `Bearer ${accessToken}`,
|
|
573
|
+
accept: "application/vnd.github+json",
|
|
574
|
+
"user-agent": this.userAgent
|
|
575
|
+
}
|
|
576
|
+
}, { label: `GitHub API (${url})` });
|
|
577
|
+
}
|
|
578
|
+
};
|
|
579
|
+
//#endregion
|
|
580
|
+
//#region src/providers/apple.ts
|
|
581
|
+
/** Apple's stable OIDC issuer — endpoints + JWKS are discovered from it. */
|
|
582
|
+
const APPLE_ISSUER = "https://appleid.apple.com";
|
|
583
|
+
/** Apple ID tokens are always ES256. */
|
|
584
|
+
const APPLE_SIGNING_ALGS = ["ES256"];
|
|
585
|
+
/** Default scope — `openid email`; `name` is omitted (Apple sends it once, out-of-band; deferred). */
|
|
586
|
+
const DEFAULT_APPLE_SCOPES = ["openid", "email"];
|
|
587
|
+
/** Default client-secret JWT lifetime (1h). Apple's hard ceiling is ~6 months (15777000s). */
|
|
588
|
+
const DEFAULT_SECRET_TTL_SEC = 3600;
|
|
589
|
+
/** A minute of slack so a cached secret is never served right at its expiry. */
|
|
590
|
+
const SECRET_RENEW_SLACK_SEC = 60;
|
|
591
|
+
/**
|
|
592
|
+
* Build Apple's dynamic `client_secret` source: a closure that mints — and
|
|
593
|
+
* caches — a short-lived ES256 JWT signed with the `.p8` key. Header
|
|
594
|
+
* `{ alg: 'ES256', kid }`, claims `{ iss: teamId, iat, exp, aud: APPLE_ISSUER,
|
|
595
|
+
* sub: clientId }`. The signed JWT is reused until shortly before its `exp`, and
|
|
596
|
+
* the (comparatively costly) `.p8` import is memoized — so a burst of exchanges
|
|
597
|
+
* shares one signature and parses the key at most once. The clock comes from the
|
|
598
|
+
* per-call context, so a registry-injected clock still drives `iat`/`exp`.
|
|
599
|
+
*/
|
|
600
|
+
function makeAppleClientSecretFactory(cfg) {
|
|
601
|
+
let cachedSecret;
|
|
602
|
+
let importedKey;
|
|
603
|
+
return async ({ clock }) => {
|
|
604
|
+
const nowSec = Math.floor(clock.now() / 1e3);
|
|
605
|
+
if (cachedSecret && cachedSecret.expSec - nowSec > SECRET_RENEW_SLACK_SEC) return cachedSecret.jwt;
|
|
606
|
+
let key;
|
|
607
|
+
try {
|
|
608
|
+
importedKey ??= (0, jose.importPKCS8)(cfg.privateKey, "ES256");
|
|
609
|
+
key = await importedKey;
|
|
610
|
+
} catch (err) {
|
|
611
|
+
importedKey = void 0;
|
|
612
|
+
throw new OAuthError("INVALID_CONFIG", "AppleProvider could not import the '.p8' private key", { cause: String(err) });
|
|
613
|
+
}
|
|
614
|
+
const expSec = nowSec + cfg.ttlSec;
|
|
615
|
+
const jwt = await new jose.SignJWT({}).setProtectedHeader({
|
|
616
|
+
alg: "ES256",
|
|
617
|
+
kid: cfg.keyId
|
|
618
|
+
}).setIssuer(cfg.teamId).setIssuedAt(nowSec).setExpirationTime(expSec).setAudience(APPLE_ISSUER).setSubject(cfg.clientId).sign(key);
|
|
619
|
+
cachedSecret = {
|
|
620
|
+
jwt,
|
|
621
|
+
expSec
|
|
622
|
+
};
|
|
623
|
+
return jwt;
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* Sign in with Apple (OIDC). A thin subclass of {@link OidcProvider} — Apple IS
|
|
628
|
+
* an OpenID Connect provider (issuer `https://appleid.apple.com`, discoverable
|
|
629
|
+
* endpoints + JWKS), so the whole §7 ID-token validation, JWKS rotation, PKCE,
|
|
630
|
+
* and discovery are inherited. Only the things Apple does differently are
|
|
631
|
+
* wired/overridden here:
|
|
632
|
+
*
|
|
633
|
+
* 1. **No static client secret.** Apple's `client_secret` is a short-lived
|
|
634
|
+
* **ES256 JWT** signed with the developer's `.p8` key. The constructor wires
|
|
635
|
+
* a {@link ClientSecretFactory} ({@link makeAppleClientSecretFactory}) that
|
|
636
|
+
* mints + caches it per token exchange — no base seam to override.
|
|
637
|
+
* 2. **`response_mode=form_post`.** Apple requires it whenever `email`/`name`
|
|
638
|
+
* scope is requested, so the callback is a cross-site **POST**. The
|
|
639
|
+
* `@aooth/auth-moost` `OAuthController` bounces that POST back to the normal
|
|
640
|
+
* GET callback path, so everything downstream (state, CSRF, exchange) is
|
|
641
|
+
* byte-identical to Google/GitHub.
|
|
642
|
+
* 3. **String `email_verified`.** Apple violates OIDC by sending
|
|
643
|
+
* `email_verified` (and `is_private_email`) as the STRING `"true"`/`"false"`;
|
|
644
|
+
* {@link normalize} coerces it after the base's strict boolean-only pass.
|
|
645
|
+
*
|
|
646
|
+
* NOTE: the user's NAME arrives only on the FIRST authorization, in the
|
|
647
|
+
* form_post `user` field (never in the id_token) — v1 deliberately does not
|
|
648
|
+
* capture it, so `displayName` is undefined for Apple. Collect a display name
|
|
649
|
+
* post-signup if you need one.
|
|
650
|
+
*/
|
|
651
|
+
var AppleProvider = class extends OidcProvider {
|
|
652
|
+
constructor(opts) {
|
|
653
|
+
if (!opts.teamId || !opts.keyId || !opts.privateKey) throw new OAuthError("INVALID_CONFIG", "AppleProvider requires 'teamId', 'keyId', and 'privateKey'");
|
|
654
|
+
super({
|
|
655
|
+
...opts,
|
|
656
|
+
id: "apple",
|
|
657
|
+
issuer: APPLE_ISSUER,
|
|
658
|
+
clientSecret: makeAppleClientSecretFactory({
|
|
659
|
+
clientId: opts.clientId,
|
|
660
|
+
teamId: opts.teamId,
|
|
661
|
+
keyId: opts.keyId,
|
|
662
|
+
privateKey: opts.privateKey,
|
|
663
|
+
ttlSec: opts.clientSecretTtlSec ?? DEFAULT_SECRET_TTL_SEC
|
|
664
|
+
}),
|
|
665
|
+
idTokenSigningAlgs: APPLE_SIGNING_ALGS,
|
|
666
|
+
scopes: opts.scopes ?? DEFAULT_APPLE_SCOPES
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
/** `email`/`name` scope makes Apple POST the callback — declare `form_post`. */
|
|
670
|
+
extraAuthorizationParams(_args) {
|
|
671
|
+
return { response_mode: "form_post" };
|
|
672
|
+
}
|
|
673
|
+
/** Coerce Apple's STRING `email_verified` (the base sets only a real boolean). */
|
|
674
|
+
normalize(payload) {
|
|
675
|
+
const profile = super.normalize(payload);
|
|
676
|
+
if (profile.emailVerified === void 0) {
|
|
677
|
+
const ev = payload.email_verified;
|
|
678
|
+
if (ev === "true") profile.emailVerified = true;
|
|
679
|
+
else if (ev === "false") profile.emailVerified = false;
|
|
680
|
+
}
|
|
681
|
+
return profile;
|
|
682
|
+
}
|
|
683
|
+
};
|
|
684
|
+
//#endregion
|
|
685
|
+
//#region src/providers/fake.ts
|
|
686
|
+
/**
|
|
687
|
+
* Deterministic, network-free {@link IdentityProvider} for unit tests and the
|
|
688
|
+
* `DEMO_MODE=test` fake-IdP e2e route (RFC §9). `exchange` resolves a `code`
|
|
689
|
+
* to a pre-registered profile (or the default), so the whole
|
|
690
|
+
* `start → callback → resolveUser → gates` round trip runs offline.
|
|
691
|
+
*
|
|
692
|
+
* It does NOT verify a nonce — it is the trusted test double; the real §7
|
|
693
|
+
* nonce/JWKS assertions are exercised against {@link OidcProvider} with `jose`.
|
|
694
|
+
*/
|
|
695
|
+
var FakeIdentityProvider = class {
|
|
696
|
+
id;
|
|
697
|
+
authorizationEndpoint;
|
|
698
|
+
defaultProfile;
|
|
699
|
+
profiles = /* @__PURE__ */ new Map();
|
|
700
|
+
constructor(opts = {}) {
|
|
701
|
+
this.id = opts.id ?? "fake";
|
|
702
|
+
this.authorizationEndpoint = opts.authorizationEndpoint ?? "https://fake-idp.test/authorize";
|
|
703
|
+
this.defaultProfile = opts.defaultProfile;
|
|
704
|
+
}
|
|
705
|
+
/** Register the profile a given `code` resolves to. Returns `this` for chaining. */
|
|
706
|
+
setProfile(code, profile) {
|
|
707
|
+
this.profiles.set(code, profile);
|
|
708
|
+
return this;
|
|
709
|
+
}
|
|
710
|
+
authorizationUrl(args) {
|
|
711
|
+
return Promise.resolve(buildAuthorizeUrl(this.authorizationEndpoint, args, { nonce: true }));
|
|
712
|
+
}
|
|
713
|
+
exchange(args) {
|
|
714
|
+
const base = this.profiles.get(args.code) ?? this.defaultProfile;
|
|
715
|
+
if (!base) return Promise.reject(new OAuthError("EXCHANGE_FAILED", `Fake IdP has no profile for code '${args.code}'`));
|
|
716
|
+
return Promise.resolve({
|
|
717
|
+
...base,
|
|
718
|
+
provider: this.id
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
};
|
|
722
|
+
//#endregion
|
|
723
|
+
//#region src/registry.ts
|
|
724
|
+
const DEFAULT_CALLBACK_TEMPLATE = "/auth/oauth/:provider/callback";
|
|
725
|
+
/**
|
|
726
|
+
* Holds the configured {@link IdentityProvider}s + {@link FederatedPolicy} +
|
|
727
|
+
* the shared signing/verification config (decision #2). Framework-agnostic —
|
|
728
|
+
* phase 3 only DI-binds it. On construction it injects the shared config into
|
|
729
|
+
* each provider that implements `applyDefaults`, and builds the per-provider
|
|
730
|
+
* fixed redirect URIs.
|
|
731
|
+
*/
|
|
732
|
+
var OAuthProviderRegistry = class {
|
|
733
|
+
baseUrl;
|
|
734
|
+
stateSecret;
|
|
735
|
+
policy;
|
|
736
|
+
providers = /* @__PURE__ */ new Map();
|
|
737
|
+
callbackPathTemplate;
|
|
738
|
+
constructor(opts) {
|
|
739
|
+
if (!opts.baseUrl) throw new OAuthError("INVALID_CONFIG", "OAuthProviderRegistry requires a 'baseUrl'");
|
|
740
|
+
if (!opts.stateSecret) throw new OAuthError("INVALID_CONFIG", "OAuthProviderRegistry requires a 'stateSecret'");
|
|
741
|
+
this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
|
|
742
|
+
this.stateSecret = opts.stateSecret;
|
|
743
|
+
this.policy = resolveFederatedPolicy(opts.policy);
|
|
744
|
+
this.callbackPathTemplate = opts.callbackPathTemplate ?? DEFAULT_CALLBACK_TEMPLATE;
|
|
745
|
+
const shared = {
|
|
746
|
+
clockToleranceSec: opts.clockToleranceSec,
|
|
747
|
+
jwks: opts.jwks,
|
|
748
|
+
clock: opts.clock,
|
|
749
|
+
fetch: opts.fetch
|
|
750
|
+
};
|
|
751
|
+
for (const p of opts.providers) {
|
|
752
|
+
if (this.providers.has(p.id)) throw new OAuthError("INVALID_CONFIG", `Duplicate provider id '${p.id}'`);
|
|
753
|
+
if (isConfigurableProvider(p)) p.applyDefaults(shared);
|
|
754
|
+
this.providers.set(p.id, p);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
/** Is a provider with this id registered? */
|
|
758
|
+
has(id) {
|
|
759
|
+
return this.providers.has(id);
|
|
760
|
+
}
|
|
761
|
+
/** Resolve a provider, or `undefined`. */
|
|
762
|
+
get(id) {
|
|
763
|
+
return this.providers.get(id);
|
|
764
|
+
}
|
|
765
|
+
/** Resolve a provider, or throw `OAuthError('UNKNOWN_PROVIDER')` (→ HTTP 404 in phase 3). */
|
|
766
|
+
require(id) {
|
|
767
|
+
const p = this.providers.get(id);
|
|
768
|
+
if (!p) throw new OAuthError("UNKNOWN_PROVIDER", void 0, { provider: id });
|
|
769
|
+
return p;
|
|
770
|
+
}
|
|
771
|
+
/** Registered provider ids, in insertion order. */
|
|
772
|
+
ids() {
|
|
773
|
+
return [...this.providers.keys()];
|
|
774
|
+
}
|
|
775
|
+
/** All registered providers, in insertion order. */
|
|
776
|
+
list() {
|
|
777
|
+
return [...this.providers.values()];
|
|
778
|
+
}
|
|
779
|
+
/** The callback path for a provider (template with `:provider` substituted). */
|
|
780
|
+
callbackPath(providerId) {
|
|
781
|
+
return this.callbackPathTemplate.replace(":provider", encodeURIComponent(providerId));
|
|
782
|
+
}
|
|
783
|
+
/** The FIXED, exact-match-registered `redirect_uri` for a provider (`baseUrl` + callback path). */
|
|
784
|
+
redirectUri(providerId) {
|
|
785
|
+
return `${this.baseUrl}${this.callbackPath(providerId)}`;
|
|
786
|
+
}
|
|
787
|
+
/** Sign a state payload with the registry's `stateSecret`. */
|
|
788
|
+
signState(payload, opts) {
|
|
789
|
+
return signState(payload, this.stateSecret, opts);
|
|
790
|
+
}
|
|
791
|
+
/** Verify + decode a state token against the registry's `stateSecret`. */
|
|
792
|
+
verifyState(token, opts) {
|
|
793
|
+
return verifyState(token, this.stateSecret, opts);
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* Derive the stateless PKCE verifier + OIDC nonce from the signed-state
|
|
797
|
+
* `random` seed, using the registry's `stateSecret`. `/start` and the
|
|
798
|
+
* callback call this with the SAME seed to obtain the SAME pair without any
|
|
799
|
+
* server-side flow store. See {@link deriveSeededPkce}.
|
|
800
|
+
*/
|
|
801
|
+
deriveSeededPkce(seed) {
|
|
802
|
+
return deriveSeededPkce(this.stateSecret, seed);
|
|
803
|
+
}
|
|
804
|
+
};
|
|
805
|
+
//#endregion
|
|
806
|
+
//#region src/federated-login-service.ts
|
|
807
|
+
/**
|
|
808
|
+
* The core federated-login logic (RFC §3.5): map a verified provider profile to
|
|
809
|
+
* an aooth user id. Pure orchestration over `UserService` + `FederatedIdentityStore`
|
|
810
|
+
* — no HTTP, no workflow, no token issuance (those are phase 3). Account-state
|
|
811
|
+
* gating, MFA, and consent are NOT done here; they run as the existing login
|
|
812
|
+
* workflow tail after the phase-3 `oauth-exchange` step sets the subject.
|
|
813
|
+
*
|
|
814
|
+
* Generic over the user shape `T` so a consumer's `UserService<DemoUser>` plugs
|
|
815
|
+
* in directly; `resolveUser` only reads base `UserCredentials` fields.
|
|
816
|
+
*/
|
|
817
|
+
var FederatedLoginService = class {
|
|
818
|
+
users;
|
|
819
|
+
federated;
|
|
820
|
+
policy;
|
|
821
|
+
constructor(deps) {
|
|
822
|
+
this.users = deps.users;
|
|
823
|
+
this.federated = deps.federated;
|
|
824
|
+
this.policy = resolveFederatedPolicy(deps.policy);
|
|
825
|
+
}
|
|
826
|
+
/**
|
|
827
|
+
* Resolve a verified {@link NormalizedProfile} to an outcome (decision #1):
|
|
828
|
+
*
|
|
829
|
+
* 1. **Known** `(provider, subject)` → `linked`.
|
|
830
|
+
* 2. **Email match** (fresh verified email via `findByHandle`):
|
|
831
|
+
* - `auto-link-if-verified` + trusted + verified → link → `auto-linked`;
|
|
832
|
+
* - otherwise (incl. `require-interactive-link`, or auto-link conditions
|
|
833
|
+
* unmet) → `needs-link` (caller completes via {@link linkIdentity}).
|
|
834
|
+
* - `create-separate` ignores the match and falls to (3).
|
|
835
|
+
* 3. **New** → `denied` if `allowSignup === false`; else create + activate +
|
|
836
|
+
* link → `created`.
|
|
837
|
+
*
|
|
838
|
+
* Every resolved outcome (`linked`/`auto-linked`/`created`) stamps
|
|
839
|
+
* `lastLoginAt` + refreshes the row's display snapshot via `touchLogin`.
|
|
840
|
+
*/
|
|
841
|
+
async resolveUser(profile) {
|
|
842
|
+
const known = await this.federated.find(profile.provider, profile.subject);
|
|
843
|
+
if (known) {
|
|
844
|
+
await this.touch(profile);
|
|
845
|
+
return {
|
|
846
|
+
kind: "linked",
|
|
847
|
+
userId: known.userId,
|
|
848
|
+
isNew: false
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
if (profile.email && this.policy.emailMatch !== "create-separate") {
|
|
852
|
+
const candidate = await this.users.findByHandle(profile.email);
|
|
853
|
+
if (candidate) {
|
|
854
|
+
if (this.policy.emailMatch === "auto-link-if-verified" && this.canAutoLink(profile)) {
|
|
855
|
+
await this.federated.link(this.newIdentity(profile, candidate.id));
|
|
856
|
+
await this.touch(profile);
|
|
857
|
+
return {
|
|
858
|
+
kind: "auto-linked",
|
|
859
|
+
userId: candidate.id,
|
|
860
|
+
isNew: false
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
return {
|
|
864
|
+
kind: "needs-link",
|
|
865
|
+
candidateUserId: candidate.id
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
if (!this.policy.allowSignup) return {
|
|
870
|
+
kind: "denied",
|
|
871
|
+
reason: "signup-disabled"
|
|
872
|
+
};
|
|
873
|
+
const userId = await this.createFederatedUser(profile);
|
|
874
|
+
await this.users.activateAccount(userId);
|
|
875
|
+
await this.federated.link(this.newIdentity(profile, userId));
|
|
876
|
+
await this.touch(profile);
|
|
877
|
+
return {
|
|
878
|
+
kind: "created",
|
|
879
|
+
userId,
|
|
880
|
+
isNew: true
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
884
|
+
* Complete an interactive link (the `needs-link` outcome): attach
|
|
885
|
+
* `(provider, subject)` to an **already-authenticated** `userId`. Idempotent
|
|
886
|
+
* when it is already that user's. Throws `UserAuthError('ALREADY_EXISTS')`
|
|
887
|
+
* when the identity is already linked to a DIFFERENT user — the
|
|
888
|
+
* confused-deputy / account-injection guard (RFC §4).
|
|
889
|
+
*
|
|
890
|
+
* The CSRF / state↔session-userId binding that proves the request truly
|
|
891
|
+
* speaks for `userId` is the phase-3 controller's responsibility.
|
|
892
|
+
*/
|
|
893
|
+
async linkIdentity(params) {
|
|
894
|
+
const existing = await this.federated.find(params.provider, params.subject);
|
|
895
|
+
if (existing) {
|
|
896
|
+
if (existing.userId === params.userId) {
|
|
897
|
+
await this.federated.touchLogin(params.provider, params.subject, params.profile ? (0, _aooth_user.pickDefinedProfile)(params.profile) : void 0);
|
|
898
|
+
return existing;
|
|
899
|
+
}
|
|
900
|
+
throw new _aooth_user.UserAuthError("ALREADY_EXISTS", "This provider account is linked to a different user");
|
|
901
|
+
}
|
|
902
|
+
const rec = {
|
|
903
|
+
provider: params.provider,
|
|
904
|
+
subject: params.subject,
|
|
905
|
+
userId: params.userId,
|
|
906
|
+
...params.profile ? (0, _aooth_user.pickDefinedProfile)(params.profile) : {}
|
|
907
|
+
};
|
|
908
|
+
return this.federated.link(rec);
|
|
909
|
+
}
|
|
910
|
+
/** Email-verified AND the provider is explicitly trusted (RFC §4). */
|
|
911
|
+
canAutoLink(profile) {
|
|
912
|
+
return profile.emailVerified === true && this.policy.trustEmailVerifiedFrom.includes(profile.provider);
|
|
913
|
+
}
|
|
914
|
+
/**
|
|
915
|
+
* Create a fresh user for an unmatched identity. Tries the policy username;
|
|
916
|
+
* on an `ALREADY_EXISTS` conflict falls back to the federated-unique
|
|
917
|
+
* `${provider}:${subject}`. Deliberately does NOT set the account `email`
|
|
918
|
+
* handle — promoting a provider email to the unique login handle is a gated
|
|
919
|
+
* phase-3 concern; the verified email is kept on the federated row instead.
|
|
920
|
+
*/
|
|
921
|
+
async createFederatedUser(profile) {
|
|
922
|
+
const desired = this.policy.usernameStrategy(profile);
|
|
923
|
+
const fallback = `${profile.provider}:${profile.subject}`;
|
|
924
|
+
try {
|
|
925
|
+
return (await this.users.createUser(desired)).id;
|
|
926
|
+
} catch (err) {
|
|
927
|
+
if (err instanceof _aooth_user.UserAuthError && err.type === "ALREADY_EXISTS" && desired !== fallback) return (await this.users.createUser(fallback)).id;
|
|
928
|
+
throw err;
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
newIdentity(profile, userId) {
|
|
932
|
+
return {
|
|
933
|
+
provider: profile.provider,
|
|
934
|
+
subject: profile.subject,
|
|
935
|
+
userId,
|
|
936
|
+
...(0, _aooth_user.pickDefinedProfile)(profile)
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
touch(profile) {
|
|
940
|
+
return this.federated.touchLogin(profile.provider, profile.subject, (0, _aooth_user.pickDefinedProfile)(profile));
|
|
941
|
+
}
|
|
942
|
+
};
|
|
943
|
+
//#endregion
|
|
944
|
+
exports.AppleProvider = AppleProvider;
|
|
945
|
+
exports.FakeIdentityProvider = FakeIdentityProvider;
|
|
946
|
+
exports.FederatedLoginService = FederatedLoginService;
|
|
947
|
+
exports.GithubProvider = GithubProvider;
|
|
948
|
+
exports.GoogleProvider = GoogleProvider;
|
|
949
|
+
exports.OAuthError = OAuthError;
|
|
950
|
+
exports.OAuthProviderRegistry = OAuthProviderRegistry;
|
|
951
|
+
exports.OidcProvider = OidcProvider;
|
|
952
|
+
exports.createPkcePair = createPkcePair;
|
|
953
|
+
exports.defaultUsernameStrategy = defaultUsernameStrategy;
|
|
954
|
+
exports.deriveSeededPkce = deriveSeededPkce;
|
|
955
|
+
exports.generateNonce = generateNonce;
|
|
956
|
+
exports.generateRandomState = generateRandomState;
|
|
957
|
+
exports.isConfigurableProvider = isConfigurableProvider;
|
|
958
|
+
exports.pkceChallengeFor = pkceChallengeFor;
|
|
959
|
+
exports.resolveFederatedPolicy = resolveFederatedPolicy;
|
|
960
|
+
exports.signState = signState;
|
|
961
|
+
exports.verifyState = verifyState;
|