@better-auth/sso 1.4.7-beta.4 → 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.
@@ -1,5 +1,5 @@
1
1
 
2
- > @better-auth/sso@1.4.7-beta.4 build /home/runner/work/better-auth/better-auth/packages/sso
2
+ > @better-auth/sso@1.4.7 build /home/runner/work/better-auth/better-auth/packages/sso
3
3
  > tsdown
4
4
 
5
5
  ℹ tsdown v0.17.2 powered by rolldown v1.0.0-beta.53
@@ -7,10 +7,10 @@
7
7
  ℹ entry: src/index.ts, src/client.ts
8
8
  ℹ tsconfig: tsconfig.json
9
9
  ℹ Build start
10
- ℹ dist/index.mjs 80.81 kB │ gzip: 15.19 kB
10
+ ℹ dist/index.mjs 83.77 kB │ gzip: 15.84 kB
11
11
  ℹ dist/client.mjs  0.15 kB │ gzip: 0.14 kB
12
12
  ℹ dist/index.d.mts  1.44 kB │ gzip: 0.52 kB
13
- ℹ dist/client.d.mts  0.49 kB │ gzip: 0.29 kB
14
- ℹ dist/index-GoyGoP_a.d.mts 41.30 kB │ gzip: 8.58 kB
15
- ℹ 5 files, total: 124.19 kB
16
- ✔ Build complete in 12053ms
13
+ ℹ dist/client.d.mts  0.49 kB │ gzip: 0.30 kB
14
+ ℹ dist/index-B9WMxRdD.d.mts 41.59 kB │ gzip: 8.59 kB
15
+ ℹ 5 files, total: 127.44 kB
16
+ ✔ Build complete in 12101ms
package/dist/client.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { t as SSOPlugin } from "./index-GoyGoP_a.mjs";
1
+ import { t as SSOPlugin } from "./index-B9WMxRdD.mjs";
2
2
 
3
3
  //#region src/client.d.ts
