@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.
- package/.turbo/turbo-build.log +6 -6
- package/dist/client.d.mts +1 -1
- package/dist/{index-GoyGoP_a.d.mts → index-B9WMxRdD.d.mts} +19 -14
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +75 -22
- package/package.json +3 -3
- package/src/oidc/discovery.test.ts +359 -25
- package/src/oidc/discovery.ts +168 -29
- package/src/oidc/errors.ts +6 -0
- package/src/oidc/types.ts +9 -0
- package/src/oidc.test.ts +3 -0
- package/src/routes/sso.ts +1 -0
- package/src/saml.test.ts +1 -0
- package/src/types.ts +0 -6
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
> @better-auth/sso@1.4.7
|
|
2
|
+
> @better-auth/sso@1.4.7 build /home/runner/work/better-auth/better-auth/packages/sso
|
|
3
3
|
> tsdown
|
|
4
4
|
|
|
5
5
|
[34mℹ[39m tsdown [2mv0.17.2[22m powered by rolldown [2mv1.0.0-beta.53[22m
|
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
[34mℹ[39m entry: [34msrc/index.ts, src/client.ts[39m
|
|
8
8
|
[34mℹ[39m tsconfig: [34mtsconfig.json[39m
|
|
9
9
|
[34mℹ[39m Build start
|
|
10
|
-
[34mℹ[39m [2mdist/[22m[1mindex.mjs[22m [
|
|
10
|
+
[34mℹ[39m [2mdist/[22m[1mindex.mjs[22m [2m83.77 kB[22m [2m│ gzip: 15.84 kB[22m
|
|
11
11
|
[34mℹ[39m [2mdist/[22m[1mclient.mjs[22m [2m 0.15 kB[22m [2m│ gzip: 0.14 kB[22m
|
|
12
12
|
[34mℹ[39m [2mdist/[22m[32m[1mindex.d.mts[22m[39m [2m 1.44 kB[22m [2m│ gzip: 0.52 kB[22m
|
|
13
|
-
[34mℹ[39m [2mdist/[22m[32m[1mclient.d.mts[22m[39m [2m 0.49 kB[22m [2m│ gzip: 0.
|
|
14
|
-
[34mℹ[39m [2mdist/[22m[32mindex-
|
|
15
|
-
[34mℹ[39m 5 files, total:
|
|
16
|
-
[32m✔[39m Build complete in [
|
|
13
|
+
[34mℹ[39m [2mdist/[22m[32m[1mclient.d.mts[22m[39m [2m 0.49 kB[22m [2m│ gzip: 0.30 kB[22m
|
|
14
|
+
[34mℹ[39m [2mdist/[22m[32mindex-B9WMxRdD.d.mts[39m [2m41.59 kB[22m [2m│ gzip: 8.59 kB[22m
|
|
15
|
+
[34mℹ[39m 5 files, total: 127.44 kB
|
|
16
|
+
[32m✔[39m Build complete in [32m12101ms[39m
|
package/dist/client.d.mts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
1156
|
-
* @param
|
|
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(
|
|
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
|
|
1169
|
+
* @param issuer - The base issuer URL
|
|
1165
1170
|
* @returns The normalized endpoint URL
|
|
1166
1171
|
*/
|
|
1167
|
-
declare function normalizeUrl(endpoint: 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-
|
|
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
|
|
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
|
|
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
|
-
|
|
320
|
-
|
|
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
|
|
403
|
-
* @param
|
|
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(
|
|
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
|
|
433
|
+
* @param issuer - The base issuer URL
|
|
414
434
|
* @returns The normalized endpoint URL
|
|
415
435
|
*/
|
|
416
|
-
function normalizeUrl(endpoint,
|
|
417
|
-
|
|
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
|
|
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
|
|
69
|
+
"better-auth": "1.4.7"
|
|
70
70
|
},
|
|
71
71
|
"peerDependencies": {
|
|
72
|
-
"better-auth": "1.4.7
|
|
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(
|
|
96
|
-
|
|
97
|
-
|
|
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(() =>
|
|
103
|
-
|
|
104
|
-
);
|
|
105
|
-
expect(() =>
|
|
106
|
-
"
|
|
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(() =>
|
|
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
|
|
318
|
-
|
|
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(
|
|
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
|
|
326
|
-
it("should return endpoint unchanged
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
});
|
package/src/oidc/discovery.ts
CHANGED
|
@@ -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
|
|
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
|
|
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(
|
|
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(
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
"
|
|
120
|
-
`
|
|
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
|
|
280
|
-
* @param
|
|
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
|
-
|
|
285
|
-
|
|
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
|
|
380
|
+
* @param issuer - The base issuer URL
|
|
295
381
|
* @returns The normalized endpoint URL
|
|
296
382
|
*/
|
|
297
|
-
export function normalizeUrl(
|
|
298
|
-
|
|
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
|
/**
|
package/src/oidc/errors.ts
CHANGED
|
@@ -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
|
/**
|