@better-auth/sso 1.4.7-beta.3 → 1.4.7

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,494 @@
1
+ /**
2
+ * OIDC Discovery Pipeline
3
+ *
4
+ * Implements OIDC discovery document fetching, validation, and hydration.
5
+ * This module is used both at provider registration time (to persist validated config)
6
+ * and at runtime (to hydrate legacy providers that are missing metadata).
7
+ *
8
+ * @see https://openid.net/specs/openid-connect-discovery-1_0.html
9
+ */
10
+
11
+ import { betterFetch } from "@better-fetch/fetch";
12
+ import type {
13
+ DiscoverOIDCConfigParams,
14
+ HydratedOIDCConfig,
15
+ OIDCDiscoveryDocument,
16
+ } from "./types";
17
+ import { DiscoveryError, REQUIRED_DISCOVERY_FIELDS } from "./types";
18
+
19
+ /** Default timeout for discovery requests (10 seconds) */
20
+ const DEFAULT_DISCOVERY_TIMEOUT = 10000;
21
+
22
+ /**
23
+ * Main entry point: Discover and hydrate OIDC configuration from an issuer.
24
+ *
25
+ * This function:
26
+ * 1. Computes the discovery URL from the issuer
27
+ * 2. Validates the discovery URL
28
+ * 3. Fetches the discovery document
29
+ * 4. Validates the discovery document (issuer match + required fields)
30
+ * 5. Normalizes URLs
31
+ * 6. Selects token endpoint auth method
32
+ * 7. Merges with existing config (existing values take precedence)
33
+ *
34
+ * @param params - Discovery parameters
35
+ * @param isTrustedOrigin - Origin verification tester function
36
+ * @returns Hydrated OIDC configuration ready for persistence
37
+ * @throws DiscoveryError on any failure
38
+ */
39
+ export async function discoverOIDCConfig(
40
+ params: DiscoverOIDCConfigParams,
41
+ ): Promise<HydratedOIDCConfig> {
42
+ const {
43
+ issuer,
44
+ existingConfig,
45
+ timeout = DEFAULT_DISCOVERY_TIMEOUT,
46
+ } = params;
47
+
48
+ const discoveryUrl =
49
+ params.discoveryEndpoint ||
50
+ existingConfig?.discoveryEndpoint ||
51
+ computeDiscoveryUrl(issuer);
52
+
53
+ validateDiscoveryUrl(discoveryUrl, params.isTrustedOrigin);
54
+
55
+ const discoveryDoc = await fetchDiscoveryDocument(discoveryUrl, timeout);
56
+
57
+ validateDiscoveryDocument(discoveryDoc, issuer);
58
+
59
+ const normalizedDoc = normalizeDiscoveryUrls(
60
+ discoveryDoc,
61
+ issuer,
62
+ params.isTrustedOrigin,
63
+ );
64
+
65
+ const tokenEndpointAuth = selectTokenEndpointAuthMethod(
66
+ normalizedDoc,
67
+ existingConfig?.tokenEndpointAuthentication,
68
+ );
69
+
70
+ const hydratedConfig: HydratedOIDCConfig = {
71
+ issuer: existingConfig?.issuer ?? normalizedDoc.issuer,
72
+ discoveryEndpoint: existingConfig?.discoveryEndpoint ?? discoveryUrl,
73
+ authorizationEndpoint:
74
+ existingConfig?.authorizationEndpoint ??
75
+ normalizedDoc.authorization_endpoint,
76
+ tokenEndpoint:
77
+ existingConfig?.tokenEndpoint ?? normalizedDoc.token_endpoint,
78
+ jwksEndpoint: existingConfig?.jwksEndpoint ?? normalizedDoc.jwks_uri,
79
+ userInfoEndpoint:
80
+ existingConfig?.userInfoEndpoint ?? normalizedDoc.userinfo_endpoint,
81
+ tokenEndpointAuthentication:
82
+ existingConfig?.tokenEndpointAuthentication ?? tokenEndpointAuth,
83
+ scopesSupported:
84
+ existingConfig?.scopesSupported ?? normalizedDoc.scopes_supported,
85
+ };
86
+
87
+ return hydratedConfig;
88
+ }
89
+
90
+ /**
91
+ * Compute the discovery URL from an issuer URL.
92
+ *
93
+ * Per OIDC Discovery spec, the discovery document is located at:
94
+ * <issuer>/.well-known/openid-configuration
95
+ *
96
+ * Handles trailing slashes correctly.
97
+ */
98
+ export function computeDiscoveryUrl(issuer: string): string {
99
+ const baseUrl = issuer.endsWith("/") ? issuer.slice(0, -1) : issuer;
100
+ return `${baseUrl}/.well-known/openid-configuration`;
101
+ }
102
+
103
+ /**
104
+ * Validate a discovery URL before fetching.
105
+ *
106
+ * @param url - The discovery URL to validate
107
+ * @param isTrustedOrigin - Origin verification tester function
108
+ * @throws DiscoveryError if URL is invalid
109
+ */
110
+ export function validateDiscoveryUrl(
111
+ url: string,
112
+ isTrustedOrigin: DiscoverOIDCConfigParams["isTrustedOrigin"],
113
+ ): void {
114
+ const discoveryEndpoint = parseURL("discoveryEndpoint", url).toString();
115
+
116
+ if (!isTrustedOrigin(discoveryEndpoint)) {
117
+ throw new DiscoveryError(
118
+ "discovery_untrusted_origin",
119
+ `The main discovery endpoint "${discoveryEndpoint}" is not trusted by your trusted origins configuration.`,
120
+ { url: discoveryEndpoint },
121
+ );
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Fetch the OIDC discovery document from the IdP.
127
+ *
128
+ * @param url - The discovery endpoint URL
129
+ * @param timeout - Request timeout in milliseconds
130
+ * @returns The parsed discovery document
131
+ * @throws DiscoveryError on network errors, timeouts, or invalid responses
132
+ */
133
+ export async function fetchDiscoveryDocument(
134
+ url: string,
135
+ timeout: number = DEFAULT_DISCOVERY_TIMEOUT,
136
+ ): Promise<OIDCDiscoveryDocument> {
137
+ try {
138
+ const response = await betterFetch<OIDCDiscoveryDocument>(url, {
139
+ method: "GET",
140
+ timeout,
141
+ });
142
+
143
+ if (response.error) {
144
+ const { status } = response.error;
145
+
146
+ if (status === 404) {
147
+ throw new DiscoveryError(
148
+ "discovery_not_found",
149
+ "Discovery endpoint not found",
150
+ {
151
+ url,
152
+ status,
153
+ },
154
+ );
155
+ }
156
+
157
+ if (status === 408) {
158
+ throw new DiscoveryError(
159
+ "discovery_timeout",
160
+ "Discovery request timed out",
161
+ {
162
+ url,
163
+ timeout,
164
+ },
165
+ );
166
+ }
167
+
168
+ throw new DiscoveryError(
169
+ "discovery_unexpected_error",
170
+ `Unexpected discovery error: ${response.error.statusText}`,
171
+ { url, ...response.error },
172
+ );
173
+ }
174
+
175
+ if (!response.data) {
176
+ throw new DiscoveryError(
177
+ "discovery_invalid_json",
178
+ "Discovery endpoint returned an empty response",
179
+ { url },
180
+ );
181
+ }
182
+
183
+ const data = response.data as OIDCDiscoveryDocument | string;
184
+ if (typeof data === "string") {
185
+ throw new DiscoveryError(
186
+ "discovery_invalid_json",
187
+ "Discovery endpoint returned invalid JSON",
188
+ { url, bodyPreview: data.slice(0, 200) },
189
+ );
190
+ }
191
+
192
+ return data;
193
+ } catch (error) {
194
+ if (error instanceof DiscoveryError) {
195
+ throw error;
196
+ }
197
+
198
+ // betterFetch throws AbortError on timeout (not returned as response.error)
199
+ // Check error.name since message varies by runtime
200
+ if (error instanceof Error && error.name === "AbortError") {
201
+ throw new DiscoveryError(
202
+ "discovery_timeout",
203
+ "Discovery request timed out",
204
+ {
205
+ url,
206
+ timeout,
207
+ },
208
+ );
209
+ }
210
+
211
+ throw new DiscoveryError(
212
+ "discovery_unexpected_error",
213
+ `Unexpected error during discovery: ${error instanceof Error ? error.message : String(error)}`,
214
+ { url },
215
+ { cause: error },
216
+ );
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Validate a discovery document.
222
+ *
223
+ * Checks:
224
+ * 1. All required fields are present
225
+ * 2. Issuer matches the configured issuer (case-sensitive, exact match)
226
+ *
227
+ * Invariant: If this function returns without throwing, the document is safe
228
+ * to use for hydrating OIDC config (required fields present, issuer matches
229
+ * configured value, basic structural sanity verified).
230
+ *
231
+ * @param doc - The discovery document to validate
232
+ * @param configuredIssuer - The expected issuer value
233
+ * @throws DiscoveryError if validation fails
234
+ */
235
+ export function validateDiscoveryDocument(
236
+ doc: OIDCDiscoveryDocument,
237
+ configuredIssuer: string,
238
+ ): void {
239
+ const missingFields: string[] = [];
240
+
241
+ for (const field of REQUIRED_DISCOVERY_FIELDS) {
242
+ if (!doc[field]) {
243
+ missingFields.push(field);
244
+ }
245
+ }
246
+
247
+ if (missingFields.length > 0) {
248
+ throw new DiscoveryError(
249
+ "discovery_incomplete",
250
+ `Discovery document is missing required fields: ${missingFields.join(", ")}`,
251
+ { missingFields },
252
+ );
253
+ }
254
+
255
+ const discoveredIssuer = doc.issuer.endsWith("/")
256
+ ? doc.issuer.slice(0, -1)
257
+ : doc.issuer;
258
+ const expectedIssuer = configuredIssuer.endsWith("/")
259
+ ? configuredIssuer.slice(0, -1)
260
+ : configuredIssuer;
261
+
262
+ if (discoveredIssuer !== expectedIssuer) {
263
+ throw new DiscoveryError(
264
+ "issuer_mismatch",
265
+ `Discovered issuer "${doc.issuer}" does not match configured issuer "${configuredIssuer}"`,
266
+ {
267
+ discovered: doc.issuer,
268
+ configured: configuredIssuer,
269
+ },
270
+ );
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Normalize URLs in the discovery document.
276
+ *
277
+ * @param document - The discovery document
278
+ * @param issuer - The base issuer URL
279
+ * @param isTrustedOrigin - Origin verification tester function
280
+ * @returns The normalized discovery document
281
+ */
282
+ export function normalizeDiscoveryUrls(
283
+ document: OIDCDiscoveryDocument,
284
+ issuer: string,
285
+ isTrustedOrigin: DiscoverOIDCConfigParams["isTrustedOrigin"],
286
+ ): OIDCDiscoveryDocument {
287
+ const doc = { ...document };
288
+
289
+ doc.token_endpoint = normalizeAndValidateUrl(
290
+ "token_endpoint",
291
+ doc.token_endpoint,
292
+ issuer,
293
+ isTrustedOrigin,
294
+ );
295
+ doc.authorization_endpoint = normalizeAndValidateUrl(
296
+ "authorization_endpoint",
297
+ doc.authorization_endpoint,
298
+ issuer,
299
+ isTrustedOrigin,
300
+ );
301
+
302
+ doc.jwks_uri = normalizeAndValidateUrl(
303
+ "jwks_uri",
304
+ doc.jwks_uri,
305
+ issuer,
306
+ isTrustedOrigin,
307
+ );
308
+
309
+ if (doc.userinfo_endpoint) {
310
+ doc.userinfo_endpoint = normalizeAndValidateUrl(
311
+ "userinfo_endpoint",
312
+ doc.userinfo_endpoint,
313
+ issuer,
314
+ isTrustedOrigin,
315
+ );
316
+ }
317
+
318
+ if (doc.revocation_endpoint) {
319
+ doc.revocation_endpoint = normalizeAndValidateUrl(
320
+ "revocation_endpoint",
321
+ doc.revocation_endpoint,
322
+ issuer,
323
+ isTrustedOrigin,
324
+ );
325
+ }
326
+
327
+ if (doc.end_session_endpoint) {
328
+ doc.end_session_endpoint = normalizeAndValidateUrl(
329
+ "end_session_endpoint",
330
+ doc.end_session_endpoint,
331
+ issuer,
332
+ isTrustedOrigin,
333
+ );
334
+ }
335
+
336
+ if (doc.introspection_endpoint) {
337
+ doc.introspection_endpoint = normalizeAndValidateUrl(
338
+ "introspection_endpoint",
339
+ doc.introspection_endpoint,
340
+ issuer,
341
+ isTrustedOrigin,
342
+ );
343
+ }
344
+
345
+ return doc;
346
+ }
347
+
348
+ /**
349
+ * Normalizes and validates a single URL endpoint
350
+ * @param name The url name
351
+ * @param endpoint The url to validate
352
+ * @param issuer The issuer base url
353
+ * @param isTrustedOrigin - Origin verification tester function
354
+ * @returns
355
+ */
356
+ function normalizeAndValidateUrl(
357
+ name: string,
358
+ endpoint: string,
359
+ issuer: string,
360
+ isTrustedOrigin: DiscoverOIDCConfigParams["isTrustedOrigin"],
361
+ ): string {
362
+ const url = normalizeUrl(name, endpoint, issuer);
363
+
364
+ if (!isTrustedOrigin(url)) {
365
+ throw new DiscoveryError(
366
+ "discovery_untrusted_origin",
367
+ `The ${name} "${url}" is not trusted by your trusted origins configuration.`,
368
+ { endpoint: name, url },
369
+ );
370
+ }
371
+
372
+ return url;
373
+ }
374
+
375
+ /**
376
+ * Normalize a single URL endpoint.
377
+ *
378
+ * @param name - The endpoint name (e.g token_endpoint)
379
+ * @param endpoint - The endpoint URL to normalize
380
+ * @param issuer - The base issuer URL
381
+ * @returns The normalized endpoint URL
382
+ */
383
+ export function normalizeUrl(
384
+ name: string,
385
+ endpoint: string,
386
+ issuer: string,
387
+ ): string {
388
+ try {
389
+ return parseURL(name, endpoint).toString();
390
+ } catch {
391
+ // In case of error, endpoint maybe a relative url
392
+ // So we try to resolve it relative to the issuer
393
+
394
+ const issuerURL = parseURL(name, issuer);
395
+ const basePath = issuerURL.pathname.replace(/\/+$/, "");
396
+ const endpointPath = endpoint.replace(/^\/+/, "");
397
+
398
+ return parseURL(
399
+ name,
400
+ basePath + "/" + endpointPath,
401
+ issuerURL.origin,
402
+ ).toString();
403
+ }
404
+ }
405
+
406
+ /**
407
+ * Parses the given URL or throws in case of invalid or unsupported protocols
408
+ *
409
+ * @param name the url name
410
+ * @param endpoint the endpoint url
411
+ * @param [base] optional base path
412
+ * @returns
413
+ */
414
+ function parseURL(name: string, endpoint: string, base?: string) {
415
+ let endpointURL: URL | undefined;
416
+
417
+ try {
418
+ endpointURL = new URL(endpoint, base);
419
+ if (endpointURL.protocol === "http:" || endpointURL.protocol === "https:") {
420
+ return endpointURL;
421
+ }
422
+ } catch (error) {
423
+ throw new DiscoveryError(
424
+ "discovery_invalid_url",
425
+ `The url "${name}" must be valid: ${endpoint}`,
426
+ {
427
+ url: endpoint,
428
+ },
429
+ { cause: error },
430
+ );
431
+ }
432
+
433
+ throw new DiscoveryError(
434
+ "discovery_invalid_url",
435
+ `The url "${name}" must use the http or https supported protocols: ${endpoint}`,
436
+ { url: endpoint, protocol: endpointURL.protocol },
437
+ );
438
+ }
439
+
440
+ /**
441
+ * Select the token endpoint authentication method.
442
+ *
443
+ * @param doc - The discovery document
444
+ * @param existing - Existing authentication method from config
445
+ * @returns The selected authentication method
446
+ */
447
+ export function selectTokenEndpointAuthMethod(
448
+ doc: OIDCDiscoveryDocument,
449
+ existing?: "client_secret_basic" | "client_secret_post",
450
+ ): "client_secret_basic" | "client_secret_post" {
451
+ if (existing) {
452
+ return existing;
453
+ }
454
+
455
+ const supported = doc.token_endpoint_auth_methods_supported;
456
+
457
+ if (!supported || supported.length === 0) {
458
+ return "client_secret_basic";
459
+ }
460
+
461
+ if (supported.includes("client_secret_basic")) {
462
+ return "client_secret_basic";
463
+ }
464
+
465
+ if (supported.includes("client_secret_post")) {
466
+ return "client_secret_post";
467
+ }
468
+
469
+ return "client_secret_basic";
470
+ }
471
+
472
+ /**
473
+ * Check if a provider configuration needs runtime discovery.
474
+ *
475
+ * Returns true if we need discovery at runtime to complete the token exchange
476
+ * and validation. Specifically checks for:
477
+ * - `tokenEndpoint` - required for exchanging authorization code for tokens
478
+ * - `jwksEndpoint` - required for validating ID token signatures
479
+ *
480
+ * Note: `authorizationEndpoint` is handled separately in the sign-in flow,
481
+ * so it's not checked here.
482
+ *
483
+ * @param config - Partial OIDC config from the provider
484
+ * @returns true if runtime discovery should be performed
485
+ */
486
+ export function needsRuntimeDiscovery(
487
+ config: Partial<HydratedOIDCConfig> | undefined,
488
+ ): boolean {
489
+ if (!config) {
490
+ return true;
491
+ }
492
+
493
+ return !config.tokenEndpoint || !config.jwksEndpoint;
494
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * OIDC Discovery Error Mapping
3
+ *
4
+ * Maps DiscoveryError codes to appropriate APIError responses.
5
+ * Used at the boundary between the discovery pipeline and HTTP handlers.
6
+ */
7
+
8
+ import { APIError } from "better-auth/api";
9
+ import type { DiscoveryError } from "./types";
10
+
11
+ /**
12
+ * Maps a DiscoveryError to an appropriate APIError for HTTP responses.
13
+ *
14
+ * Error code mapping:
15
+ * - discovery_invalid_url → 400 BAD_REQUEST
16
+ * - discovery_not_found → 400 BAD_REQUEST
17
+ * - discovery_invalid_json → 400 BAD_REQUEST
18
+ * - discovery_incomplete → 400 BAD_REQUEST
19
+ * - issuer_mismatch → 400 BAD_REQUEST
20
+ * - unsupported_token_auth_method → 400 BAD_REQUEST
21
+ * - discovery_timeout → 502 BAD_GATEWAY
22
+ * - discovery_unexpected_error → 502 BAD_GATEWAY
23
+ *
24
+ * @param error - The DiscoveryError to map
25
+ * @returns An APIError with appropriate status and message
26
+ */
27
+ export function mapDiscoveryErrorToAPIError(error: DiscoveryError): APIError {
28
+ switch (error.code) {
29
+ case "discovery_timeout":
30
+ return new APIError("BAD_GATEWAY", {
31
+ message: `OIDC discovery timed out: ${error.message}`,
32
+ code: error.code,
33
+ });
34
+
35
+ case "discovery_unexpected_error":
36
+ return new APIError("BAD_GATEWAY", {
37
+ message: `OIDC discovery failed: ${error.message}`,
38
+ code: error.code,
39
+ });
40
+
41
+ case "discovery_not_found":
42
+ return new APIError("BAD_REQUEST", {
43
+ message: `OIDC discovery endpoint not found. The issuer may not support OIDC discovery, or the URL is incorrect. ${error.message}`,
44
+ code: error.code,
45
+ });
46
+
47
+ case "discovery_invalid_url":
48
+ return new APIError("BAD_REQUEST", {
49
+ message: `Invalid OIDC discovery URL: ${error.message}`,
50
+ code: error.code,
51
+ });
52
+
53
+ case "discovery_untrusted_origin":
54
+ return new APIError("BAD_REQUEST", {
55
+ message: `Untrusted OIDC discovery URL: ${error.message}`,
56
+ code: error.code,
57
+ });
58
+
59
+ case "discovery_invalid_json":
60
+ return new APIError("BAD_REQUEST", {
61
+ message: `OIDC discovery returned invalid data: ${error.message}`,
62
+ code: error.code,
63
+ });
64
+
65
+ case "discovery_incomplete":
66
+ return new APIError("BAD_REQUEST", {
67
+ message: `OIDC discovery document is missing required fields: ${error.message}`,
68
+ code: error.code,
69
+ });
70
+
71
+ case "issuer_mismatch":
72
+ return new APIError("BAD_REQUEST", {
73
+ message: `OIDC issuer mismatch: ${error.message}`,
74
+ code: error.code,
75
+ });
76
+
77
+ case "unsupported_token_auth_method":
78
+ return new APIError("BAD_REQUEST", {
79
+ message: `Incompatible OIDC provider: ${error.message}`,
80
+ code: error.code,
81
+ });
82
+
83
+ default: {
84
+ // Exhaustive check - TypeScript will error if we miss a case
85
+ const _exhaustiveCheck: never = error.code;
86
+ return new APIError("INTERNAL_SERVER_ERROR", {
87
+ message: `Unexpected discovery error: ${error.message}`,
88
+ code: "discovery_unexpected_error",
89
+ });
90
+ }
91
+ }
92
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * OIDC Discovery Module
3
+ *
4
+ * This module provides OIDC discovery document fetching, validation, and hydration.
5
+ * It is used both at provider registration time (to persist validated config)
6
+ * and at runtime (to hydrate legacy providers that are missing metadata).
7
+ */
8
+
9
+ export {
10
+ computeDiscoveryUrl,
11
+ discoverOIDCConfig,
12
+ fetchDiscoveryDocument,
13
+ needsRuntimeDiscovery,
14
+ normalizeDiscoveryUrls,
15
+ normalizeUrl,
16
+ selectTokenEndpointAuthMethod,
17
+ validateDiscoveryDocument,
18
+ validateDiscoveryUrl,
19
+ } from "./discovery";
20
+
21
+ export { mapDiscoveryErrorToAPIError } from "./errors";
22
+
23
+ export {
24
+ type DiscoverOIDCConfigParams,
25
+ DiscoveryError,
26
+ type DiscoveryErrorCode,
27
+ type HydratedOIDCConfig,
28
+ type OIDCDiscoveryDocument,
29
+ REQUIRED_DISCOVERY_FIELDS,
30
+ type RequiredDiscoveryField,
31
+ } from "./types";