4
4
  interface SSOClientOptions {
@@ -253,13 +253,7 @@ interface SSOOptions {
253
253
  *
254
254
  * If you want to allow account linking for specific trusted providers, enable the `accountLinking` option in your auth config and specify those
255
255
  * providers in the `trustedProviders` list.
256
- *
257
256
  * @default false
258
- *
259
- * @deprecated This option is discouraged for new projects. Relying on provider-level `email_verified` is a weaker
260
- * trust signal compared to using `trustedProviders` in `accountLinking` or enabling `domainVerification` for SSO.
261
- * Existing configurations will continue to work, but new integrations should use explicit trust mechanisms.
262
- * This option may be removed in a future major version.
263
257
  */
264
258
  trustEmailVerified?: boolean | undefined;
265
259
  /**
@@ -1022,6 +1016,7 @@ type DiscoveryErrorCode = /** Request to discovery endpoint timed out */
1022
1016
  /** Discovery endpoint returned 404 or similar */ | "discovery_not_found"
1023
1017
  /** Discovery endpoint returned invalid JSON */ | "discovery_invalid_json"
1024
1018
  /** Discovery URL is invalid or malformed */ | "discovery_invalid_url"
1019
+ /** Discovery URL is not trusted by the trusted origins configuration */ | "discovery_untrusted_origin"
1025
1020
  /** Discovery document issuer doesn't match configured issuer */ | "issuer_mismatch"
1026
1021
  /** Discovery document is missing required fields */ | "discovery_incomplete"
1027
1022
  /** IdP only advertises token auth methods that Better Auth doesn't currently support */ | "unsupported_token_auth_method"
@@ -1083,6 +1078,12 @@ interface DiscoverOIDCConfigParams {
1083
1078
  * @default 10000 (10 seconds)
1084
1079
  */
1085
1080
  timeout?: number;
1081
+ /**
1082
+ * Trusted origin predicate. See "trustedOrigins" option
1083
+ * @param url the url to test
1084
+ * @returns {boolean} return true for urls that belong to a trusted origin and false otherwise
1085
+ */
1086
+ isTrustedOrigin: (url: string) => boolean;
1086
1087
  }
1087
1088
  /**
1088
1089
  * Required fields that must be present in a valid discovery document.
@@ -1096,14 +1097,15 @@ type RequiredDiscoveryField = (typeof REQUIRED_DISCOVERY_FIELDS)[number];
1096
1097
  *
1097
1098
  * This function:
1098
1099
  * 1. Computes the discovery URL from the issuer
1099
- * 2. Validates the discovery URL (stub for now)
1100
+ * 2. Validates the discovery URL
1100
1101
  * 3. Fetches the discovery document
1101
1102
  * 4. Validates the discovery document (issuer match + required fields)
1102
- * 5. Normalizes URLs (stub for now)
1103
+ * 5. Normalizes URLs
1103
1104
  * 6. Selects token endpoint auth method
1104
1105
  * 7. Merges with existing config (existing values take precedence)
1105
1106
  *
1106
1107
  * @param params - Discovery parameters
1108
+ * @param isTrustedOrigin - Origin verification tester function
1107
1109
  * @returns Hydrated OIDC configuration ready for persistence
1108
1110
  * @throws DiscoveryError on any failure
1109
1111
  */
@@ -1121,9 +1123,10 @@ declare function computeDiscoveryUrl(issuer: string): string;
1121
1123
  * Validate a discovery URL before fetching.
1122
1124
  *
1123
1125
  * @param url - The discovery URL to validate
1126
+ * @param isTrustedOrigin - Origin verification tester function
1124
1127
  * @throws DiscoveryError if URL is invalid
1125
1128
  */
1126
- declare function validateDiscoveryUrl(url: string): void;
1129
+ declare function validateDiscoveryUrl(url: string, isTrustedOrigin: DiscoverOIDCConfigParams["isTrustedOrigin"]): void;
1127
1130
  /**
1128
1131
  * Fetch the OIDC discovery document from the IdP.
1129
1132
  *
@@ -1152,19 +1155,21 @@ declare function validateDiscoveryDocument(doc: OIDCDiscoveryDocument, configure
1152
1155
  /**
1153
1156
  * Normalize URLs in the discovery document.
1154
1157
  *
1155
- * @param doc - The discovery document
1156
- * @param _issuerBase - The base issuer URL
1158
+ * @param document - The discovery document
1159
+ * @param issuer - The base issuer URL
1160
+ * @param isTrustedOrigin - Origin verification tester function
1157
1161
  * @returns The normalized discovery document
1158
1162
  */
1159
- declare function normalizeDiscoveryUrls(doc: OIDCDiscoveryDocument, _issuerBase: string): OIDCDiscoveryDocument;
1163
+ declare function normalizeDiscoveryUrls(document: OIDCDiscoveryDocument, issuer: string, isTrustedOrigin: DiscoverOIDCConfigParams["isTrustedOrigin"]): OIDCDiscoveryDocument;
1160
1164
  /**
1161
1165
  * Normalize a single URL endpoint.
1162
1166
  *
1167
+ * @param name - The endpoint name (e.g token_endpoint)
1163
1168
  * @param endpoint - The endpoint URL to normalize
1164
- * @param _issuerBase - The base issuer URL
1169
+ * @param issuer - The base issuer URL
1165
1170
  * @returns The normalized endpoint URL
1166
1171
  */
1167
- declare function normalizeUrl(endpoint: string, _issuerBase: string): string;
1172
+ declare function normalizeUrl(name: string, endpoint: string, issuer: string): string;
1168
1173
  /**
1169
1174
  * Select the token endpoint authentication method.
1170
1175
  *
package/dist/index.d.mts CHANGED
@@ -1,2 +1,2 @@
1
- import { A as createInMemoryAuthnRequestStore, C as OIDCConfig, D as AuthnRequestRecord, E as SSOProvider, O as AuthnRequestStore, S as validateSAMLTimestamp, T as SSOOptions, _ as REQUIRED_DISCOVERY_FIELDS, a as fetchDiscoveryDocument, b as SAMLConditions, c as normalizeUrl, d as validateDiscoveryUrl, f as DiscoverOIDCConfigParams, g as OIDCDiscoveryDocument, h as HydratedOIDCConfig, i as discoverOIDCConfig, k as DEFAULT_AUTHN_REQUEST_TTL_MS, l as selectTokenEndpointAuthMethod, m as DiscoveryErrorCode, n as sso, o as needsRuntimeDiscovery, p as DiscoveryError, r as computeDiscoveryUrl, s as normalizeDiscoveryUrls, t as SSOPlugin, u as validateDiscoveryDocument, v as RequiredDiscoveryField, w as SAMLConfig, x as TimestampValidationOptions, y as DEFAULT_CLOCK_SKEW_MS } from "./index-GoyGoP_a.mjs";
1
+ import { A as createInMemoryAuthnRequestStore, C as OIDCConfig, D as AuthnRequestRecord, E as SSOProvider, O as AuthnRequestStore, S as validateSAMLTimestamp, T as SSOOptions, _ as REQUIRED_DISCOVERY_FIELDS, a as fetchDiscoveryDocument, b as SAMLConditions, c as normalizeUrl, d as validateDiscoveryUrl, f as DiscoverOIDCConfigParams, g as OIDCDiscoveryDocument, h as HydratedOIDCConfig, i as discoverOIDCConfig, k as DEFAULT_AUTHN_REQUEST_TTL_MS, l as selectTokenEndpointAuthMethod, m as DiscoveryErrorCode, n as sso, o as needsRuntimeDiscovery, p as DiscoveryError, r as computeDiscoveryUrl, s as normalizeDiscoveryUrls, t as SSOPlugin, u as validateDiscoveryDocument, v as RequiredDiscoveryField, w as SAMLConfig, x as TimestampValidationOptions, y as DEFAULT_CLOCK_SKEW_MS } from "./index-B9WMxRdD.mjs";
2
2
  export { AuthnRequestRecord, AuthnRequestStore, DEFAULT_AUTHN_REQUEST_TTL_MS, DEFAULT_CLOCK_SKEW_MS, DiscoverOIDCConfigParams, DiscoveryError, DiscoveryErrorCode, HydratedOIDCConfig, OIDCConfig, OIDCDiscoveryDocument, REQUIRED_DISCOVERY_FIELDS, RequiredDiscoveryField, SAMLConditions, SAMLConfig, SSOOptions, SSOPlugin, SSOProvider, TimestampValidationOptions, computeDiscoveryUrl, createInMemoryAuthnRequestStore, discoverOIDCConfig, fetchDiscoveryDocument, needsRuntimeDiscovery, normalizeDiscoveryUrls, normalizeUrl, selectTokenEndpointAuthMethod, sso, validateDiscoveryDocument, validateDiscoveryUrl, validateSAMLTimestamp };
package/dist/index.mjs CHANGED
@@ -268,24 +268,25 @@ const DEFAULT_DISCOVERY_TIMEOUT = 1e4;
268
268
  *
269
269
  * This function:
270
270
  * 1. Computes the discovery URL from the issuer
271
- * 2. Validates the discovery URL (stub for now)
271
+ * 2. Validates the discovery URL
272
272
  * 3. Fetches the discovery document
273
273
  * 4. Validates the discovery document (issuer match + required fields)
274
- * 5. Normalizes URLs (stub for now)
274
+ * 5. Normalizes URLs
275
275
  * 6. Selects token endpoint auth method
276
276
  * 7. Merges with existing config (existing values take precedence)
277
277
  *
278
278
  * @param params - Discovery parameters
279
+ * @param isTrustedOrigin - Origin verification tester function
279
280
  * @returns Hydrated OIDC configuration ready for persistence
280
281
  * @throws DiscoveryError on any failure
281
282
  */
282
283
  async function discoverOIDCConfig(params) {
283
284
  const { issuer, existingConfig, timeout = DEFAULT_DISCOVERY_TIMEOUT } = params;
284
285
  const discoveryUrl = params.discoveryEndpoint || existingConfig?.discoveryEndpoint || computeDiscoveryUrl(issuer);
285
- validateDiscoveryUrl(discoveryUrl);
286
+ validateDiscoveryUrl(discoveryUrl, params.isTrustedOrigin);
286
287
  const discoveryDoc = await fetchDiscoveryDocument(discoveryUrl, timeout);
287
288
  validateDiscoveryDocument(discoveryDoc, issuer);
288
- const normalizedDoc = normalizeDiscoveryUrls(discoveryDoc, issuer);
289
+ const normalizedDoc = normalizeDiscoveryUrls(discoveryDoc, issuer, params.isTrustedOrigin);
289
290
  const tokenEndpointAuth = selectTokenEndpointAuthMethod(normalizedDoc, existingConfig?.tokenEndpointAuthentication);
290
291
  return {
291
292
  issuer: existingConfig?.issuer ?? normalizedDoc.issuer,
@@ -313,19 +314,12 @@ function computeDiscoveryUrl(issuer) {
313
314
  * Validate a discovery URL before fetching.
314
315
  *
315
316
  * @param url - The discovery URL to validate
317
+ * @param isTrustedOrigin - Origin verification tester function
316
318
  * @throws DiscoveryError if URL is invalid
317
319
  */
318
- function validateDiscoveryUrl(url) {
319
- try {
320
- const parsed = new URL(url);
321
- if (parsed.protocol !== "https:" && parsed.protocol !== "http:") throw new DiscoveryError("discovery_invalid_url", `Discovery URL must use HTTP or HTTPS protocol: ${url}`, {
322
- url,
323
- protocol: parsed.protocol
324
- });
325
- } catch (error) {
326
- if (error instanceof DiscoveryError) throw error;
327
- throw new DiscoveryError("discovery_invalid_url", `Invalid discovery URL: ${url}`, { url }, { cause: error });
328
- }
320
+ function validateDiscoveryUrl(url, isTrustedOrigin) {
321
+ const discoveryEndpoint = parseURL("discoveryEndpoint", url).toString();
322
+ if (!isTrustedOrigin(discoveryEndpoint)) throw new DiscoveryError("discovery_untrusted_origin", `The main discovery endpoint "${discoveryEndpoint}" is not trusted by your trusted origins configuration.`, { url: discoveryEndpoint });
329
323
  }
330
324
  /**
331
325
  * Fetch the OIDC discovery document from the IdP.
@@ -399,22 +393,76 @@ function validateDiscoveryDocument(doc, configuredIssuer) {
399
393
  /**
400
394
  * Normalize URLs in the discovery document.
401
395
  *
402
- * @param doc - The discovery document
403
- * @param _issuerBase - The base issuer URL
396
+ * @param document - The discovery document
397
+ * @param issuer - The base issuer URL
398
+ * @param isTrustedOrigin - Origin verification tester function
404
399
  * @returns The normalized discovery document
405
400
  */
406
- function normalizeDiscoveryUrls(doc, _issuerBase) {
401
+ function normalizeDiscoveryUrls(document, issuer, isTrustedOrigin) {
402
+ const doc = { ...document };
403
+ doc.token_endpoint = normalizeAndValidateUrl("token_endpoint", doc.token_endpoint, issuer, isTrustedOrigin);
404
+ doc.authorization_endpoint = normalizeAndValidateUrl("authorization_endpoint", doc.authorization_endpoint, issuer, isTrustedOrigin);
405
+ doc.jwks_uri = normalizeAndValidateUrl("jwks_uri", doc.jwks_uri, issuer, isTrustedOrigin);
406
+ if (doc.userinfo_endpoint) doc.userinfo_endpoint = normalizeAndValidateUrl("userinfo_endpoint", doc.userinfo_endpoint, issuer, isTrustedOrigin);
407
+ if (doc.revocation_endpoint) doc.revocation_endpoint = normalizeAndValidateUrl("revocation_endpoint", doc.revocation_endpoint, issuer, isTrustedOrigin);
408
+ if (doc.end_session_endpoint) doc.end_session_endpoint = normalizeAndValidateUrl("end_session_endpoint", doc.end_session_endpoint, issuer, isTrustedOrigin);
409
+ if (doc.introspection_endpoint) doc.introspection_endpoint = normalizeAndValidateUrl("introspection_endpoint", doc.introspection_endpoint, issuer, isTrustedOrigin);
407
410
  return doc;
408
411
  }
409
412
  /**
413
+ * Normalizes and validates a single URL endpoint
414
+ * @param name The url name
415
+ * @param endpoint The url to validate
416
+ * @param issuer The issuer base url
417
+ * @param isTrustedOrigin - Origin verification tester function
418
+ * @returns
419
+ */
420
+ function normalizeAndValidateUrl(name, endpoint, issuer, isTrustedOrigin) {
421
+ const url = normalizeUrl(name, endpoint, issuer);
422
+ if (!isTrustedOrigin(url)) throw new DiscoveryError("discovery_untrusted_origin", `The ${name} "${url}" is not trusted by your trusted origins configuration.`, {
423
+ endpoint: name,
424
+ url
425
+ });
426
+ return url;
427
+ }
428
+ /**
410
429
  * Normalize a single URL endpoint.
411
430
  *
431
+ * @param name - The endpoint name (e.g token_endpoint)
412
432
  * @param endpoint - The endpoint URL to normalize
413
- * @param _issuerBase - The base issuer URL
433
+ * @param issuer - The base issuer URL
414
434
  * @returns The normalized endpoint URL
415
435
  */
416
- function normalizeUrl(endpoint, _issuerBase) {
417
- return endpoint;
436
+ function normalizeUrl(name, endpoint, issuer) {
437
+ try {
438
+ return parseURL(name, endpoint).toString();
439
+ } catch {
440
+ const issuerURL = parseURL(name, issuer);
441
+ const basePath = issuerURL.pathname.replace(/\/+$/, "");
442
+ const endpointPath = endpoint.replace(/^\/+/, "");
443
+ return parseURL(name, basePath + "/" + endpointPath, issuerURL.origin).toString();
444
+ }
445
+ }
446
+ /**
447
+ * Parses the given URL or throws in case of invalid or unsupported protocols
448
+ *
449
+ * @param name the url name
450
+ * @param endpoint the endpoint url
451
+ * @param [base] optional base path
452
+ * @returns
453
+ */
454
+ function parseURL(name, endpoint, base) {
455
+ let endpointURL;
456
+ try {
457
+ endpointURL = new URL(endpoint, base);
458
+ if (endpointURL.protocol === "http:" || endpointURL.protocol === "https:") return endpointURL;
459
+ } catch (error) {
460
+ throw new DiscoveryError("discovery_invalid_url", `The url "${name}" must be valid: ${endpoint}`, { url: endpoint }, { cause: error });
461
+ }
462
+ throw new DiscoveryError("discovery_invalid_url", `The url "${name}" must use the http or https supported protocols: ${endpoint}`, {
463
+ url: endpoint,
464
+ protocol: endpointURL.protocol
465
+ });
418
466
  }
419
467
  /**
420
468
  * Select the token endpoint authentication method.
@@ -492,6 +540,10 @@ function mapDiscoveryErrorToAPIError(error) {
492
540
  message: `Invalid OIDC discovery URL: ${error.message}`,
493
541
  code: error.code
494
542
  });
543
+ case "discovery_untrusted_origin": return new APIError("BAD_REQUEST", {
544
+ message: `Untrusted OIDC discovery URL: ${error.message}`,
545
+ code: error.code
546
+ });
495
547
  case "discovery_invalid_json": return new APIError("BAD_REQUEST", {
496
548
  message: `OIDC discovery returned invalid data: ${error.message}`,
497
549
  code: error.code
@@ -918,7 +970,8 @@ const registerSSOProvider = (options) => {
918
970
  jwksEndpoint: body.oidcConfig.jwksEndpoint,
919
971
  userInfoEndpoint: body.oidcConfig.userInfoEndpoint,
920
972
  tokenEndpointAuthentication: body.oidcConfig.tokenEndpointAuthentication
921
- }
973
+ },
974
+ isTrustedOrigin: ctx.context.isTrustedOrigin
922
975
  });
923
976
  } catch (error) {
924
977
  if (error instanceof DiscoveryError) throw mapDiscoveryErrorToAPIError(error);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@better-auth/sso",
3
3
  "author": "Bereket Engida",
4
- "version": "1.4.7-beta.4",
4
+ "version": "1.4.7",
5
5
  "type": "module",
6
6
  "main": "dist/index.mjs",
7
7
  "types": "dist/index.d.mts",
@@ -66,10 +66,10 @@
66
66
  "express": "^5.1.0",
67
67
  "oauth2-mock-server": "^8.2.0",
68
68
  "tsdown": "^0.17.2",
69
- "better-auth": "1.4.7-beta.4"
69
+ "better-auth": "1.4.7"
70
70
  },
71
71
  "peerDependencies": {
72
- "better-auth": "1.4.7-beta.4"
72
+ "better-auth": "1.4.7"
73
73
  },
74
74
  "scripts": {
75
75
  "test": "vitest",
@@ -75,10 +75,13 @@ describe("OIDC Discovery", () => {
75
75
  });
76
76
 
77
77
  describe("validateDiscoveryUrl", () => {
78
+ const isTrustedOrigin = vi.fn().mockReturnValue(true);
79
+
78
80
  it("should accept valid HTTPS URL", () => {
79
81
  expect(() =>
80
82
  validateDiscoveryUrl(
81
83
  "https://idp.example.com/.well-known/openid-configuration",
84
+ isTrustedOrigin,
82
85
  ),
83
86
  ).not.toThrow();
84
87
  });
@@ -87,28 +90,31 @@ describe("OIDC Discovery", () => {
87
90
  expect(() =>
88
91
  validateDiscoveryUrl(
89
92
  "http://localhost:8080/.well-known/openid-configuration",
93
+ isTrustedOrigin,
90
94
  ),
91
95
  ).not.toThrow();
92
96
  });
93
97
 
94
98
  it("should reject invalid URL", () => {
95
- expect(() => validateDiscoveryUrl("not-a-url")).toThrow(DiscoveryError);
96
- expect(() => validateDiscoveryUrl("not-a-url")).toThrow(
97
- "Invalid discovery URL",
99
+ expect(() => validateDiscoveryUrl("not-a-url", isTrustedOrigin)).toThrow(
100
+ DiscoveryError,
101
+ );
102
+ expect(() => validateDiscoveryUrl("not-a-url", isTrustedOrigin)).toThrow(
103
+ 'The url "discoveryEndpoint" must be valid',
98
104
  );
99
105
  });
100
106
 
101
107
  it("should reject non-HTTP protocols", () => {
102
- expect(() => validateDiscoveryUrl("ftp://example.com/config")).toThrow(
103
- DiscoveryError,
104
- );
105
- expect(() => validateDiscoveryUrl("ftp://example.com/config")).toThrow(
106
- "must use HTTP or HTTPS",
107
- );
108
+ expect(() =>
109
+ validateDiscoveryUrl("ftp://example.com/config", isTrustedOrigin),
110
+ ).toThrow(DiscoveryError);
111
+ expect(() =>
112
+ validateDiscoveryUrl("ftp://example.com/config", isTrustedOrigin),
113
+ ).toThrow("must use the http or https supported protocols");
108
114
  });
109
115
 
110
116
  it("should throw DiscoveryError with discovery_invalid_url code for invalid URL", () => {
111
- expect(() => validateDiscoveryUrl("not-a-url")).toThrow(
117
+ expect(() => validateDiscoveryUrl("not-a-url", isTrustedOrigin)).toThrow(
112
118
  expect.objectContaining({
113
119
  code: "discovery_invalid_url",
114
120
  details: expect.objectContaining({
@@ -119,7 +125,9 @@ describe("OIDC Discovery", () => {
119
125
  });
120
126
 
121
127
  it("should throw DiscoveryError with discovery_invalid_url code for non-HTTP protocol", () => {
122
- expect(() => validateDiscoveryUrl("ftp://example.com/config")).toThrow(
128
+ expect(() =>
129
+ validateDiscoveryUrl("ftp://example.com/config", isTrustedOrigin),
130
+ ).toThrow(
123
131
  expect.objectContaining({
124
132
  code: "discovery_invalid_url",
125
133
  details: expect.objectContaining({
@@ -128,6 +136,22 @@ describe("OIDC Discovery", () => {
128
136
  }),
129
137
  );
130
138
  });
139
+
140
+ it("should throw DiscoveryError with discovery_untrusted_origin code for untrusted origins", () => {
141
+ isTrustedOrigin.mockReturnValue(false);
142
+
143
+ expect(() =>
144
+ validateDiscoveryUrl(
145
+ "https://untrusted.com/.well-known/openid-configuration",
146
+ isTrustedOrigin,
147
+ ),
148
+ ).toThrow(
149
+ expect.objectContaining({
150
+ code: "discovery_untrusted_origin",
151
+ message: `The main discovery endpoint "https://untrusted.com/.well-known/openid-configuration" is not trusted by your trusted origins configuration.`,
152
+ }),
153
+ );
154
+ });
131
155
  });
132
156
 
133
157
  describe("validateDiscoveryDocument", () => {
@@ -314,18 +338,265 @@ describe("OIDC Discovery", () => {
314
338
  });
315
339
  });
316
340
 
317
- describe("normalizeDiscoveryUrls (stub)", () => {
318
- it("should return document unchanged in Phase 1", () => {
341
+ describe("normalizeDiscoveryUrls", () => {
342
+ const isTrustedOrigin = vi.fn().mockReturnValue(true);
343
+
344
+ it("should return the document unchanged if all urls are already absolute", () => {
319
345
  const doc = createMockDiscoveryDocument();
320
- const result = normalizeDiscoveryUrls(doc, "https://idp.example.com");
346
+ const result = normalizeDiscoveryUrls(
347
+ doc,
348
+ "https://idp.example.com",
349
+ isTrustedOrigin,
350
+ );
321
351
  expect(result).toEqual(doc);
322
352
  });
353
+
354
+ it("should resolve all required discovery urls relative to the issuer", () => {
355
+ const expected = createMockDiscoveryDocument({
356
+ issuer: "https://idp.example.com",
357
+ authorization_endpoint: "https://idp.example.com/oauth2/authorize",
358
+ token_endpoint: "https://idp.example.com/oauth2/token",
359
+ jwks_uri: "https://idp.example.com/.well-known/jwks.json",
360
+ });
361
+ const doc = createMockDiscoveryDocument({
362
+ issuer: "https://idp.example.com",
363
+ authorization_endpoint: "/oauth2/authorize",
364
+ token_endpoint: "/oauth2/token",
365
+ jwks_uri: "/.well-known/jwks.json",
366
+ });
367
+ const result = normalizeDiscoveryUrls(
368
+ doc,
369
+ "https://idp.example.com",
370
+ isTrustedOrigin,
371
+ );
372
+ expect(result).toEqual(expected);
373
+ });
374
+
375
+ it("should resolve all discovery urls relative to the issuer", () => {
376
+ const expected = createMockDiscoveryDocument({
377
+ issuer: "https://idp.example.com",
378
+ authorization_endpoint: "https://idp.example.com/oauth2/authorize",
379
+ token_endpoint: "https://idp.example.com/oauth2/token",
380
+ jwks_uri: "https://idp.example.com/.well-known/jwks.json",
381
+ userinfo_endpoint: "https://idp.example.com/userinfo",
382
+ revocation_endpoint: "https://idp.example.com/revoke",
383
+ });
384
+ const doc = createMockDiscoveryDocument({
385
+ issuer: "https://idp.example.com",
386
+ authorization_endpoint: "/oauth2/authorize",
387
+ token_endpoint: "/oauth2/token",
388
+ jwks_uri: "/.well-known/jwks.json",
389
+ userinfo_endpoint: "/userinfo",
390
+ revocation_endpoint: "/revoke",
391
+ });
392
+ const result = normalizeDiscoveryUrls(
393
+ doc,
394
+ "https://idp.example.com",
395
+ isTrustedOrigin,
396
+ );
397
+ expect(result).toEqual(expected);
398
+ });
399
+
400
+ it("should reject on invalid discovery urls", () => {
401
+ const doc = createMockDiscoveryDocument({
402
+ authorization_endpoint: "/oauth2/authorize",
403
+ });
404
+ expect(() =>
405
+ normalizeDiscoveryUrls(doc, "not-url", isTrustedOrigin),
406
+ ).toThrowError('The url "authorization_endpoint" must be valid');
407
+ });
408
+
409
+ it("should reject with discovery_untrusted_origin code on untrusted discovery urls", () => {
410
+ const doc = createMockDiscoveryDocument({
411
+ authorization_endpoint: "/oauth2/authorize",
412
+ token_endpoint: "/oauth2/token",
413
+ jwks_uri: "/.well-known/jwks.json",
414
+ userinfo_endpoint: "/userinfo",
415
+ revocation_endpoint: "/revoke",
416
+ end_session_endpoint: "/endsession",
417
+ introspection_endpoint: "/introspection",
418
+ });
419
+
420
+ expect(() =>
421
+ normalizeDiscoveryUrls(
422
+ doc,
423
+ "https://idp.example.com",
424
+ (url) => !url.endsWith("/oauth2/token"),
425
+ ),
426
+ ).toThrowError(
427
+ expect.objectContaining({
428
+ code: "discovery_untrusted_origin",
429
+ message:
430
+ 'The token_endpoint "https://idp.example.com/oauth2/token" is not trusted by your trusted origins configuration.',
431
+ details: {
432
+ endpoint: "token_endpoint",
433
+ url: "https://idp.example.com/oauth2/token",
434
+ },
435
+ }),
436
+ );
437
+
438
+ expect(() =>
439
+ normalizeDiscoveryUrls(
440
+ doc,
441
+ "https://idp.example.com",
442
+ (url) => !url.endsWith("/oauth2/authorize"),
443
+ ),
444
+ ).toThrowError(
445
+ expect.objectContaining({
446
+ code: "discovery_untrusted_origin",
447
+ message:
448
+ 'The authorization_endpoint "https://idp.example.com/oauth2/authorize" is not trusted by your trusted origins configuration.',
449
+ details: {
450
+ endpoint: "authorization_endpoint",
451
+ url: "https://idp.example.com/oauth2/authorize",
452
+ },
453
+ }),
454
+ );
455
+
456
+ expect(() =>
457
+ normalizeDiscoveryUrls(
458
+ doc,
459
+ "https://idp.example.com",
460
+ (url) => !url.endsWith("/.well-known/jwks.json"),
461
+ ),
462
+ ).toThrowError(
463
+ expect.objectContaining({
464
+ code: "discovery_untrusted_origin",
465
+ message:
466
+ 'The jwks_uri "https://idp.example.com/.well-known/jwks.json" is not trusted by your trusted origins configuration.',
467
+ details: {
468
+ endpoint: "jwks_uri",
469
+ url: "https://idp.example.com/.well-known/jwks.json",
470
+ },
471
+ }),
472
+ );
473
+
474
+ expect(() =>
475
+ normalizeDiscoveryUrls(
476
+ doc,
477
+ "https://idp.example.com",
478
+ (url) => !url.endsWith("/userinfo"),
479
+ ),
480
+ ).toThrowError(
481
+ expect.objectContaining({
482
+ code: "discovery_untrusted_origin",
483
+ message:
484
+ 'The userinfo_endpoint "https://idp.example.com/userinfo" is not trusted by your trusted origins configuration.',
485
+ details: {
486
+ endpoint: "userinfo_endpoint",
487
+ url: "https://idp.example.com/userinfo",
488
+ },
489
+ }),
490
+ );
491
+
492
+ expect(() =>
493
+ normalizeDiscoveryUrls(
494
+ doc,
495
+ "https://idp.example.com",
496
+ (url) => !url.endsWith("/revoke"),
497
+ ),
498
+ ).toThrowError(
499
+ expect.objectContaining({
500
+ code: "discovery_untrusted_origin",
501
+ message:
502
+ 'The revocation_endpoint "https://idp.example.com/revoke" is not trusted by your trusted origins configuration.',
503
+ details: {
504
+ endpoint: "revocation_endpoint",
505
+ url: "https://idp.example.com/revoke",
506
+ },
507
+ }),
508
+ );
509
+
510
+ expect(() =>
511
+ normalizeDiscoveryUrls(
512
+ doc,
513
+ "https://idp.example.com",
514
+ (url) => !url.endsWith("/endsession"),
515
+ ),
516
+ ).toThrowError(
517
+ expect.objectContaining({
518
+ code: "discovery_untrusted_origin",
519
+ message:
520
+ 'The end_session_endpoint "https://idp.example.com/endsession" is not trusted by your trusted origins configuration.',
521
+ details: {
522
+ endpoint: "end_session_endpoint",
523
+ url: "https://idp.example.com/endsession",
524
+ },
525
+ }),
526
+ );
527
+
528
+ expect(() =>
529
+ normalizeDiscoveryUrls(
530
+ doc,
531
+ "https://idp.example.com",
532
+ (url) => !url.endsWith("/introspection"),
533
+ ),
534
+ ).toThrowError(
535
+ expect.objectContaining({
536
+ code: "discovery_untrusted_origin",
537
+ message:
538
+ 'The introspection_endpoint "https://idp.example.com/introspection" is not trusted by your trusted origins configuration.',
539
+ details: {
540
+ endpoint: "introspection_endpoint",
541
+ url: "https://idp.example.com/introspection",
542
+ },
543
+ }),
544
+ );
545
+ });
323
546
  });
324
547
 
325
- describe("normalizeUrl (stub)", () => {
326
- it("should return endpoint unchanged in Phase 1", () => {
548
+ describe("normalizeUrl", () => {
549
+ it("should return endpoint unchanged if already absolute", () => {
327
550
  const endpoint = "https://idp.example.com/oauth2/token";
328
- expect(normalizeUrl(endpoint, "https://idp.example.com")).toBe(endpoint);
551
+ expect(normalizeUrl("url", endpoint, "https://idp.example.com")).toBe(
552
+ endpoint,
553
+ );
554
+ });
555
+
556
+ it("should return endpoint as an absolute url", () => {
557
+ const endpoint = "/oauth2/token";
558
+ expect(normalizeUrl("url", endpoint, "https://idp.example.com")).toBe(
559
+ "https://idp.example.com/oauth2/token",
560
+ );
561
+ });
562
+
563
+ it.each([
564
+ [
565
+ "/oauth2/token",
566
+ "https://idp.example.com/base",
567
+ "endpoint with leading slash",
568
+ ],
569
+ [
570
+ "oauth2/token",
571
+ "https://idp.example.com/base",
572
+ "endpoint without leading slash",
573
+ ],
574
+ [
575
+ "/oauth2/token",
576
+ "https://idp.example.com/base/",
577
+ "issuer with trailing slash",
578
+ ],
579
+ ["//oauth2/token", "https://idp.example.com/base//", "multiple slashes"],
580
+ ])("should resolve relative endpoint preserving issuer base path (%s, %s) - %s", (endpoint, issuer) => {
581
+ expect(normalizeUrl("url", endpoint, issuer)).toBe(
582
+ "https://idp.example.com/base/oauth2/token",
583
+ );
584
+ });
585
+
586
+ it("should reject invalid endpoint urls", () => {
587
+ const endpoint = "oauth2/token";
588
+ const issuer = "not-a-url";
589
+ expect(() => normalizeUrl("url", endpoint, issuer)).toThrowError(
590
+ 'The url "url" must be valid',
591
+ );
592
+ });
593
+
594
+ it("should reject urls with unsupported protocols", () => {
595
+ const endpoint = "not-a-url";
596
+ const issuer = "ftp://idp.example.com";
597
+ expect(() => normalizeUrl("url", endpoint, issuer)).toThrowError(
598
+ 'The url "url" must use the http or https supported protocols',
599
+ );
329
600
  });
330
601
  });
331
602
 
@@ -521,6 +792,7 @@ describe("OIDC Discovery", () => {
521
792
  describe("discoverOIDCConfig (integration)", () => {
522
793
  const mockBetterFetch = betterFetch as ReturnType<typeof vi.fn>;
523
794
  const issuer = "https://idp.example.com";
795
+ const isTrustedOrigin = vi.fn().mockReturnValue(true);
524
796
 
525
797
  beforeEach(() => {
526
798
  vi.clearAllMocks();
@@ -539,7 +811,7 @@ describe("OIDC Discovery", () => {
539
811
  error: null,
540
812
  });
541
813
 
542
- const result = await discoverOIDCConfig({ issuer });
814
+ const result = await discoverOIDCConfig({ issuer, isTrustedOrigin });
543
815
 
544
816
  expect(result.issuer).toBe(issuer);
545
817
  expect(result.authorizationEndpoint).toBe(`${issuer}/oauth2/authorize`);
@@ -570,6 +842,7 @@ describe("OIDC Discovery", () => {
570
842
  tokenEndpoint: "https://custom.example.com/token",
571
843
  tokenEndpointAuthentication: "client_secret_post",
572
844
  },
845
+ isTrustedOrigin,
573
846
  });
574
847
 
575
848
  expect(result.tokenEndpoint).toBe("https://custom.example.com/token");
@@ -589,6 +862,7 @@ describe("OIDC Discovery", () => {
589
862
  const result = await discoverOIDCConfig({
590
863
  issuer,
591
864
  discoveryEndpoint: customEndpoint,
865
+ isTrustedOrigin,
592
866
  });
593
867
 
594
868
  expect(result.discoveryEndpoint).toBe(customEndpoint);
@@ -610,6 +884,7 @@ describe("OIDC Discovery", () => {
610
884
  existingConfig: {
611
885
  discoveryEndpoint: existingEndpoint,
612
886
  },
887
+ isTrustedOrigin,
613
888
  });
614
889
 
615
890
  expect(result.discoveryEndpoint).toBe(existingEndpoint);
@@ -627,7 +902,9 @@ describe("OIDC Discovery", () => {
627
902
  error: null,
628
903
  });
629
904
 
630
- await expect(discoverOIDCConfig({ issuer })).rejects.toThrow(
905
+ await expect(
906
+ discoverOIDCConfig({ issuer, isTrustedOrigin }),
907
+ ).rejects.toThrow(
631
908
  expect.objectContaining({
632
909
  code: "issuer_mismatch",
633
910
  }),
@@ -643,7 +920,9 @@ describe("OIDC Discovery", () => {
643
920
  error: null,
644
921
  });
645
922
 
646
- await expect(discoverOIDCConfig({ issuer })).rejects.toThrow(
923
+ await expect(
924
+ discoverOIDCConfig({ issuer, isTrustedOrigin }),
925
+ ).rejects.toThrow(
647
926
  expect.objectContaining({
648
927
  code: "discovery_incomplete",
649
928
  }),
@@ -656,7 +935,9 @@ describe("OIDC Discovery", () => {
656
935
  error: { status: 404, message: "Not Found" },
657
936
  });
658
937
 
659
- await expect(discoverOIDCConfig({ issuer })).rejects.toThrow(
938
+ await expect(
939
+ discoverOIDCConfig({ issuer, isTrustedOrigin }),
940
+ ).rejects.toThrow(
660
941
  expect.objectContaining({
661
942
  code: "discovery_not_found",
662
943
  }),
@@ -673,7 +954,7 @@ describe("OIDC Discovery", () => {
673
954
  error: null,
674
955
  });
675
956
 
676
- const result = await discoverOIDCConfig({ issuer });
957
+ const result = await discoverOIDCConfig({ issuer, isTrustedOrigin });
677
958
 
678
959
  expect(result.scopesSupported).toEqual(scopes);
679
960
  });
@@ -689,7 +970,7 @@ describe("OIDC Discovery", () => {
689
970
  error: null,
690
971
  });
691
972
 
692
- const result = await discoverOIDCConfig({ issuer });
973
+ const result = await discoverOIDCConfig({ issuer, isTrustedOrigin });
693
974
 
694
975
  expect(result.issuer).toBe(issuer);
695
976
  expect(result.authorizationEndpoint).toBe(`${issuer}/authorize`);
@@ -720,6 +1001,7 @@ describe("OIDC Discovery", () => {
720
1001
  tokenEndpointAuthentication: "client_secret_post",
721
1002
  scopesSupported: ["openid", "profile"],
722
1003
  },
1004
+ isTrustedOrigin,
723
1005
  });
724
1006
 
725
1007
  expect(result.issuer).toBe(issuer);
@@ -750,7 +1032,7 @@ describe("OIDC Discovery", () => {
750
1032
  error: null,
751
1033
  });
752
1034
 
753
- const result = await discoverOIDCConfig({ issuer });
1035
+ const result = await discoverOIDCConfig({ issuer, isTrustedOrigin });
754
1036
  expect(result.tokenEndpointAuthentication).toBe("client_secret_basic");
755
1037
  });
756
1038
 
@@ -774,6 +1056,7 @@ describe("OIDC Discovery", () => {
774
1056
  // Only jwksEndpoint is set (simulating a legacy/partial config)
775
1057
  jwksEndpoint: "https://custom.example.com/jwks",
776
1058
  },
1059
+ isTrustedOrigin,
777
1060
  });
778
1061
 
779
1062
  // Existing value should be preserved
@@ -804,7 +1087,7 @@ describe("OIDC Discovery", () => {
804
1087
  error: null,
805
1088
  });
806
1089
 
807
- const result = await discoverOIDCConfig({ issuer });
1090
+ const result = await discoverOIDCConfig({ issuer, isTrustedOrigin });
808
1091
 
809
1092
  // Should successfully extract required fields
810
1093
  expect(result.issuer).toBe(issuer);
@@ -819,5 +1102,56 @@ describe("OIDC Discovery", () => {
819
1102
  // Should default auth method when not specified
820
1103
  expect(result.tokenEndpointAuthentication).toBe("client_secret_basic");
821
1104
  });
1105
+
1106
+ it("should throw an error with discovery_untrusted_origin code when the main discovery url is untrusted", async () => {
1107
+ isTrustedOrigin.mockReturnValue(false);
1108
+
1109
+ await expect(
1110
+ discoverOIDCConfig({ issuer, isTrustedOrigin }),
1111
+ ).rejects.toThrow(
1112
+ expect.objectContaining({
1113
+ name: "DiscoveryError",
1114
+ message:
1115
+ 'The main discovery endpoint "https://idp.example.com/.well-known/openid-configuration" is not trusted by your trusted origins configuration.',
1116
+ code: "discovery_untrusted_origin",
1117
+ details: {
1118
+ url: "https://idp.example.com/.well-known/openid-configuration",
1119
+ },
1120
+ }),
1121
+ );
1122
+ });
1123
+
1124
+ it("should throw an error with discovery_untrusted_origin code when discovered urls are untrusted", async () => {
1125
+ isTrustedOrigin.mockImplementation((url: string) => {
1126
+ return url.endsWith(".well-known/openid-configuration");
1127
+ });
1128
+
1129
+ const discoveryDoc = createMockDiscoveryDocument({
1130
+ issuer,
1131
+ authorization_endpoint: `${issuer}/oauth2/authorize`,
1132
+ token_endpoint: `${issuer}/oauth2/token`,
1133
+ jwks_uri: `${issuer}/.well-known/jwks.json`,
1134
+ userinfo_endpoint: `${issuer}/userinfo`,
1135
+ });
1136
+ mockBetterFetch.mockResolvedValueOnce({
1137
+ data: discoveryDoc,
1138
+ error: null,
1139
+ });
1140
+
1141
+ await expect(
1142
+ discoverOIDCConfig({ issuer, isTrustedOrigin }),
1143
+ ).rejects.toThrow(
1144
+ expect.objectContaining({
1145
+ name: "DiscoveryError",
1146
+ message:
1147
+ 'The token_endpoint "https://idp.example.com/oauth2/token" is not trusted by your trusted origins configuration.',
1148
+ code: "discovery_untrusted_origin",
1149
+ details: {
1150
+ endpoint: "token_endpoint",
1151
+ url: "https://idp.example.com/oauth2/token",
1152
+ },
1153
+ }),
1154
+ );
1155
+ });
822
1156
  });
823
1157
  });
@@ -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
 
package/src/routes/sso.ts CHANGED
@@ -681,6 +681,7 @@ export const registerSSOProvider = <O extends SSOOptions>(options: O) => {
681
681
  tokenEndpointAuthentication:
682
682
  body.oidcConfig.tokenEndpointAuthentication,
683
683
  },
684
+ isTrustedOrigin: ctx.context.isTrustedOrigin,
684
685
  });
685
686
  } catch (error) {
686
687
  if (error instanceof DiscoveryError) {
package/src/saml.test.ts CHANGED
@@ -1941,6 +1941,7 @@ describe("SSO Provider Config Parsing", () => {
1941
1941
 
1942
1942
  const auth = betterAuth({
1943
1943
  database: memory,
1944
+ trustedOrigins: ["http://localhost:8082"],
1944
1945
  baseURL: "http://localhost:3000",
1945
1946
  emailAndPassword: { enabled: true },
1946
1947
  plugins: [sso()],
package/src/types.ts CHANGED
@@ -233,13 +233,7 @@ export interface SSOOptions {
233
233
  *
234
234
  * If you want to allow account linking for specific trusted providers, enable the `accountLinking` option in your auth config and specify those
235
235
  * providers in the `trustedProviders` list.
236
- *
237
236
  * @default false
238
- *
239
- * @deprecated This option is discouraged for new projects. Relying on provider-level `email_verified` is a weaker
240
- * trust signal compared to using `trustedProviders` in `accountLinking` or enabling `domainVerification` for SSO.
241
- * Existing configurations will continue to work, but new integrations should use explicit trust mechanisms.
242
- * This option may be removed in a future major version.
243
237
  */
244
238
  trustEmailVerified?: boolean | undefined;
245
239
  /**