@better-auth/sso 1.4.18 → 1.4.20
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 +7 -7
- package/dist/client.d.mts +1 -1
- package/dist/{index-C4nbdf2g.d.mts → index-D-VInsst.d.mts} +7 -3
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +97 -62
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -3
- package/src/domain-verification.test.ts +46 -4
- package/src/linking/org-assignment.ts +2 -2
- package/src/oidc.test.ts +1 -3
- package/src/routes/domain-verification.ts +34 -12
- package/src/routes/sso.ts +131 -93
- package/src/saml-state.ts +1 -1
- package/src/saml.test.ts +392 -0
- package/src/types.ts +6 -2
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
> @better-auth/sso@1.4.
|
|
2
|
+
> @better-auth/sso@1.4.20 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,12 +7,12 @@
|
|
|
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 [2m120.
|
|
10
|
+
[34mℹ[39m [2mdist/[22m[1mindex.mjs[22m [2m120.93 kB[22m [2m│ gzip: 24.08 kB[22m
|
|
11
11
|
[34mℹ[39m [2mdist/[22m[1mclient.mjs[22m [2m 0.28 kB[22m [2m│ gzip: 0.21 kB[22m
|
|
12
|
-
[34mℹ[39m [2mdist/[22mindex.mjs.map [
|
|
12
|
+
[34mℹ[39m [2mdist/[22mindex.mjs.map [2m246.56 kB[22m [2m│ gzip: 47.33 kB[22m
|
|
13
13
|
[34mℹ[39m [2mdist/[22mclient.mjs.map [2m 0.94 kB[22m [2m│ gzip: 0.50 kB[22m
|
|
14
14
|
[34mℹ[39m [2mdist/[22m[32m[1mindex.d.mts[22m[39m [2m 1.67 kB[22m [2m│ gzip: 0.57 kB[22m
|
|
15
|
-
[34mℹ[39m [2mdist/[22m[32m[1mclient.d.mts[22m[39m [2m 0.62 kB[22m [2m│ gzip: 0.
|
|
16
|
-
[34mℹ[39m [2mdist/[22m[32mindex-
|
|
17
|
-
[34mℹ[39m 7 files, total:
|
|
18
|
-
[32m✔[39m Build complete in [
|
|
15
|
+
[34mℹ[39m [2mdist/[22m[32m[1mclient.d.mts[22m[39m [2m 0.62 kB[22m [2m│ gzip: 0.35 kB[22m
|
|
16
|
+
[34mℹ[39m [2mdist/[22m[32mindex-D-VInsst.d.mts[39m [2m 55.92 kB[22m [2m│ gzip: 9.95 kB[22m
|
|
17
|
+
[34mℹ[39m 7 files, total: 426.92 kB
|
|
18
|
+
[32m✔[39m Build complete in [32m19437ms[39m
|
package/dist/client.d.mts
CHANGED
|
@@ -109,6 +109,7 @@ interface SAMLConfig {
|
|
|
109
109
|
encPrivateKeyPass?: string | undefined;
|
|
110
110
|
};
|
|
111
111
|
wantAssertionsSigned?: boolean | undefined;
|
|
112
|
+
authnRequestsSigned?: boolean | undefined;
|
|
112
113
|
signatureAlgorithm?: string | undefined;
|
|
113
114
|
digestAlgorithm?: string | undefined;
|
|
114
115
|
identifierFormat?: string | undefined;
|
|
@@ -278,9 +279,12 @@ interface SSOOptions {
|
|
|
278
279
|
*/
|
|
279
280
|
enabled?: boolean;
|
|
280
281
|
/**
|
|
281
|
-
* Prefix used to generate the domain verification token
|
|
282
|
+
* Prefix used to generate the domain verification token.
|
|
283
|
+
* An underscore is automatically prepended to follow DNS
|
|
284
|
+
* infrastructure subdomain conventions (RFC 8552), so do
|
|
285
|
+
* not include a leading underscore.
|
|
282
286
|
*
|
|
283
|
-
* @default "better-auth-token
|
|
287
|
+
* @default "better-auth-token"
|
|
284
288
|
*/
|
|
285
289
|
tokenPrefix?: string;
|
|
286
290
|
};
|
|
@@ -1656,4 +1660,4 @@ declare function sso<O extends SSOOptions>(options?: O | undefined): {
|
|
|
1656
1660
|
};
|
|
1657
1661
|
//#endregion
|
|
1658
1662
|
export { DataEncryptionAlgorithm as A, TimestampValidationOptions as C, SSOOptions as D, SAMLConfig as E, DigestAlgorithm as M, KeyEncryptionAlgorithm as N, SSOProvider as O, SignatureAlgorithm as P, SAMLConditions as S, OIDCConfig as T, REQUIRED_DISCOVERY_FIELDS as _, fetchDiscoveryDocument as a, DEFAULT_MAX_SAML_METADATA_SIZE as b, normalizeUrl as c, validateDiscoveryUrl as d, DiscoverOIDCConfigParams as f, OIDCDiscoveryDocument as g, HydratedOIDCConfig as h, discoverOIDCConfig as i, DeprecatedAlgorithmBehavior as j, AlgorithmValidationOptions as k, selectTokenEndpointAuthMethod as l, DiscoveryErrorCode as m, sso as n, needsRuntimeDiscovery as o, DiscoveryError as p, computeDiscoveryUrl as r, normalizeDiscoveryUrls as s, SSOPlugin as t, validateDiscoveryDocument as u, RequiredDiscoveryField as v, validateSAMLTimestamp as w, DEFAULT_MAX_SAML_RESPONSE_SIZE as x, DEFAULT_CLOCK_SKEW_MS as y };
|
|
1659
|
-
//# sourceMappingURL=index-
|
|
1663
|
+
//# sourceMappingURL=index-D-VInsst.d.mts.map
|
package/dist/index.d.mts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { A as DataEncryptionAlgorithm, C as TimestampValidationOptions, D as SSOOptions, E as SAMLConfig, M as DigestAlgorithm, N as KeyEncryptionAlgorithm, O as SSOProvider, P as SignatureAlgorithm, S as SAMLConditions, T as OIDCConfig, _ as REQUIRED_DISCOVERY_FIELDS, a as fetchDiscoveryDocument, b as DEFAULT_MAX_SAML_METADATA_SIZE, c as normalizeUrl, d as validateDiscoveryUrl, f as DiscoverOIDCConfigParams, g as OIDCDiscoveryDocument, h as HydratedOIDCConfig, i as discoverOIDCConfig, j as DeprecatedAlgorithmBehavior, k as AlgorithmValidationOptions, 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 validateSAMLTimestamp, x as DEFAULT_MAX_SAML_RESPONSE_SIZE, y as DEFAULT_CLOCK_SKEW_MS } from "./index-
|
|
1
|
+
import { A as DataEncryptionAlgorithm, C as TimestampValidationOptions, D as SSOOptions, E as SAMLConfig, M as DigestAlgorithm, N as KeyEncryptionAlgorithm, O as SSOProvider, P as SignatureAlgorithm, S as SAMLConditions, T as OIDCConfig, _ as REQUIRED_DISCOVERY_FIELDS, a as fetchDiscoveryDocument, b as DEFAULT_MAX_SAML_METADATA_SIZE, c as normalizeUrl, d as validateDiscoveryUrl, f as DiscoverOIDCConfigParams, g as OIDCDiscoveryDocument, h as HydratedOIDCConfig, i as discoverOIDCConfig, j as DeprecatedAlgorithmBehavior, k as AlgorithmValidationOptions, 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 validateSAMLTimestamp, x as DEFAULT_MAX_SAML_RESPONSE_SIZE, y as DEFAULT_CLOCK_SKEW_MS } from "./index-D-VInsst.mjs";
|
|
2
2
|
export { AlgorithmValidationOptions, DEFAULT_CLOCK_SKEW_MS, DEFAULT_MAX_SAML_METADATA_SIZE, DEFAULT_MAX_SAML_RESPONSE_SIZE, DataEncryptionAlgorithm, DeprecatedAlgorithmBehavior, DigestAlgorithm, DiscoverOIDCConfigParams, DiscoveryError, DiscoveryErrorCode, HydratedOIDCConfig, KeyEncryptionAlgorithm, OIDCConfig, OIDCDiscoveryDocument, REQUIRED_DISCOVERY_FIELDS, RequiredDiscoveryField, SAMLConditions, SAMLConfig, SSOOptions, SSOPlugin, SSOProvider, SignatureAlgorithm, TimestampValidationOptions, computeDiscoveryUrl, discoverOIDCConfig, fetchDiscoveryDocument, needsRuntimeDiscovery, normalizeDiscoveryUrls, normalizeUrl, selectTokenEndpointAuthMethod, sso, validateDiscoveryDocument, validateDiscoveryUrl, validateSAMLTimestamp };
|
package/dist/index.mjs
CHANGED
|
@@ -11,7 +11,6 @@ import { HIDE_METADATA, createAuthorizationURL, generateGenericState, generateSt
|
|
|
11
11
|
import { setSessionCookie } from "better-auth/cookies";
|
|
12
12
|
import { handleOAuthUserInfo } from "better-auth/oauth2";
|
|
13
13
|
import { decodeJwt } from "jose";
|
|
14
|
-
import { APIError as APIError$1 } from "better-call";
|
|
15
14
|
|
|
16
15
|
//#region src/utils.ts
|
|
17
16
|
/**
|
|
@@ -162,7 +161,12 @@ async function assignOrganizationByDomain(ctx, options) {
|
|
|
162
161
|
|
|
163
162
|
//#endregion
|
|
164
163
|
//#region src/routes/domain-verification.ts
|
|
164
|
+
const DNS_LABEL_MAX_LENGTH = 63;
|
|
165
|
+
const DEFAULT_TOKEN_PREFIX = "better-auth-token";
|
|
165
166
|
const domainVerificationBodySchema = z$1.object({ providerId: z$1.string() });
|
|
167
|
+
function getVerificationIdentifier(options, providerId) {
|
|
168
|
+
return `_${options.domainVerification?.tokenPrefix || DEFAULT_TOKEN_PREFIX}-${providerId}`;
|
|
169
|
+
}
|
|
166
170
|
const requestDomainVerification = (options) => {
|
|
167
171
|
return createAuthEndpoint("/sso/request-domain-verification", {
|
|
168
172
|
method: "POST",
|
|
@@ -210,11 +214,12 @@ const requestDomainVerification = (options) => {
|
|
|
210
214
|
message: "Domain has already been verified",
|
|
211
215
|
code: "DOMAIN_VERIFIED"
|
|
212
216
|
});
|
|
217
|
+
const identifier = getVerificationIdentifier(options, provider.providerId);
|
|
213
218
|
const activeVerification = await ctx.context.adapter.findOne({
|
|
214
219
|
model: "verification",
|
|
215
220
|
where: [{
|
|
216
221
|
field: "identifier",
|
|
217
|
-
value:
|
|
222
|
+
value: identifier
|
|
218
223
|
}, {
|
|
219
224
|
field: "expiresAt",
|
|
220
225
|
value: /* @__PURE__ */ new Date(),
|
|
@@ -229,7 +234,7 @@ const requestDomainVerification = (options) => {
|
|
|
229
234
|
await ctx.context.adapter.create({
|
|
230
235
|
model: "verification",
|
|
231
236
|
data: {
|
|
232
|
-
identifier
|
|
237
|
+
identifier,
|
|
233
238
|
createdAt: /* @__PURE__ */ new Date(),
|
|
234
239
|
updatedAt: /* @__PURE__ */ new Date(),
|
|
235
240
|
value: domainVerificationToken,
|
|
@@ -288,11 +293,16 @@ const verifyDomain = (options) => {
|
|
|
288
293
|
message: "Domain has already been verified",
|
|
289
294
|
code: "DOMAIN_VERIFIED"
|
|
290
295
|
});
|
|
296
|
+
const identifier = getVerificationIdentifier(options, provider.providerId);
|
|
297
|
+
if (identifier.length > DNS_LABEL_MAX_LENGTH) throw new APIError("BAD_REQUEST", {
|
|
298
|
+
message: `Verification identifier exceeds the DNS label limit of ${DNS_LABEL_MAX_LENGTH} characters`,
|
|
299
|
+
code: "IDENTIFIER_TOO_LONG"
|
|
300
|
+
});
|
|
291
301
|
const activeVerification = await ctx.context.adapter.findOne({
|
|
292
302
|
model: "verification",
|
|
293
303
|
where: [{
|
|
294
304
|
field: "identifier",
|
|
295
|
-
value:
|
|
305
|
+
value: identifier
|
|
296
306
|
}, {
|
|
297
307
|
field: "expiresAt",
|
|
298
308
|
value: /* @__PURE__ */ new Date(),
|
|
@@ -315,7 +325,8 @@ const verifyDomain = (options) => {
|
|
|
315
325
|
});
|
|
316
326
|
}
|
|
317
327
|
try {
|
|
318
|
-
|
|
328
|
+
const hostname = new URL(provider.domain).hostname;
|
|
329
|
+
records = (await dns.resolveTxt(`${identifier}.${hostname}`)).flat();
|
|
319
330
|
} catch (error) {
|
|
320
331
|
ctx.context.logger.warn("DNS resolution failure while validating domain ownership", error);
|
|
321
332
|
}
|
|
@@ -1357,7 +1368,7 @@ function mapDiscoveryErrorToAPIError(error) {
|
|
|
1357
1368
|
//#region src/saml-state.ts
|
|
1358
1369
|
async function generateRelayState(c, link, additionalData) {
|
|
1359
1370
|
const callbackURL = c.body.callbackURL;
|
|
1360
|
-
if (!callbackURL) throw new APIError
|
|
1371
|
+
if (!callbackURL) throw new APIError("BAD_REQUEST", { message: "callbackURL is required" });
|
|
1361
1372
|
const codeVerifier = generateRandomString(128);
|
|
1362
1373
|
const stateData = {
|
|
1363
1374
|
...additionalData ? additionalData : {},
|
|
@@ -1373,7 +1384,7 @@ async function generateRelayState(c, link, additionalData) {
|
|
|
1373
1384
|
return generateGenericState(c, stateData, { cookieName: "relay_state" });
|
|
1374
1385
|
} catch (error) {
|
|
1375
1386
|
c.context.logger.error("Failed to create verification for relay state", error);
|
|
1376
|
-
throw new APIError
|
|
1387
|
+
throw new APIError("INTERNAL_SERVER_ERROR", {
|
|
1377
1388
|
message: "State error: Unable to create verification for relay state",
|
|
1378
1389
|
cause: error
|
|
1379
1390
|
});
|
|
@@ -1387,7 +1398,7 @@ async function parseRelayState(c) {
|
|
|
1387
1398
|
parsedData = await parseGenericState(c, state, { cookieName: "relay_state" });
|
|
1388
1399
|
} catch (error) {
|
|
1389
1400
|
c.context.logger.error("Failed to parse relay state", error);
|
|
1390
|
-
throw new APIError
|
|
1401
|
+
throw new APIError("BAD_REQUEST", {
|
|
1391
1402
|
message: "State error: failed to validate relay state",
|
|
1392
1403
|
cause: error
|
|
1393
1404
|
});
|
|
@@ -1489,6 +1500,7 @@ const spMetadata = () => {
|
|
|
1489
1500
|
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
1490
1501
|
Location: parsedSamlConfig.callbackUrl || `${ctx.context.baseURL}/sso/saml2/sp/acs/${provider.id}`
|
|
1491
1502
|
}],
|
|
1503
|
+
authnRequestsSigned: parsedSamlConfig.authnRequestsSigned || false,
|
|
1492
1504
|
wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
|
|
1493
1505
|
nameIDFormat: parsedSamlConfig.identifierFormat ? [parsedSamlConfig.identifierFormat] : void 0
|
|
1494
1506
|
});
|
|
@@ -1873,7 +1885,7 @@ const registerSSOProvider = (options) => {
|
|
|
1873
1885
|
await ctx.context.adapter.create({
|
|
1874
1886
|
model: "verification",
|
|
1875
1887
|
data: {
|
|
1876
|
-
identifier: options
|
|
1888
|
+
identifier: getVerificationIdentifier(options, provider.providerId),
|
|
1877
1889
|
createdAt: /* @__PURE__ */ new Date(),
|
|
1878
1890
|
updatedAt: /* @__PURE__ */ new Date(),
|
|
1879
1891
|
value: domainVerificationToken,
|
|
@@ -2072,18 +2084,37 @@ const signInSSO = (options) => {
|
|
|
2072
2084
|
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
2073
2085
|
Location: parsedSamlConfig.callbackUrl || `${ctx.context.baseURL}/sso/saml2/sp/acs/${provider.providerId}`
|
|
2074
2086
|
}],
|
|
2087
|
+
authnRequestsSigned: parsedSamlConfig.authnRequestsSigned || false,
|
|
2075
2088
|
wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
|
|
2076
2089
|
nameIDFormat: parsedSamlConfig.identifierFormat ? [parsedSamlConfig.identifierFormat] : void 0
|
|
2077
2090
|
}).getMetadata() || "";
|
|
2078
2091
|
const sp = saml.ServiceProvider({
|
|
2079
2092
|
metadata,
|
|
2093
|
+
privateKey: parsedSamlConfig.spMetadata?.privateKey || parsedSamlConfig.privateKey,
|
|
2094
|
+
privateKeyPass: parsedSamlConfig.spMetadata?.privateKeyPass,
|
|
2080
2095
|
allowCreate: true
|
|
2081
2096
|
});
|
|
2082
|
-
const
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
singleSignOnService:
|
|
2097
|
+
const idpData = parsedSamlConfig.idpMetadata;
|
|
2098
|
+
let idp;
|
|
2099
|
+
if (!idpData?.metadata) idp = saml.IdentityProvider({
|
|
2100
|
+
entityID: idpData?.entityID || parsedSamlConfig.issuer,
|
|
2101
|
+
singleSignOnService: idpData?.singleSignOnService || [{
|
|
2102
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
2103
|
+
Location: parsedSamlConfig.entryPoint
|
|
2104
|
+
}],
|
|
2105
|
+
signingCert: idpData?.cert || parsedSamlConfig.cert,
|
|
2106
|
+
wantAuthnRequestsSigned: parsedSamlConfig.authnRequestsSigned || false,
|
|
2107
|
+
isAssertionEncrypted: idpData?.isAssertionEncrypted || false,
|
|
2108
|
+
encPrivateKey: idpData?.encPrivateKey,
|
|
2109
|
+
encPrivateKeyPass: idpData?.encPrivateKeyPass
|
|
2110
|
+
});
|
|
2111
|
+
else idp = saml.IdentityProvider({
|
|
2112
|
+
metadata: idpData.metadata,
|
|
2113
|
+
privateKey: idpData.privateKey,
|
|
2114
|
+
privateKeyPass: idpData.privateKeyPass,
|
|
2115
|
+
isAssertionEncrypted: idpData.isAssertionEncrypted,
|
|
2116
|
+
encPrivateKey: idpData.encPrivateKey,
|
|
2117
|
+
encPrivateKeyPass: idpData.encPrivateKeyPass
|
|
2087
2118
|
});
|
|
2088
2119
|
const loginRequest = sp.createLoginRequest(idp, "redirect");
|
|
2089
2120
|
if (!loginRequest) throw new APIError("BAD_REQUEST", { message: "Invalid SAML request" });
|
|
@@ -2162,10 +2193,10 @@ const callbackSSO = (options) => {
|
|
|
2162
2193
|
oidcConfig: safeJsonParse(res.oidcConfig) || void 0
|
|
2163
2194
|
};
|
|
2164
2195
|
});
|
|
2165
|
-
if (!provider) throw ctx.redirect(`${errorURL || callbackURL}
|
|
2196
|
+
if (!provider) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=provider not found`);
|
|
2166
2197
|
if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
|
|
2167
2198
|
let config = provider.oidcConfig;
|
|
2168
|
-
if (!config) throw ctx.redirect(`${errorURL || callbackURL}
|
|
2199
|
+
if (!config) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=provider not found`);
|
|
2169
2200
|
const discovery = await betterFetch(config.discoveryEndpoint);
|
|
2170
2201
|
if (discovery.data) config = {
|
|
2171
2202
|
tokenEndpoint: discovery.data.token_endpoint,
|
|
@@ -2179,7 +2210,7 @@ const callbackSSO = (options) => {
|
|
|
2179
2210
|
],
|
|
2180
2211
|
...config
|
|
2181
2212
|
};
|
|
2182
|
-
if (!config.tokenEndpoint) throw ctx.redirect(`${errorURL || callbackURL}
|
|
2213
|
+
if (!config.tokenEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_endpoint_not_found`);
|
|
2183
2214
|
const tokenResponse = await validateAuthorizationCode({
|
|
2184
2215
|
code,
|
|
2185
2216
|
codeVerifier: config.pkce ? stateData.codeVerifier : void 0,
|
|
@@ -2194,17 +2225,19 @@ const callbackSSO = (options) => {
|
|
|
2194
2225
|
if (e instanceof BetterFetchError) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=${e.message}`);
|
|
2195
2226
|
return null;
|
|
2196
2227
|
});
|
|
2197
|
-
if (!tokenResponse) throw ctx.redirect(`${errorURL || callbackURL}
|
|
2228
|
+
if (!tokenResponse) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_response_not_found`);
|
|
2198
2229
|
let userInfo = null;
|
|
2199
2230
|
if (tokenResponse.idToken) {
|
|
2200
2231
|
const idToken = decodeJwt(tokenResponse.idToken);
|
|
2201
|
-
if (!config.jwksEndpoint) throw ctx.redirect(`${errorURL || callbackURL}
|
|
2202
|
-
const verified = await validateToken(tokenResponse.idToken, config.jwksEndpoint
|
|
2232
|
+
if (!config.jwksEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=jwks_endpoint_not_found`);
|
|
2233
|
+
const verified = await validateToken(tokenResponse.idToken, config.jwksEndpoint, {
|
|
2234
|
+
audience: config.clientId,
|
|
2235
|
+
issuer: provider.issuer
|
|
2236
|
+
}).catch((e) => {
|
|
2203
2237
|
ctx.context.logger.error(e);
|
|
2204
2238
|
return null;
|
|
2205
2239
|
});
|
|
2206
|
-
if (!verified) throw ctx.redirect(`${errorURL || callbackURL}
|
|
2207
|
-
if (verified.payload.iss !== provider.issuer) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=issuer_mismatch`);
|
|
2240
|
+
if (!verified) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_not_verified`);
|
|
2208
2241
|
const mapping = config.mapping || {};
|
|
2209
2242
|
userInfo = {
|
|
2210
2243
|
...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, verified.payload[value]])),
|
|
@@ -2216,12 +2249,12 @@ const callbackSSO = (options) => {
|
|
|
2216
2249
|
};
|
|
2217
2250
|
}
|
|
2218
2251
|
if (!userInfo) {
|
|
2219
|
-
if (!config.userInfoEndpoint) throw ctx.redirect(`${errorURL || callbackURL}
|
|
2252
|
+
if (!config.userInfoEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=user_info_endpoint_not_found`);
|
|
2220
2253
|
const userInfoResponse = await betterFetch(config.userInfoEndpoint, { headers: { Authorization: `Bearer ${tokenResponse.accessToken}` } });
|
|
2221
|
-
if (userInfoResponse.error) throw ctx.redirect(`${errorURL || callbackURL}
|
|
2254
|
+
if (userInfoResponse.error) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=${userInfoResponse.error.message}`);
|
|
2222
2255
|
userInfo = userInfoResponse.data;
|
|
2223
2256
|
}
|
|
2224
|
-
if (!userInfo.email || !userInfo.id) throw ctx.redirect(`${errorURL || callbackURL}
|
|
2257
|
+
if (!userInfo.email || !userInfo.id) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=missing_user_info`);
|
|
2225
2258
|
const isTrustedProvider = "domainVerified" in provider && provider.domainVerified === true && validateEmailDomain(userInfo.email, provider.domain);
|
|
2226
2259
|
const linked = await handleOAuthUserInfo(ctx, {
|
|
2227
2260
|
userInfo: {
|
|
@@ -2246,7 +2279,7 @@ const callbackSSO = (options) => {
|
|
|
2246
2279
|
overrideUserInfo: config.overrideUserInfo,
|
|
2247
2280
|
isTrustedProvider
|
|
2248
2281
|
});
|
|
2249
|
-
if (linked.error) throw ctx.redirect(`${errorURL || callbackURL}
|
|
2282
|
+
if (linked.error) throw ctx.redirect(`${errorURL || callbackURL}?error=${linked.error}`);
|
|
2250
2283
|
const { session, user } = linked.data;
|
|
2251
2284
|
if (options?.provisionUser) await options.provisionUser({
|
|
2252
2285
|
user,
|
|
@@ -2340,8 +2373,8 @@ const callbackSSOSAML = (options) => {
|
|
|
2340
2373
|
if (ctx.method === "GET" && !ctx.body?.SAMLResponse) {
|
|
2341
2374
|
if (!(await getSessionFromCtx(ctx))?.session) throw ctx.redirect(`${errorURL}?error=invalid_request`);
|
|
2342
2375
|
const relayState$1 = ctx.query?.RelayState;
|
|
2343
|
-
const safeRedirectUrl
|
|
2344
|
-
throw ctx.redirect(safeRedirectUrl
|
|
2376
|
+
const safeRedirectUrl = getSafeRedirectUrl(relayState$1, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
|
|
2377
|
+
throw ctx.redirect(safeRedirectUrl);
|
|
2345
2378
|
}
|
|
2346
2379
|
if (!ctx.body?.SAMLResponse) throw new APIError("BAD_REQUEST", { message: "SAMLResponse is required for POST requests" });
|
|
2347
2380
|
const { SAMLResponse } = ctx.body;
|
|
@@ -2380,16 +2413,18 @@ const callbackSSOSAML = (options) => {
|
|
|
2380
2413
|
if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
|
|
2381
2414
|
const parsedSamlConfig = safeJsonParse(provider.samlConfig);
|
|
2382
2415
|
if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
|
|
2416
|
+
const isTrusted = (url, settings) => ctx.context.isTrustedOrigin(url, settings);
|
|
2417
|
+
const safeErrorUrl = getSafeRedirectUrl(relayState?.errorURL || relayState?.callbackURL || parsedSamlConfig.callbackUrl, currentCallbackPath, appOrigin, isTrusted);
|
|
2383
2418
|
const idpData = parsedSamlConfig.idpMetadata;
|
|
2384
2419
|
let idp = null;
|
|
2385
2420
|
if (!idpData?.metadata) idp = saml.IdentityProvider({
|
|
2386
2421
|
entityID: idpData?.entityID || parsedSamlConfig.issuer,
|
|
2387
|
-
singleSignOnService: [{
|
|
2422
|
+
singleSignOnService: idpData?.singleSignOnService || [{
|
|
2388
2423
|
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
2389
2424
|
Location: parsedSamlConfig.entryPoint
|
|
2390
2425
|
}],
|
|
2391
2426
|
signingCert: idpData?.cert || parsedSamlConfig.cert,
|
|
2392
|
-
wantAuthnRequestsSigned: parsedSamlConfig.
|
|
2427
|
+
wantAuthnRequestsSigned: parsedSamlConfig.authnRequestsSigned || false,
|
|
2393
2428
|
isAssertionEncrypted: idpData?.isAssertionEncrypted || false,
|
|
2394
2429
|
encPrivateKey: idpData?.encPrivateKey,
|
|
2395
2430
|
encPrivateKeyPass: idpData?.encPrivateKeyPass
|
|
@@ -2429,7 +2464,7 @@ const callbackSSOSAML = (options) => {
|
|
|
2429
2464
|
} catch (error) {
|
|
2430
2465
|
ctx.context.logger.error("SAML response validation failed", {
|
|
2431
2466
|
error,
|
|
2432
|
-
decodedResponse:
|
|
2467
|
+
decodedResponse: new TextDecoder().decode(base64.decode(SAMLResponse))
|
|
2433
2468
|
});
|
|
2434
2469
|
throw new APIError("BAD_REQUEST", {
|
|
2435
2470
|
message: "Invalid SAML response",
|
|
@@ -2460,8 +2495,7 @@ const callbackSSOSAML = (options) => {
|
|
|
2460
2495
|
inResponseTo,
|
|
2461
2496
|
providerId: provider.providerId
|
|
2462
2497
|
});
|
|
2463
|
-
|
|
2464
|
-
throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`);
|
|
2498
|
+
throw ctx.redirect(`${safeErrorUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`);
|
|
2465
2499
|
}
|
|
2466
2500
|
if (storedRequest.providerId !== provider.providerId) {
|
|
2467
2501
|
ctx.context.logger.error("SAML InResponseTo validation failed: provider mismatch", {
|
|
@@ -2470,14 +2504,12 @@ const callbackSSOSAML = (options) => {
|
|
|
2470
2504
|
actualProvider: provider.providerId
|
|
2471
2505
|
});
|
|
2472
2506
|
await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
|
|
2473
|
-
|
|
2474
|
-
throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`);
|
|
2507
|
+
throw ctx.redirect(`${safeErrorUrl}?error=invalid_saml_response&error_description=Provider+mismatch`);
|
|
2475
2508
|
}
|
|
2476
2509
|
await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
|
|
2477
2510
|
} else if (!allowIdpInitiated) {
|
|
2478
2511
|
ctx.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId: provider.providerId });
|
|
2479
|
-
|
|
2480
|
-
throw ctx.redirect(`${redirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`);
|
|
2512
|
+
throw ctx.redirect(`${safeErrorUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`);
|
|
2481
2513
|
}
|
|
2482
2514
|
}
|
|
2483
2515
|
const samlContent = parsedResponse.samlContent;
|
|
@@ -2503,8 +2535,7 @@ const callbackSSOSAML = (options) => {
|
|
|
2503
2535
|
issuer,
|
|
2504
2536
|
providerId: provider.providerId
|
|
2505
2537
|
});
|
|
2506
|
-
|
|
2507
|
-
throw ctx.redirect(`${redirectUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`);
|
|
2538
|
+
throw ctx.redirect(`${safeErrorUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`);
|
|
2508
2539
|
}
|
|
2509
2540
|
await ctx.context.internalAdapter.createVerificationValue({
|
|
2510
2541
|
identifier: `${USED_ASSERTION_KEY_PREFIX}${assertionId}`,
|
|
@@ -2537,7 +2568,7 @@ const callbackSSOSAML = (options) => {
|
|
|
2537
2568
|
throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
|
|
2538
2569
|
}
|
|
2539
2570
|
const isTrustedProvider = !!ctx.context.options.account?.accountLinking?.trustedProviders?.includes(provider.providerId) || "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
|
|
2540
|
-
const
|
|
2571
|
+
const safeCallbackUrl = getSafeRedirectUrl(relayState?.callbackURL || parsedSamlConfig.callbackUrl, currentCallbackPath, appOrigin, isTrusted);
|
|
2541
2572
|
const result = await handleOAuthUserInfo(ctx, {
|
|
2542
2573
|
userInfo: {
|
|
2543
2574
|
email: userInfo.email,
|
|
@@ -2551,11 +2582,11 @@ const callbackSSOSAML = (options) => {
|
|
|
2551
2582
|
accessToken: "",
|
|
2552
2583
|
refreshToken: ""
|
|
2553
2584
|
},
|
|
2554
|
-
callbackURL:
|
|
2585
|
+
callbackURL: safeCallbackUrl,
|
|
2555
2586
|
disableSignUp: options?.disableImplicitSignUp,
|
|
2556
2587
|
isTrustedProvider
|
|
2557
2588
|
});
|
|
2558
|
-
if (result.error) throw ctx.redirect(`${
|
|
2589
|
+
if (result.error) throw ctx.redirect(`${safeCallbackUrl}?error=${result.error.split(" ").join("_")}`);
|
|
2559
2590
|
const { session, user } = result.data;
|
|
2560
2591
|
if (options?.provisionUser) await options.provisionUser({
|
|
2561
2592
|
user,
|
|
@@ -2579,8 +2610,7 @@ const callbackSSOSAML = (options) => {
|
|
|
2579
2610
|
session,
|
|
2580
2611
|
user
|
|
2581
2612
|
});
|
|
2582
|
-
|
|
2583
|
-
throw ctx.redirect(safeRedirectUrl);
|
|
2613
|
+
throw ctx.redirect(safeCallbackUrl);
|
|
2584
2614
|
});
|
|
2585
2615
|
};
|
|
2586
2616
|
const acsEndpointBodySchema = z.object({
|
|
@@ -2602,10 +2632,18 @@ const acsEndpoint = (options) => {
|
|
|
2602
2632
|
}
|
|
2603
2633
|
}
|
|
2604
2634
|
}, async (ctx) => {
|
|
2605
|
-
const { SAMLResponse
|
|
2635
|
+
const { SAMLResponse } = ctx.body;
|
|
2606
2636
|
const { providerId } = ctx.params;
|
|
2637
|
+
const currentCallbackPath = `${ctx.context.baseURL}/sso/saml2/sp/acs/${providerId}`;
|
|
2638
|
+
const appOrigin = new URL(ctx.context.baseURL).origin;
|
|
2607
2639
|
const maxResponseSize = options?.saml?.maxResponseSize ?? DEFAULT_MAX_SAML_RESPONSE_SIZE;
|
|
2608
2640
|
if (new TextEncoder().encode(SAMLResponse).length > maxResponseSize) throw new APIError("BAD_REQUEST", { message: `SAML response exceeds maximum allowed size (${maxResponseSize} bytes)` });
|
|
2641
|
+
let relayState = null;
|
|
2642
|
+
if (ctx.body.RelayState) try {
|
|
2643
|
+
relayState = await parseRelayState(ctx);
|
|
2644
|
+
} catch {
|
|
2645
|
+
relayState = null;
|
|
2646
|
+
}
|
|
2609
2647
|
let provider = null;
|
|
2610
2648
|
if (options?.defaultSSO?.length) {
|
|
2611
2649
|
const matchingDefault = providerId ? options.defaultSSO.find((defaultProvider) => defaultProvider.providerId === providerId) : options.defaultSSO[0];
|
|
@@ -2633,6 +2671,8 @@ const acsEndpoint = (options) => {
|
|
|
2633
2671
|
if (!provider?.samlConfig) throw new APIError("NOT_FOUND", { message: "No SAML provider found" });
|
|
2634
2672
|
if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
|
|
2635
2673
|
const parsedSamlConfig = provider.samlConfig;
|
|
2674
|
+
const isTrusted = (url, settings) => ctx.context.isTrustedOrigin(url, settings);
|
|
2675
|
+
const safeErrorUrl = getSafeRedirectUrl(relayState?.errorURL || relayState?.callbackURL || parsedSamlConfig.callbackUrl, currentCallbackPath, appOrigin, isTrusted);
|
|
2636
2676
|
const sp = saml.ServiceProvider({
|
|
2637
2677
|
entityID: parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
|
|
2638
2678
|
assertionConsumerService: [{
|
|
@@ -2658,9 +2698,8 @@ const acsEndpoint = (options) => {
|
|
|
2658
2698
|
validateSingleAssertion(SAMLResponse);
|
|
2659
2699
|
} catch (error) {
|
|
2660
2700
|
if (error instanceof APIError) {
|
|
2661
|
-
const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2662
2701
|
const errorCode = error.body?.code === "SAML_MULTIPLE_ASSERTIONS" ? "multiple_assertions" : "no_assertion";
|
|
2663
|
-
throw ctx.redirect(`${
|
|
2702
|
+
throw ctx.redirect(`${safeErrorUrl}?error=${errorCode}&error_description=${encodeURIComponent(error.message)}`);
|
|
2664
2703
|
}
|
|
2665
2704
|
throw error;
|
|
2666
2705
|
}
|
|
@@ -2668,13 +2707,13 @@ const acsEndpoint = (options) => {
|
|
|
2668
2707
|
try {
|
|
2669
2708
|
parsedResponse = await sp.parseLoginResponse(idp, "post", { body: {
|
|
2670
2709
|
SAMLResponse,
|
|
2671
|
-
RelayState: RelayState || void 0
|
|
2710
|
+
RelayState: ctx.body.RelayState || void 0
|
|
2672
2711
|
} });
|
|
2673
2712
|
if (!parsedResponse?.extract) throw new Error("Invalid SAML response structure");
|
|
2674
2713
|
} catch (error) {
|
|
2675
2714
|
ctx.context.logger.error("SAML response validation failed", {
|
|
2676
2715
|
error,
|
|
2677
|
-
decodedResponse:
|
|
2716
|
+
decodedResponse: new TextDecoder().decode(base64.decode(SAMLResponse))
|
|
2678
2717
|
});
|
|
2679
2718
|
throw new APIError("BAD_REQUEST", {
|
|
2680
2719
|
message: "Invalid SAML response",
|
|
@@ -2705,8 +2744,7 @@ const acsEndpoint = (options) => {
|
|
|
2705
2744
|
inResponseTo: inResponseToAcs,
|
|
2706
2745
|
providerId
|
|
2707
2746
|
});
|
|
2708
|
-
|
|
2709
|
-
throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`);
|
|
2747
|
+
throw ctx.redirect(`${safeErrorUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`);
|
|
2710
2748
|
}
|
|
2711
2749
|
if (storedRequest.providerId !== providerId) {
|
|
2712
2750
|
ctx.context.logger.error("SAML InResponseTo validation failed: provider mismatch", {
|
|
@@ -2715,17 +2753,15 @@ const acsEndpoint = (options) => {
|
|
|
2715
2753
|
actualProvider: providerId
|
|
2716
2754
|
});
|
|
2717
2755
|
await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`);
|
|
2718
|
-
|
|
2719
|
-
throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`);
|
|
2756
|
+
throw ctx.redirect(`${safeErrorUrl}?error=invalid_saml_response&error_description=Provider+mismatch`);
|
|
2720
2757
|
}
|
|
2721
2758
|
await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`);
|
|
2722
2759
|
} else if (!allowIdpInitiated) {
|
|
2723
2760
|
ctx.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId });
|
|
2724
|
-
|
|
2725
|
-
throw ctx.redirect(`${redirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`);
|
|
2761
|
+
throw ctx.redirect(`${safeErrorUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`);
|
|
2726
2762
|
}
|
|
2727
2763
|
}
|
|
2728
|
-
const assertionIdAcs = extractAssertionId(
|
|
2764
|
+
const assertionIdAcs = extractAssertionId(new TextDecoder().decode(base64.decode(SAMLResponse)));
|
|
2729
2765
|
if (assertionIdAcs) {
|
|
2730
2766
|
const issuer = idp.entityMeta.getEntityID();
|
|
2731
2767
|
const conditions = extract.conditions;
|
|
@@ -2747,8 +2783,7 @@ const acsEndpoint = (options) => {
|
|
|
2747
2783
|
issuer,
|
|
2748
2784
|
providerId
|
|
2749
2785
|
});
|
|
2750
|
-
|
|
2751
|
-
throw ctx.redirect(`${redirectUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`);
|
|
2786
|
+
throw ctx.redirect(`${safeErrorUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`);
|
|
2752
2787
|
}
|
|
2753
2788
|
await ctx.context.internalAdapter.createVerificationValue({
|
|
2754
2789
|
identifier: `${USED_ASSERTION_KEY_PREFIX}${assertionIdAcs}`,
|
|
@@ -2781,7 +2816,7 @@ const acsEndpoint = (options) => {
|
|
|
2781
2816
|
throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
|
|
2782
2817
|
}
|
|
2783
2818
|
const isTrustedProvider = !!ctx.context.options.account?.accountLinking?.trustedProviders?.includes(provider.providerId) || "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
|
|
2784
|
-
const
|
|
2819
|
+
const safeCallbackUrl = getSafeRedirectUrl(relayState?.callbackURL || parsedSamlConfig.callbackUrl, currentCallbackPath, appOrigin, isTrusted);
|
|
2785
2820
|
const result = await handleOAuthUserInfo(ctx, {
|
|
2786
2821
|
userInfo: {
|
|
2787
2822
|
email: userInfo.email,
|
|
@@ -2795,11 +2830,11 @@ const acsEndpoint = (options) => {
|
|
|
2795
2830
|
accessToken: "",
|
|
2796
2831
|
refreshToken: ""
|
|
2797
2832
|
},
|
|
2798
|
-
callbackURL:
|
|
2833
|
+
callbackURL: safeCallbackUrl,
|
|
2799
2834
|
disableSignUp: options?.disableImplicitSignUp,
|
|
2800
2835
|
isTrustedProvider
|
|
2801
2836
|
});
|
|
2802
|
-
if (result.error) throw ctx.redirect(`${
|
|
2837
|
+
if (result.error) throw ctx.redirect(`${safeCallbackUrl}?error=${result.error.split(" ").join("_")}`);
|
|
2803
2838
|
const { session, user } = result.data;
|
|
2804
2839
|
if (options?.provisionUser) await options.provisionUser({
|
|
2805
2840
|
user,
|
|
@@ -2823,7 +2858,7 @@ const acsEndpoint = (options) => {
|
|
|
2823
2858
|
session,
|
|
2824
2859
|
user
|
|
2825
2860
|
});
|
|
2826
|
-
throw ctx.redirect(
|
|
2861
|
+
throw ctx.redirect(safeCallbackUrl);
|
|
2827
2862
|
});
|
|
2828
2863
|
};
|
|
2829
2864
|
|