@better-auth/sso 1.4.7-beta.4 → 1.4.8-beta.1

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.
@@ -24,14 +24,15 @@ const DEFAULT_DISCOVERY_TIMEOUT = 10000;
24
24
  *
25
25
  * This function:
26
26
  * 1. Computes the discovery URL from the issuer
27
- * 2. Validates the discovery URL (stub for now)
27
+ * 2. Validates the discovery URL
28
28
  * 3. Fetches the discovery document
29
29
  * 4. Validates the discovery document (issuer match + required fields)
30
- * 5. Normalizes URLs (stub for now)
30
+ * 5. Normalizes URLs
31
31
  * 6. Selects token endpoint auth method
32
32
  * 7. Merges with existing config (existing values take precedence)
33
33
  *
34
34
  * @param params - Discovery parameters
35
+ * @param isTrustedOrigin - Origin verification tester function
35
36
  * @returns Hydrated OIDC configuration ready for persistence
36
37
  * @throws DiscoveryError on any failure
37
38
  */
@@ -49,13 +50,17 @@ export async function discoverOIDCConfig(
49
50
  existingConfig?.discoveryEndpoint ||
50
51
  computeDiscoveryUrl(issuer);
51
52
 
52
- validateDiscoveryUrl(discoveryUrl);
53
+ validateDiscoveryUrl(discoveryUrl, params.isTrustedOrigin);
53
54
 
54
55
  const discoveryDoc = await fetchDiscoveryDocument(discoveryUrl, timeout);
55
56
 
56
57
  validateDiscoveryDocument(discoveryDoc, issuer);
57
58
 
58
- const normalizedDoc = normalizeDiscoveryUrls(discoveryDoc, issuer);
59
+ const normalizedDoc = normalizeDiscoveryUrls(
60
+ discoveryDoc,
61
+ issuer,
62
+ params.isTrustedOrigin,
63
+ );
59
64
 
60
65
  const tokenEndpointAuth = selectTokenEndpointAuthMethod(
61
66
  normalizedDoc,
@@ -99,27 +104,20 @@ export function computeDiscoveryUrl(issuer: string): string {
99
104
  * Validate a discovery URL before fetching.
100
105
  *
101
106
  * @param url - The discovery URL to validate
107
+ * @param isTrustedOrigin - Origin verification tester function
102
108
  * @throws DiscoveryError if URL is invalid
103
109
  */
104
- export function validateDiscoveryUrl(url: string): void {
105
- try {
106
- const parsed = new URL(url);
107
- if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
108
- throw new DiscoveryError(
109
- "discovery_invalid_url",
110
- `Discovery URL must use HTTP or HTTPS protocol: ${url}`,
111
- { url, protocol: parsed.protocol },
112
- );
113
- }
114
- } catch (error) {
115
- if (error instanceof DiscoveryError) {
116
- throw error;
117
- }
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)) {
118
117
  throw new DiscoveryError(
119
- "discovery_invalid_url",
120
- `Invalid discovery URL: ${url}`,
121
- { url },
122
- { cause: error },
118
+ "discovery_untrusted_origin",
119
+ `The main discovery endpoint "${discoveryEndpoint}" is not trusted by your trusted origins configuration.`,
120
+ { url: discoveryEndpoint },
123
121
  );
124
122
  }
125
123
  }
@@ -276,26 +274,167 @@ export function validateDiscoveryDocument(
276
274
  /**
277
275
  * Normalize URLs in the discovery document.
278
276
  *
279
- * @param doc - The discovery document
280
- * @param _issuerBase - The base issuer URL
277
+ * @param document - The discovery document
278
+ * @param issuer - The base issuer URL
279
+ * @param isTrustedOrigin - Origin verification tester function
281
280
  * @returns The normalized discovery document
282
281
  */
283
282
  export function normalizeDiscoveryUrls(
284
- doc: OIDCDiscoveryDocument,
285
- _issuerBase: string,
283
+ document: OIDCDiscoveryDocument,
284
+ issuer: string,
285
+ isTrustedOrigin: DiscoverOIDCConfigParams["isTrustedOrigin"],
286
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
+
287
345
  return doc;
288
346
  }
289
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
+
290
375
  /**
291
376
  * Normalize a single URL endpoint.
292
377
  *
378
+ * @param name - The endpoint name (e.g token_endpoint)
293
379
  * @param endpoint - The endpoint URL to normalize
294
- * @param _issuerBase - The base issuer URL
380
+ * @param issuer - The base issuer URL
295
381
  * @returns The normalized endpoint URL
296
382
  */
297
- export function normalizeUrl(endpoint: string, _issuerBase: string): string {
298
- return endpoint;
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
+ );
299
438
  }
300
439
 
301
440
  /**
@@ -50,6 +50,12 @@ export function mapDiscoveryErrorToAPIError(error: DiscoveryError): APIError {
50
50
  code: error.code,
51
51
  });
52
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
+
53
59
  case "discovery_invalid_json":
54
60
  return new APIError("BAD_REQUEST", {
55
61
  message: `OIDC discovery returned invalid data: ${error.message}`,
package/src/oidc/types.ts CHANGED
@@ -103,6 +103,8 @@ export type DiscoveryErrorCode =
103
103
  | "discovery_invalid_json"
104
104
  /** Discovery URL is invalid or malformed */
105
105
  | "discovery_invalid_url"
106
+ /** Discovery URL is not trusted by the trusted origins configuration */
107
+ | "discovery_untrusted_origin"
106
108
  /** Discovery document issuer doesn't match configured issuer */
107
109
  | "issuer_mismatch"
108
110
  /** Discovery document is missing required fields */
@@ -195,6 +197,13 @@ export interface DiscoverOIDCConfigParams {
195
197
  * @default 10000 (10 seconds)
196
198
  */
197
199
  timeout?: number;
200
+
201
+ /**
202
+ * Trusted origin predicate. See "trustedOrigins" option
203
+ * @param url the url to test
204
+ * @returns {boolean} return true for urls that belong to a trusted origin and false otherwise
205
+ */
206
+ isTrustedOrigin: (url: string) => boolean;
198
207
  }
199
208
 
200
209
  /**
package/src/oidc.test.ts CHANGED
@@ -12,6 +12,7 @@ let server = new OAuth2Server();
12
12
  describe("SSO", async () => {
13
13
  const { auth, signInWithTestUser, customFetchImpl, cookieSetter } =
14
14
  await getTestInstance({
15
+ trustedOrigins: ["http://localhost:8080"],
15
16
  plugins: [sso(), organization()],
16
17
  });
17
18
 
@@ -257,6 +258,7 @@ describe("SSO", async () => {
257
258
  describe("SSO disable implicit sign in", async () => {
258
259
  const { auth, signInWithTestUser, customFetchImpl, cookieSetter } =
259
260
  await getTestInstance({
261
+ trustedOrigins: ["http://localhost:8080"],
260
262
  plugins: [sso({ disableImplicitSignUp: true }), organization()],
261
263
  });
262
264
 
@@ -419,6 +421,7 @@ describe("SSO disable implicit sign in", async () => {
419
421
  describe("provisioning", async (ctx) => {
420
422
  const { auth, signInWithTestUser, customFetchImpl, cookieSetter } =
421
423
  await getTestInstance({
424
+ trustedOrigins: ["http://localhost:8080"],
422
425
  plugins: [sso(), organization()],
423
426
  });
424
427