@better-auth/sso 1.4.10 → 1.4.11-beta.2
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 +5 -5
- package/dist/client.d.mts +1 -1
- package/dist/{index-D4Ey-vkQ.d.mts → index-BJqo8KP7.d.mts} +12 -12
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +90 -20
- package/package.json +5 -4
- package/src/routes/sso.ts +24 -1
- package/src/saml/algorithms.ts +1 -32
- package/src/saml/assertions.test.ts +239 -0
- package/src/saml/assertions.ts +62 -0
- package/src/saml/index.ts +2 -0
- package/src/saml/parser.ts +56 -0
- package/src/saml.test.ts +348 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
> @better-auth/sso@1.4.
|
|
2
|
+
> @better-auth/sso@1.4.11-beta.2 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 [2m99.53 kB[22m [2m│ gzip: 19.50 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.67 kB[22m [2m│ gzip: 0.57 kB[22m
|
|
13
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-
|
|
15
|
-
[34mℹ[39m 5 files, total:
|
|
16
|
-
[32m✔[39m Build complete in [
|
|
14
|
+
[34mℹ[39m [2mdist/[22m[32mindex-BJqo8KP7.d.mts[39m [2m44.21 kB[22m [2m│ gzip: 9.10 kB[22m
|
|
15
|
+
[34mℹ[39m 5 files, total: 146.05 kB
|
|
16
|
+
[32m✔[39m Build complete in [32m15932ms[39m
|
package/dist/client.d.mts
CHANGED
|
@@ -2,7 +2,7 @@ import { APIError } from "better-auth/api";
|
|
|
2
2
|
import * as z$1 from "zod/v4";
|
|
3
3
|
import z from "zod/v4";
|
|
4
4
|
import { Awaitable, OAuth2Tokens, User } from "better-auth";
|
|
5
|
-
import * as
|
|
5
|
+
import * as better_call7 from "better-call";
|
|
6
6
|
|
|
7
7
|
//#region src/saml/algorithms.d.ts
|
|
8
8
|
declare const SignatureAlgorithm: {
|
|
@@ -383,7 +383,7 @@ interface SSOOptions {
|
|
|
383
383
|
}
|
|
384
384
|
//#endregion
|
|
385
385
|
//#region src/routes/domain-verification.d.ts
|
|
386
|
-
declare const requestDomainVerification: (options: SSOOptions) =>
|
|
386
|
+
declare const requestDomainVerification: (options: SSOOptions) => better_call7.StrictEndpoint<"/sso/request-domain-verification", {
|
|
387
387
|
method: "POST";
|
|
388
388
|
body: z$1.ZodObject<{
|
|
389
389
|
providerId: z$1.ZodString;
|
|
@@ -405,7 +405,7 @@ declare const requestDomainVerification: (options: SSOOptions) => better_call0.S
|
|
|
405
405
|
};
|
|
406
406
|
};
|
|
407
407
|
};
|
|
408
|
-
use: ((inputContext:
|
|
408
|
+
use: ((inputContext: better_call7.MiddlewareInputContext<better_call7.MiddlewareOptions>) => Promise<{
|
|
409
409
|
session: {
|
|
410
410
|
session: Record<string, any> & {
|
|
411
411
|
id: string;
|
|
@@ -431,7 +431,7 @@ declare const requestDomainVerification: (options: SSOOptions) => better_call0.S
|
|
|
431
431
|
}, {
|
|
432
432
|
domainVerificationToken: string;
|
|
433
433
|
}>;
|
|
434
|
-
declare const verifyDomain: (options: SSOOptions) =>
|
|
434
|
+
declare const verifyDomain: (options: SSOOptions) => better_call7.StrictEndpoint<"/sso/verify-domain", {
|
|
435
435
|
method: "POST";
|
|
436
436
|
body: z$1.ZodObject<{
|
|
437
437
|
providerId: z$1.ZodString;
|
|
@@ -456,7 +456,7 @@ declare const verifyDomain: (options: SSOOptions) => better_call0.StrictEndpoint
|
|
|
456
456
|
};
|
|
457
457
|
};
|
|
458
458
|
};
|
|
459
|
-
use: ((inputContext:
|
|
459
|
+
use: ((inputContext: better_call7.MiddlewareInputContext<better_call7.MiddlewareOptions>) => Promise<{
|
|
460
460
|
session: {
|
|
461
461
|
session: Record<string, any> & {
|
|
462
462
|
id: string;
|
|
@@ -500,7 +500,7 @@ interface SAMLConditions {
|
|
|
500
500
|
* @throws {APIError} If timestamps are invalid, expired, or not yet valid
|
|
501
501
|
*/
|
|
502
502
|
declare function validateSAMLTimestamp(conditions: SAMLConditions | undefined, options?: TimestampValidationOptions): void;
|
|
503
|
-
declare const spMetadata: () =>
|
|
503
|
+
declare const spMetadata: () => better_call7.StrictEndpoint<"/sso/saml2/sp/metadata", {
|
|
504
504
|
method: "GET";
|
|
505
505
|
query: z.ZodObject<{
|
|
506
506
|
providerId: z.ZodString;
|
|
@@ -522,7 +522,7 @@ declare const spMetadata: () => better_call0.StrictEndpoint<"/sso/saml2/sp/metad
|
|
|
522
522
|
};
|
|
523
523
|
};
|
|
524
524
|
}, Response>;
|
|
525
|
-
declare const registerSSOProvider: <O extends SSOOptions>(options: O) =>
|
|
525
|
+
declare const registerSSOProvider: <O extends SSOOptions>(options: O) => better_call7.StrictEndpoint<"/sso/register", {
|
|
526
526
|
method: "POST";
|
|
527
527
|
body: z.ZodObject<{
|
|
528
528
|
providerId: z.ZodString;
|
|
@@ -601,7 +601,7 @@ declare const registerSSOProvider: <O extends SSOOptions>(options: O) => better_
|
|
|
601
601
|
organizationId: z.ZodOptional<z.ZodString>;
|
|
602
602
|
overrideUserInfo: z.ZodOptional<z.ZodDefault<z.ZodBoolean>>;
|
|
603
603
|
}, z.core.$strip>;
|
|
604
|
-
use: ((inputContext:
|
|
604
|
+
use: ((inputContext: better_call7.MiddlewareInputContext<better_call7.MiddlewareOptions>) => Promise<{
|
|
605
605
|
session: {
|
|
606
606
|
session: Record<string, any> & {
|
|
607
607
|
id: string;
|
|
@@ -799,7 +799,7 @@ declare const registerSSOProvider: <O extends SSOOptions>(options: O) => better_
|
|
|
799
799
|
oidcConfig: OIDCConfig | null;
|
|
800
800
|
samlConfig: SAMLConfig | null;
|
|
801
801
|
} & Omit<SSOProvider<O>, "oidcConfig" | "samlConfig">>;
|
|
802
|
-
declare const signInSSO: (options?: SSOOptions) =>
|
|
802
|
+
declare const signInSSO: (options?: SSOOptions) => better_call7.StrictEndpoint<"/sign-in/sso", {
|
|
803
803
|
method: "POST";
|
|
804
804
|
body: z.ZodObject<{
|
|
805
805
|
email: z.ZodOptional<z.ZodString>;
|
|
@@ -893,7 +893,7 @@ declare const signInSSO: (options?: SSOOptions) => better_call0.StrictEndpoint<"
|
|
|
893
893
|
url: string;
|
|
894
894
|
redirect: boolean;
|
|
895
895
|
}>;
|
|
896
|
-
declare const callbackSSO: (options?: SSOOptions) =>
|
|
896
|
+
declare const callbackSSO: (options?: SSOOptions) => better_call7.StrictEndpoint<"/sso/callback/:providerId", {
|
|
897
897
|
method: "GET";
|
|
898
898
|
query: z.ZodObject<{
|
|
899
899
|
code: z.ZodOptional<z.ZodString>;
|
|
@@ -916,7 +916,7 @@ declare const callbackSSO: (options?: SSOOptions) => better_call0.StrictEndpoint
|
|
|
916
916
|
scope: "server";
|
|
917
917
|
};
|
|
918
918
|
}, never>;
|
|
919
|
-
declare const callbackSSOSAML: (options?: SSOOptions) =>
|
|
919
|
+
declare const callbackSSOSAML: (options?: SSOOptions) => better_call7.StrictEndpoint<"/sso/saml2/callback/:providerId", {
|
|
920
920
|
method: "POST";
|
|
921
921
|
body: z.ZodObject<{
|
|
922
922
|
SAMLResponse: z.ZodString;
|
|
@@ -943,7 +943,7 @@ declare const callbackSSOSAML: (options?: SSOOptions) => better_call0.StrictEndp
|
|
|
943
943
|
scope: "server";
|
|
944
944
|
};
|
|
945
945
|
}, never>;
|
|
946
|
-
declare const acsEndpoint: (options?: SSOOptions) =>
|
|
946
|
+
declare const acsEndpoint: (options?: SSOOptions) => better_call7.StrictEndpoint<"/sso/saml2/sp/acs/:providerId", {
|
|
947
947
|
method: "POST";
|
|
948
948
|
params: z.ZodObject<{
|
|
949
949
|
providerId: z.ZodOptional<z.ZodString>;
|
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-BJqo8KP7.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
|
@@ -9,6 +9,7 @@ import { HIDE_METADATA, createAuthorizationURL, generateState, parseState, valid
|
|
|
9
9
|
import { setSessionCookie } from "better-auth/cookies";
|
|
10
10
|
import { handleOAuthUserInfo } from "better-auth/oauth2";
|
|
11
11
|
import { decodeJwt } from "jose";
|
|
12
|
+
import { base64 } from "@better-auth/utils/base64";
|
|
12
13
|
|
|
13
14
|
//#region src/linking/org-assignment.ts
|
|
14
15
|
/**
|
|
@@ -662,6 +663,41 @@ function mapDiscoveryErrorToAPIError(error) {
|
|
|
662
663
|
}
|
|
663
664
|
}
|
|
664
665
|
|
|
666
|
+
//#endregion
|
|
667
|
+
//#region src/saml/parser.ts
|
|
668
|
+
const xmlParser = new XMLParser({
|
|
669
|
+
ignoreAttributes: false,
|
|
670
|
+
attributeNamePrefix: "@_",
|
|
671
|
+
removeNSPrefix: true,
|
|
672
|
+
processEntities: false
|
|
673
|
+
});
|
|
674
|
+
function findNode(obj, nodeName) {
|
|
675
|
+
if (!obj || typeof obj !== "object") return null;
|
|
676
|
+
const record = obj;
|
|
677
|
+
if (nodeName in record) return record[nodeName];
|
|
678
|
+
for (const value of Object.values(record)) if (Array.isArray(value)) for (const item of value) {
|
|
679
|
+
const found = findNode(item, nodeName);
|
|
680
|
+
if (found) return found;
|
|
681
|
+
}
|
|
682
|
+
else if (typeof value === "object" && value !== null) {
|
|
683
|
+
const found = findNode(value, nodeName);
|
|
684
|
+
if (found) return found;
|
|
685
|
+
}
|
|
686
|
+
return null;
|
|
687
|
+
}
|
|
688
|
+
function countAllNodes(obj, nodeName) {
|
|
689
|
+
if (!obj || typeof obj !== "object") return 0;
|
|
690
|
+
let count = 0;
|
|
691
|
+
const record = obj;
|
|
692
|
+
if (nodeName in record) {
|
|
693
|
+
const node = record[nodeName];
|
|
694
|
+
count += Array.isArray(node) ? node.length : 1;
|
|
695
|
+
}
|
|
696
|
+
for (const value of Object.values(record)) if (Array.isArray(value)) for (const item of value) count += countAllNodes(item, nodeName);
|
|
697
|
+
else if (typeof value === "object" && value !== null) count += countAllNodes(value, nodeName);
|
|
698
|
+
return count;
|
|
699
|
+
}
|
|
700
|
+
|
|
665
701
|
//#endregion
|
|
666
702
|
//#region src/saml/algorithms.ts
|
|
667
703
|
const SignatureAlgorithm = {
|
|
@@ -735,26 +771,6 @@ function normalizeSignatureAlgorithm(alg) {
|
|
|
735
771
|
function normalizeDigestAlgorithm(alg) {
|
|
736
772
|
return SHORT_FORM_DIGEST_TO_URI[alg.toLowerCase()] ?? alg;
|
|
737
773
|
}
|
|
738
|
-
const xmlParser = new XMLParser({
|
|
739
|
-
ignoreAttributes: false,
|
|
740
|
-
attributeNamePrefix: "@_",
|
|
741
|
-
removeNSPrefix: true,
|
|
742
|
-
processEntities: false
|
|
743
|
-
});
|
|
744
|
-
function findNode(obj, nodeName) {
|
|
745
|
-
if (!obj || typeof obj !== "object") return null;
|
|
746
|
-
const record = obj;
|
|
747
|
-
if (nodeName in record) return record[nodeName];
|
|
748
|
-
for (const value of Object.values(record)) if (Array.isArray(value)) for (const item of value) {
|
|
749
|
-
const found = findNode(item, nodeName);
|
|
750
|
-
if (found) return found;
|
|
751
|
-
}
|
|
752
|
-
else if (typeof value === "object" && value !== null) {
|
|
753
|
-
const found = findNode(value, nodeName);
|
|
754
|
-
if (found) return found;
|
|
755
|
-
}
|
|
756
|
-
return null;
|
|
757
|
-
}
|
|
758
774
|
function extractEncryptionAlgorithms(xml) {
|
|
759
775
|
try {
|
|
760
776
|
const parsed = xmlParser.parse(xml);
|
|
@@ -863,6 +879,49 @@ function validateConfigAlgorithms(config, options = {}) {
|
|
|
863
879
|
}
|
|
864
880
|
}
|
|
865
881
|
|
|
882
|
+
//#endregion
|
|
883
|
+
//#region src/saml/assertions.ts
|
|
884
|
+
/** @lintignore used in tests */
|
|
885
|
+
function countAssertions(xml) {
|
|
886
|
+
let parsed;
|
|
887
|
+
try {
|
|
888
|
+
parsed = xmlParser.parse(xml);
|
|
889
|
+
} catch {
|
|
890
|
+
throw new APIError("BAD_REQUEST", {
|
|
891
|
+
message: "Failed to parse SAML response XML",
|
|
892
|
+
code: "SAML_INVALID_XML"
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
const assertions = countAllNodes(parsed, "Assertion");
|
|
896
|
+
const encryptedAssertions = countAllNodes(parsed, "EncryptedAssertion");
|
|
897
|
+
return {
|
|
898
|
+
assertions,
|
|
899
|
+
encryptedAssertions,
|
|
900
|
+
total: assertions + encryptedAssertions
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
function validateSingleAssertion(samlResponse) {
|
|
904
|
+
let xml;
|
|
905
|
+
try {
|
|
906
|
+
xml = new TextDecoder().decode(base64.decode(samlResponse));
|
|
907
|
+
if (!xml.includes("<")) throw new Error("Not XML");
|
|
908
|
+
} catch {
|
|
909
|
+
throw new APIError("BAD_REQUEST", {
|
|
910
|
+
message: "Invalid base64-encoded SAML response",
|
|
911
|
+
code: "SAML_INVALID_ENCODING"
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
const counts = countAssertions(xml);
|
|
915
|
+
if (counts.total === 0) throw new APIError("BAD_REQUEST", {
|
|
916
|
+
message: "SAML response contains no assertions",
|
|
917
|
+
code: "SAML_NO_ASSERTION"
|
|
918
|
+
});
|
|
919
|
+
if (counts.total > 1) throw new APIError("BAD_REQUEST", {
|
|
920
|
+
message: `SAML response contains ${counts.total} assertions, expected exactly 1`,
|
|
921
|
+
code: "SAML_MULTIPLE_ASSERTIONS"
|
|
922
|
+
});
|
|
923
|
+
}
|
|
924
|
+
|
|
866
925
|
//#endregion
|
|
867
926
|
//#region src/utils.ts
|
|
868
927
|
/**
|
|
@@ -1854,6 +1913,7 @@ const callbackSSOSAML = (options) => {
|
|
|
1854
1913
|
wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
|
|
1855
1914
|
nameIDFormat: parsedSamlConfig.identifierFormat ? [parsedSamlConfig.identifierFormat] : void 0
|
|
1856
1915
|
});
|
|
1916
|
+
validateSingleAssertion(SAMLResponse);
|
|
1857
1917
|
let parsedResponse;
|
|
1858
1918
|
try {
|
|
1859
1919
|
parsedResponse = await sp.parseLoginResponse(idp, "post", { body: {
|
|
@@ -2090,6 +2150,16 @@ const acsEndpoint = (options) => {
|
|
|
2090
2150
|
}],
|
|
2091
2151
|
signingCert: idpData?.cert || parsedSamlConfig.cert
|
|
2092
2152
|
}) : saml.IdentityProvider({ metadata: idpData.metadata });
|
|
2153
|
+
try {
|
|
2154
|
+
validateSingleAssertion(SAMLResponse);
|
|
2155
|
+
} catch (error) {
|
|
2156
|
+
if (error instanceof APIError) {
|
|
2157
|
+
const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2158
|
+
const errorCode = error.body?.code === "SAML_MULTIPLE_ASSERTIONS" ? "multiple_assertions" : "no_assertion";
|
|
2159
|
+
throw ctx.redirect(`${redirectUrl}?error=${errorCode}&error_description=${encodeURIComponent(error.message)}`);
|
|
2160
|
+
}
|
|
2161
|
+
throw error;
|
|
2162
|
+
}
|
|
2093
2163
|
let parsedResponse;
|
|
2094
2164
|
try {
|
|
2095
2165
|
parsedResponse = await sp.parseLoginResponse(idp, "post", { body: {
|
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.
|
|
4
|
+
"version": "1.4.11-beta.2",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.mjs",
|
|
7
7
|
"types": "dist/index.d.mts",
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
}
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
|
+
"@better-auth/utils": "0.3.0",
|
|
55
56
|
"@better-fetch/fetch": "1.1.21",
|
|
56
57
|
"fast-xml-parser": "^5.2.5",
|
|
57
58
|
"jose": "^6.1.0",
|
|
@@ -59,7 +60,6 @@
|
|
|
59
60
|
"zod": "^4.1.12"
|
|
60
61
|
},
|
|
61
62
|
"devDependencies": {
|
|
62
|
-
"@better-auth/utils": "0.3.0",
|
|
63
63
|
"@types/body-parser": "^1.19.6",
|
|
64
64
|
"@types/express": "^5.0.5",
|
|
65
65
|
"better-call": "1.1.7",
|
|
@@ -67,10 +67,11 @@
|
|
|
67
67
|
"express": "^5.1.0",
|
|
68
68
|
"oauth2-mock-server": "^8.2.0",
|
|
69
69
|
"tsdown": "^0.17.2",
|
|
70
|
-
"better-auth": "1.4.
|
|
70
|
+
"better-auth": "1.4.11-beta.2"
|
|
71
71
|
},
|
|
72
72
|
"peerDependencies": {
|
|
73
|
-
"better-auth": "
|
|
73
|
+
"@better-auth/utils": "0.3.0",
|
|
74
|
+
"better-auth": "1.4.11-beta.2"
|
|
74
75
|
},
|
|
75
76
|
"scripts": {
|
|
76
77
|
"test": "vitest",
|
package/src/routes/sso.ts
CHANGED
|
@@ -47,7 +47,11 @@ import {
|
|
|
47
47
|
discoverOIDCConfig,
|
|
48
48
|
mapDiscoveryErrorToAPIError,
|
|
49
49
|
} from "../oidc";
|
|
50
|
-
import {
|
|
50
|
+
import {
|
|
51
|
+
validateConfigAlgorithms,
|
|
52
|
+
validateSAMLAlgorithms,
|
|
53
|
+
validateSingleAssertion,
|
|
54
|
+
} from "../saml";
|
|
51
55
|
import type { OIDCConfig, SAMLConfig, SSOOptions, SSOProvider } from "../types";
|
|
52
56
|
import { safeJsonParse, validateEmailDomain } from "../utils";
|
|
53
57
|
|
|
@@ -1835,6 +1839,8 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
|
|
|
1835
1839
|
: undefined,
|
|
1836
1840
|
});
|
|
1837
1841
|
|
|
1842
|
+
validateSingleAssertion(SAMLResponse);
|
|
1843
|
+
|
|
1838
1844
|
let parsedResponse: FlowResult;
|
|
1839
1845
|
try {
|
|
1840
1846
|
parsedResponse = await sp.parseLoginResponse(idp, "post", {
|
|
@@ -2272,6 +2278,23 @@ export const acsEndpoint = (options?: SSOOptions) => {
|
|
|
2272
2278
|
metadata: idpData.metadata,
|
|
2273
2279
|
});
|
|
2274
2280
|
|
|
2281
|
+
try {
|
|
2282
|
+
validateSingleAssertion(SAMLResponse);
|
|
2283
|
+
} catch (error) {
|
|
2284
|
+
if (error instanceof APIError) {
|
|
2285
|
+
const redirectUrl =
|
|
2286
|
+
RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2287
|
+
const errorCode =
|
|
2288
|
+
error.body?.code === "SAML_MULTIPLE_ASSERTIONS"
|
|
2289
|
+
? "multiple_assertions"
|
|
2290
|
+
: "no_assertion";
|
|
2291
|
+
throw ctx.redirect(
|
|
2292
|
+
`${redirectUrl}?error=${errorCode}&error_description=${encodeURIComponent(error.message)}`,
|
|
2293
|
+
);
|
|
2294
|
+
}
|
|
2295
|
+
throw error;
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2275
2298
|
// Parse and validate SAML response
|
|
2276
2299
|
let parsedResponse: FlowResult;
|
|
2277
2300
|
try {
|
package/src/saml/algorithms.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { APIError } from "better-auth/api";
|
|
2
|
-
import {
|
|
2
|
+
import { findNode, xmlParser } from "./parser";
|
|
3
3
|
|
|
4
4
|
export const SignatureAlgorithm = {
|
|
5
5
|
RSA_SHA1: "http://www.w3.org/2000/09/xmldsig#rsa-sha1",
|
|
@@ -102,37 +102,6 @@ export interface AlgorithmValidationOptions {
|
|
|
102
102
|
allowedDataEncryptionAlgorithms?: string[];
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
const xmlParser = new XMLParser({
|
|
106
|
-
ignoreAttributes: false,
|
|
107
|
-
attributeNamePrefix: "@_",
|
|
108
|
-
removeNSPrefix: true,
|
|
109
|
-
processEntities: false,
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
function findNode(obj: unknown, nodeName: string): unknown {
|
|
113
|
-
if (!obj || typeof obj !== "object") return null;
|
|
114
|
-
|
|
115
|
-
const record = obj as Record<string, unknown>;
|
|
116
|
-
|
|
117
|
-
if (nodeName in record) {
|
|
118
|
-
return record[nodeName];
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
for (const value of Object.values(record)) {
|
|
122
|
-
if (Array.isArray(value)) {
|
|
123
|
-
for (const item of value) {
|
|
124
|
-
const found = findNode(item, nodeName);
|
|
125
|
-
if (found) return found;
|
|
126
|
-
}
|
|
127
|
-
} else if (typeof value === "object" && value !== null) {
|
|
128
|
-
const found = findNode(value, nodeName);
|
|
129
|
-
if (found) return found;
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
return null;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
105
|
function extractEncryptionAlgorithms(xml: string): {
|
|
137
106
|
keyEncryption: string | null;
|
|
138
107
|
dataEncryption: string | null;
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { countAssertions, validateSingleAssertion } from "./assertions";
|
|
3
|
+
|
|
4
|
+
describe("validateSingleAssertion", () => {
|
|
5
|
+
const encode = (xml: string) => Buffer.from(xml).toString("base64");
|
|
6
|
+
|
|
7
|
+
describe("valid responses (exactly 1 assertion)", () => {
|
|
8
|
+
it("should accept response with single assertion", () => {
|
|
9
|
+
const xml = `
|
|
10
|
+
<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
|
|
11
|
+
<saml:Assertion ID="123">
|
|
12
|
+
<saml:Subject><saml:NameID>user@example.com</saml:NameID></saml:Subject>
|
|
13
|
+
</saml:Assertion>
|
|
14
|
+
</samlp:Response>
|
|
15
|
+
`;
|
|
16
|
+
expect(() => validateSingleAssertion(encode(xml))).not.toThrow();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("should accept response with single encrypted assertion", () => {
|
|
20
|
+
const xml = `
|
|
21
|
+
<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
|
|
22
|
+
<saml:EncryptedAssertion>
|
|
23
|
+
<xenc:EncryptedData>...</xenc:EncryptedData>
|
|
24
|
+
</saml:EncryptedAssertion>
|
|
25
|
+
</samlp:Response>
|
|
26
|
+
`;
|
|
27
|
+
expect(() => validateSingleAssertion(encode(xml))).not.toThrow();
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("no assertions", () => {
|
|
32
|
+
it("should reject response with no assertions", () => {
|
|
33
|
+
const xml = `
|
|
34
|
+
<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol">
|
|
35
|
+
<samlp:Status>
|
|
36
|
+
<samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
|
|
37
|
+
</samlp:Status>
|
|
38
|
+
</samlp:Response>
|
|
39
|
+
`;
|
|
40
|
+
expect(() => validateSingleAssertion(encode(xml))).toThrow(
|
|
41
|
+
"SAML response contains no assertions",
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("multiple assertions", () => {
|
|
47
|
+
it("should reject response with multiple unencrypted assertions", () => {
|
|
48
|
+
const xml = `
|
|
49
|
+
<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
|
|
50
|
+
<saml:Assertion ID="assertion1">
|
|
51
|
+
<saml:Subject><saml:NameID>user@example.com</saml:NameID></saml:Subject>
|
|
52
|
+
</saml:Assertion>
|
|
53
|
+
<saml:Assertion ID="assertion2">
|
|
54
|
+
<saml:Subject><saml:NameID>attacker@evil.com</saml:NameID></saml:Subject>
|
|
55
|
+
</saml:Assertion>
|
|
56
|
+
</samlp:Response>
|
|
57
|
+
`;
|
|
58
|
+
expect(() => validateSingleAssertion(encode(xml))).toThrow(
|
|
59
|
+
"SAML response contains 2 assertions, expected exactly 1",
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("should reject response with multiple encrypted assertions", () => {
|
|
64
|
+
const xml = `
|
|
65
|
+
<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
|
|
66
|
+
<saml:EncryptedAssertion>
|
|
67
|
+
<xenc:EncryptedData>...</xenc:EncryptedData>
|
|
68
|
+
</saml:EncryptedAssertion>
|
|
69
|
+
<saml:EncryptedAssertion>
|
|
70
|
+
<xenc:EncryptedData>...</xenc:EncryptedData>
|
|
71
|
+
</saml:EncryptedAssertion>
|
|
72
|
+
</samlp:Response>
|
|
73
|
+
`;
|
|
74
|
+
expect(() => validateSingleAssertion(encode(xml))).toThrow(
|
|
75
|
+
"SAML response contains 2 assertions, expected exactly 1",
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("should reject response with mixed assertion types", () => {
|
|
80
|
+
const xml = `
|
|
81
|
+
<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
|
|
82
|
+
<saml:Assertion ID="plain-assertion">
|
|
83
|
+
<saml:Subject><saml:NameID>user@example.com</saml:NameID></saml:Subject>
|
|
84
|
+
</saml:Assertion>
|
|
85
|
+
<saml:EncryptedAssertion>
|
|
86
|
+
<xenc:EncryptedData>...</xenc:EncryptedData>
|
|
87
|
+
</saml:EncryptedAssertion>
|
|
88
|
+
</samlp:Response>
|
|
89
|
+
`;
|
|
90
|
+
expect(() => validateSingleAssertion(encode(xml))).toThrow(
|
|
91
|
+
"SAML response contains 2 assertions, expected exactly 1",
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("XSW attack patterns", () => {
|
|
97
|
+
it("should reject assertion injected in Extensions element", () => {
|
|
98
|
+
const xml = `
|
|
99
|
+
<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
|
|
100
|
+
<samlp:Extensions>
|
|
101
|
+
<saml:Assertion ID="injected-assertion">
|
|
102
|
+
<saml:Subject><saml:NameID>attacker@evil.com</saml:NameID></saml:Subject>
|
|
103
|
+
</saml:Assertion>
|
|
104
|
+
</samlp:Extensions>
|
|
105
|
+
<saml:Assertion ID="legitimate-assertion">
|
|
106
|
+
<saml:Subject><saml:NameID>user@example.com</saml:NameID></saml:Subject>
|
|
107
|
+
</saml:Assertion>
|
|
108
|
+
</samlp:Response>
|
|
109
|
+
`;
|
|
110
|
+
expect(() => validateSingleAssertion(encode(xml))).toThrow(
|
|
111
|
+
"SAML response contains 2 assertions, expected exactly 1",
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("should reject assertion wrapped in arbitrary element", () => {
|
|
116
|
+
const xml = `
|
|
117
|
+
<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
|
|
118
|
+
<Wrapper>
|
|
119
|
+
<saml:Assertion ID="wrapped-assertion">
|
|
120
|
+
<saml:Subject><saml:NameID>attacker@evil.com</saml:NameID></saml:Subject>
|
|
121
|
+
</saml:Assertion>
|
|
122
|
+
</Wrapper>
|
|
123
|
+
<saml:Assertion ID="legitimate-assertion">
|
|
124
|
+
<saml:Subject><saml:NameID>user@example.com</saml:NameID></saml:Subject>
|
|
125
|
+
</saml:Assertion>
|
|
126
|
+
</samlp:Response>
|
|
127
|
+
`;
|
|
128
|
+
expect(() => validateSingleAssertion(encode(xml))).toThrow(
|
|
129
|
+
"SAML response contains 2 assertions, expected exactly 1",
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("should reject deeply nested injected assertion", () => {
|
|
134
|
+
const xml = `
|
|
135
|
+
<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
|
|
136
|
+
<Level1>
|
|
137
|
+
<Level2>
|
|
138
|
+
<Level3>
|
|
139
|
+
<saml:Assertion ID="deep-injected">
|
|
140
|
+
<saml:Subject><saml:NameID>attacker@evil.com</saml:NameID></saml:Subject>
|
|
141
|
+
</saml:Assertion>
|
|
142
|
+
</Level3>
|
|
143
|
+
</Level2>
|
|
144
|
+
</Level1>
|
|
145
|
+
<saml:Assertion ID="legitimate-assertion">
|
|
146
|
+
<saml:Subject><saml:NameID>user@example.com</saml:NameID></saml:Subject>
|
|
147
|
+
</saml:Assertion>
|
|
148
|
+
</samlp:Response>
|
|
149
|
+
`;
|
|
150
|
+
expect(() => validateSingleAssertion(encode(xml))).toThrow(
|
|
151
|
+
"SAML response contains 2 assertions, expected exactly 1",
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("namespace handling", () => {
|
|
157
|
+
it("should handle assertion without namespace prefix", () => {
|
|
158
|
+
const xml = `
|
|
159
|
+
<Response>
|
|
160
|
+
<Assertion ID="123">
|
|
161
|
+
<Subject><NameID>user@example.com</NameID></Subject>
|
|
162
|
+
</Assertion>
|
|
163
|
+
</Response>
|
|
164
|
+
`;
|
|
165
|
+
expect(() => validateSingleAssertion(encode(xml))).not.toThrow();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("should handle assertion with saml2: prefix", () => {
|
|
169
|
+
const xml = `
|
|
170
|
+
<saml2p:Response xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">
|
|
171
|
+
<saml2:Assertion ID="123">
|
|
172
|
+
<saml2:Subject><saml2:NameID>user@example.com</saml2:NameID></saml2:Subject>
|
|
173
|
+
</saml2:Assertion>
|
|
174
|
+
</saml2p:Response>
|
|
175
|
+
`;
|
|
176
|
+
expect(() => validateSingleAssertion(encode(xml))).not.toThrow();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("should handle assertion with custom prefix", () => {
|
|
180
|
+
const xml = `
|
|
181
|
+
<custom:Response xmlns:custom="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:myprefix="urn:oasis:names:tc:SAML:2.0:assertion">
|
|
182
|
+
<myprefix:Assertion ID="123">
|
|
183
|
+
<myprefix:Subject><myprefix:NameID>user@example.com</myprefix:NameID></myprefix:Subject>
|
|
184
|
+
</myprefix:Assertion>
|
|
185
|
+
</custom:Response>
|
|
186
|
+
`;
|
|
187
|
+
expect(() => validateSingleAssertion(encode(xml))).not.toThrow();
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe("countAssertions", () => {
|
|
193
|
+
it("should return separate counts for assertions and encrypted assertions", () => {
|
|
194
|
+
const xml = `
|
|
195
|
+
<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
|
|
196
|
+
<saml:Assertion ID="plain">
|
|
197
|
+
<saml:Subject><saml:NameID>user@example.com</saml:NameID></saml:Subject>
|
|
198
|
+
</saml:Assertion>
|
|
199
|
+
<saml:EncryptedAssertion>
|
|
200
|
+
<xenc:EncryptedData>...</xenc:EncryptedData>
|
|
201
|
+
</saml:EncryptedAssertion>
|
|
202
|
+
</samlp:Response>
|
|
203
|
+
`;
|
|
204
|
+
const counts = countAssertions(xml);
|
|
205
|
+
expect(counts.assertions).toBe(1);
|
|
206
|
+
expect(counts.encryptedAssertions).toBe(1);
|
|
207
|
+
expect(counts.total).toBe(2);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("should not count AssertionConsumerService as assertion", () => {
|
|
211
|
+
const xml = `
|
|
212
|
+
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata">
|
|
213
|
+
<md:SPSSODescriptor>
|
|
214
|
+
<md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="http://example.com/acs"/>
|
|
215
|
+
</md:SPSSODescriptor>
|
|
216
|
+
</md:EntityDescriptor>
|
|
217
|
+
`;
|
|
218
|
+
const counts = countAssertions(xml);
|
|
219
|
+
expect(counts.assertions).toBe(0);
|
|
220
|
+
expect(counts.total).toBe(0);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe("error handling", () => {
|
|
225
|
+
const encode = (str: string) => Buffer.from(str).toString("base64");
|
|
226
|
+
|
|
227
|
+
it("should reject invalid base64 input", () => {
|
|
228
|
+
expect(() => validateSingleAssertion("not-valid-base64!!!")).toThrow(
|
|
229
|
+
"Invalid base64-encoded SAML response",
|
|
230
|
+
);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("should reject non-XML content", () => {
|
|
234
|
+
const notXml = encode("this is not xml at all");
|
|
235
|
+
expect(() => validateSingleAssertion(notXml)).toThrow(
|
|
236
|
+
"Invalid base64-encoded SAML response",
|
|
237
|
+
);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { base64 } from "@better-auth/utils/base64";
|
|
2
|
+
import { APIError } from "better-auth/api";
|
|
3
|
+
import { countAllNodes, xmlParser } from "./parser";
|
|
4
|
+
|
|
5
|
+
export interface AssertionCounts {
|
|
6
|
+
assertions: number;
|
|
7
|
+
encryptedAssertions: number;
|
|
8
|
+
total: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** @lintignore used in tests */
|
|
12
|
+
export function countAssertions(xml: string): AssertionCounts {
|
|
13
|
+
let parsed: unknown;
|
|
14
|
+
try {
|
|
15
|
+
parsed = xmlParser.parse(xml);
|
|
16
|
+
} catch {
|
|
17
|
+
throw new APIError("BAD_REQUEST", {
|
|
18
|
+
message: "Failed to parse SAML response XML",
|
|
19
|
+
code: "SAML_INVALID_XML",
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const assertions = countAllNodes(parsed, "Assertion");
|
|
24
|
+
const encryptedAssertions = countAllNodes(parsed, "EncryptedAssertion");
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
assertions,
|
|
28
|
+
encryptedAssertions,
|
|
29
|
+
total: assertions + encryptedAssertions,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function validateSingleAssertion(samlResponse: string): void {
|
|
34
|
+
let xml: string;
|
|
35
|
+
try {
|
|
36
|
+
xml = new TextDecoder().decode(base64.decode(samlResponse));
|
|
37
|
+
if (!xml.includes("<")) {
|
|
38
|
+
throw new Error("Not XML");
|
|
39
|
+
}
|
|
40
|
+
} catch {
|
|
41
|
+
throw new APIError("BAD_REQUEST", {
|
|
42
|
+
message: "Invalid base64-encoded SAML response",
|
|
43
|
+
code: "SAML_INVALID_ENCODING",
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const counts = countAssertions(xml);
|
|
48
|
+
|
|
49
|
+
if (counts.total === 0) {
|
|
50
|
+
throw new APIError("BAD_REQUEST", {
|
|
51
|
+
message: "SAML response contains no assertions",
|
|
52
|
+
code: "SAML_NO_ASSERTION",
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (counts.total > 1) {
|
|
57
|
+
throw new APIError("BAD_REQUEST", {
|
|
58
|
+
message: `SAML response contains ${counts.total} assertions, expected exactly 1`,
|
|
59
|
+
code: "SAML_MULTIPLE_ASSERTIONS",
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
package/src/saml/index.ts
CHANGED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { XMLParser } from "fast-xml-parser";
|
|
2
|
+
|
|
3
|
+
export const xmlParser = new XMLParser({
|
|
4
|
+
ignoreAttributes: false,
|
|
5
|
+
attributeNamePrefix: "@_",
|
|
6
|
+
removeNSPrefix: true,
|
|
7
|
+
processEntities: false,
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
export function findNode(obj: unknown, nodeName: string): unknown {
|
|
11
|
+
if (!obj || typeof obj !== "object") return null;
|
|
12
|
+
|
|
13
|
+
const record = obj as Record<string, unknown>;
|
|
14
|
+
|
|
15
|
+
if (nodeName in record) {
|
|
16
|
+
return record[nodeName];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
for (const value of Object.values(record)) {
|
|
20
|
+
if (Array.isArray(value)) {
|
|
21
|
+
for (const item of value) {
|
|
22
|
+
const found = findNode(item, nodeName);
|
|
23
|
+
if (found) return found;
|
|
24
|
+
}
|
|
25
|
+
} else if (typeof value === "object" && value !== null) {
|
|
26
|
+
const found = findNode(value, nodeName);
|
|
27
|
+
if (found) return found;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function countAllNodes(obj: unknown, nodeName: string): number {
|
|
35
|
+
if (!obj || typeof obj !== "object") return 0;
|
|
36
|
+
|
|
37
|
+
let count = 0;
|
|
38
|
+
const record = obj as Record<string, unknown>;
|
|
39
|
+
|
|
40
|
+
if (nodeName in record) {
|
|
41
|
+
const node = record[nodeName];
|
|
42
|
+
count += Array.isArray(node) ? node.length : 1;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
for (const value of Object.values(record)) {
|
|
46
|
+
if (Array.isArray(value)) {
|
|
47
|
+
for (const item of value) {
|
|
48
|
+
count += countAllNodes(item, nodeName);
|
|
49
|
+
}
|
|
50
|
+
} else if (typeof value === "object" && value !== null) {
|
|
51
|
+
count += countAllNodes(value, nodeName);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return count;
|
|
56
|
+
}
|
package/src/saml.test.ts
CHANGED
|
@@ -2614,3 +2614,351 @@ describe("SAML SSO - Assertion Replay Protection", () => {
|
|
|
2614
2614
|
expect(acsLocation).toContain("error=replay_detected");
|
|
2615
2615
|
});
|
|
2616
2616
|
});
|
|
2617
|
+
|
|
2618
|
+
describe("SAML SSO - Single Assertion Validation", () => {
|
|
2619
|
+
it("should reject SAML response with multiple assertions on callback endpoint", async () => {
|
|
2620
|
+
const { auth, signInWithTestUser } = await getTestInstance({
|
|
2621
|
+
plugins: [sso()],
|
|
2622
|
+
});
|
|
2623
|
+
|
|
2624
|
+
const { headers } = await signInWithTestUser();
|
|
2625
|
+
|
|
2626
|
+
await auth.api.registerSSOProvider({
|
|
2627
|
+
body: {
|
|
2628
|
+
providerId: "multi-assertion-callback-provider",
|
|
2629
|
+
issuer: "http://localhost:8081",
|
|
2630
|
+
domain: "http://localhost:8081",
|
|
2631
|
+
samlConfig: {
|
|
2632
|
+
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
|
|
2633
|
+
cert: certificate,
|
|
2634
|
+
callbackUrl: "http://localhost:3000/dashboard",
|
|
2635
|
+
wantAssertionsSigned: false,
|
|
2636
|
+
signatureAlgorithm: "sha256",
|
|
2637
|
+
digestAlgorithm: "sha256",
|
|
2638
|
+
idpMetadata: {
|
|
2639
|
+
metadata: idpMetadata,
|
|
2640
|
+
},
|
|
2641
|
+
spMetadata: {
|
|
2642
|
+
metadata: spMetadata,
|
|
2643
|
+
},
|
|
2644
|
+
identifierFormat:
|
|
2645
|
+
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
|
|
2646
|
+
},
|
|
2647
|
+
},
|
|
2648
|
+
headers,
|
|
2649
|
+
});
|
|
2650
|
+
|
|
2651
|
+
const multiAssertionResponse = `
|
|
2652
|
+
<saml2p:Response xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">
|
|
2653
|
+
<saml2:Issuer>http://localhost:8081</saml2:Issuer>
|
|
2654
|
+
<saml2p:Status>
|
|
2655
|
+
<saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
|
|
2656
|
+
</saml2p:Status>
|
|
2657
|
+
<saml2:Assertion ID="assertion-1">
|
|
2658
|
+
<saml2:Issuer>http://localhost:8081</saml2:Issuer>
|
|
2659
|
+
<saml2:Subject>
|
|
2660
|
+
<saml2:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">legitimate@example.com</saml2:NameID>
|
|
2661
|
+
</saml2:Subject>
|
|
2662
|
+
</saml2:Assertion>
|
|
2663
|
+
<saml2:Assertion ID="assertion-2">
|
|
2664
|
+
<saml2:Issuer>http://localhost:8081</saml2:Issuer>
|
|
2665
|
+
<saml2:Subject>
|
|
2666
|
+
<saml2:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">attacker@evil.com</saml2:NameID>
|
|
2667
|
+
</saml2:Subject>
|
|
2668
|
+
</saml2:Assertion>
|
|
2669
|
+
</saml2p:Response>
|
|
2670
|
+
`;
|
|
2671
|
+
|
|
2672
|
+
const encodedResponse = Buffer.from(multiAssertionResponse).toString(
|
|
2673
|
+
"base64",
|
|
2674
|
+
);
|
|
2675
|
+
|
|
2676
|
+
await expect(
|
|
2677
|
+
auth.api.callbackSSOSAML({
|
|
2678
|
+
body: {
|
|
2679
|
+
SAMLResponse: encodedResponse,
|
|
2680
|
+
RelayState: "http://localhost:3000/dashboard",
|
|
2681
|
+
},
|
|
2682
|
+
params: {
|
|
2683
|
+
providerId: "multi-assertion-callback-provider",
|
|
2684
|
+
},
|
|
2685
|
+
}),
|
|
2686
|
+
).rejects.toMatchObject({
|
|
2687
|
+
body: {
|
|
2688
|
+
code: "SAML_MULTIPLE_ASSERTIONS",
|
|
2689
|
+
},
|
|
2690
|
+
});
|
|
2691
|
+
});
|
|
2692
|
+
|
|
2693
|
+
it("should reject SAML response with multiple assertions on ACS endpoint", async () => {
|
|
2694
|
+
const { auth, signInWithTestUser } = await getTestInstance({
|
|
2695
|
+
plugins: [sso()],
|
|
2696
|
+
});
|
|
2697
|
+
|
|
2698
|
+
const { headers } = await signInWithTestUser();
|
|
2699
|
+
|
|
2700
|
+
await auth.api.registerSSOProvider({
|
|
2701
|
+
body: {
|
|
2702
|
+
providerId: "multi-assertion-acs-provider",
|
|
2703
|
+
issuer: "http://localhost:8081",
|
|
2704
|
+
domain: "http://localhost:8081",
|
|
2705
|
+
samlConfig: {
|
|
2706
|
+
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
|
|
2707
|
+
cert: certificate,
|
|
2708
|
+
callbackUrl: "http://localhost:3000/dashboard",
|
|
2709
|
+
wantAssertionsSigned: false,
|
|
2710
|
+
signatureAlgorithm: "sha256",
|
|
2711
|
+
digestAlgorithm: "sha256",
|
|
2712
|
+
idpMetadata: {
|
|
2713
|
+
metadata: idpMetadata,
|
|
2714
|
+
},
|
|
2715
|
+
spMetadata: {
|
|
2716
|
+
metadata: spMetadata,
|
|
2717
|
+
},
|
|
2718
|
+
identifierFormat:
|
|
2719
|
+
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
|
|
2720
|
+
},
|
|
2721
|
+
},
|
|
2722
|
+
headers,
|
|
2723
|
+
});
|
|
2724
|
+
|
|
2725
|
+
const multiAssertionResponse = `
|
|
2726
|
+
<saml2p:Response xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">
|
|
2727
|
+
<saml2:Issuer>http://localhost:8081</saml2:Issuer>
|
|
2728
|
+
<saml2p:Status>
|
|
2729
|
+
<saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
|
|
2730
|
+
</saml2p:Status>
|
|
2731
|
+
<saml2:Assertion ID="assertion-1">
|
|
2732
|
+
<saml2:Issuer>http://localhost:8081</saml2:Issuer>
|
|
2733
|
+
<saml2:Subject>
|
|
2734
|
+
<saml2:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">legitimate@example.com</saml2:NameID>
|
|
2735
|
+
</saml2:Subject>
|
|
2736
|
+
</saml2:Assertion>
|
|
2737
|
+
<saml2:Assertion ID="assertion-2">
|
|
2738
|
+
<saml2:Issuer>http://localhost:8081</saml2:Issuer>
|
|
2739
|
+
<saml2:Subject>
|
|
2740
|
+
<saml2:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">attacker@evil.com</saml2:NameID>
|
|
2741
|
+
</saml2:Subject>
|
|
2742
|
+
</saml2:Assertion>
|
|
2743
|
+
</saml2p:Response>
|
|
2744
|
+
`;
|
|
2745
|
+
|
|
2746
|
+
const encodedResponse = Buffer.from(multiAssertionResponse).toString(
|
|
2747
|
+
"base64",
|
|
2748
|
+
);
|
|
2749
|
+
|
|
2750
|
+
const response = await auth.handler(
|
|
2751
|
+
new Request(
|
|
2752
|
+
"http://localhost:3000/api/auth/sso/saml2/sp/acs/multi-assertion-acs-provider",
|
|
2753
|
+
{
|
|
2754
|
+
method: "POST",
|
|
2755
|
+
headers: {
|
|
2756
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
2757
|
+
},
|
|
2758
|
+
body: new URLSearchParams({
|
|
2759
|
+
SAMLResponse: encodedResponse,
|
|
2760
|
+
RelayState: "http://localhost:3000/dashboard",
|
|
2761
|
+
}),
|
|
2762
|
+
},
|
|
2763
|
+
),
|
|
2764
|
+
);
|
|
2765
|
+
|
|
2766
|
+
expect(response.status).toBe(302);
|
|
2767
|
+
const location = response.headers.get("location") || "";
|
|
2768
|
+
expect(location).toContain("error=multiple_assertions");
|
|
2769
|
+
});
|
|
2770
|
+
|
|
2771
|
+
it("should reject SAML response with no assertions", async () => {
|
|
2772
|
+
const { auth, signInWithTestUser } = await getTestInstance({
|
|
2773
|
+
plugins: [sso()],
|
|
2774
|
+
});
|
|
2775
|
+
|
|
2776
|
+
const { headers } = await signInWithTestUser();
|
|
2777
|
+
|
|
2778
|
+
await auth.api.registerSSOProvider({
|
|
2779
|
+
body: {
|
|
2780
|
+
providerId: "no-assertion-provider",
|
|
2781
|
+
issuer: "http://localhost:8081",
|
|
2782
|
+
domain: "http://localhost:8081",
|
|
2783
|
+
samlConfig: {
|
|
2784
|
+
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
|
|
2785
|
+
cert: certificate,
|
|
2786
|
+
callbackUrl: "http://localhost:3000/dashboard",
|
|
2787
|
+
wantAssertionsSigned: false,
|
|
2788
|
+
signatureAlgorithm: "sha256",
|
|
2789
|
+
digestAlgorithm: "sha256",
|
|
2790
|
+
idpMetadata: {
|
|
2791
|
+
metadata: idpMetadata,
|
|
2792
|
+
},
|
|
2793
|
+
spMetadata: {
|
|
2794
|
+
metadata: spMetadata,
|
|
2795
|
+
},
|
|
2796
|
+
identifierFormat:
|
|
2797
|
+
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
|
|
2798
|
+
},
|
|
2799
|
+
},
|
|
2800
|
+
headers,
|
|
2801
|
+
});
|
|
2802
|
+
|
|
2803
|
+
const noAssertionResponse = `
|
|
2804
|
+
<saml2p:Response xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">
|
|
2805
|
+
<saml2:Issuer>http://localhost:8081</saml2:Issuer>
|
|
2806
|
+
<saml2p:Status>
|
|
2807
|
+
<saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
|
|
2808
|
+
</saml2p:Status>
|
|
2809
|
+
</saml2p:Response>
|
|
2810
|
+
`;
|
|
2811
|
+
|
|
2812
|
+
const encodedResponse = Buffer.from(noAssertionResponse).toString("base64");
|
|
2813
|
+
|
|
2814
|
+
await expect(
|
|
2815
|
+
auth.api.callbackSSOSAML({
|
|
2816
|
+
body: {
|
|
2817
|
+
SAMLResponse: encodedResponse,
|
|
2818
|
+
RelayState: "http://localhost:3000/dashboard",
|
|
2819
|
+
},
|
|
2820
|
+
params: {
|
|
2821
|
+
providerId: "no-assertion-provider",
|
|
2822
|
+
},
|
|
2823
|
+
}),
|
|
2824
|
+
).rejects.toMatchObject({
|
|
2825
|
+
body: {
|
|
2826
|
+
code: "SAML_NO_ASSERTION",
|
|
2827
|
+
},
|
|
2828
|
+
});
|
|
2829
|
+
});
|
|
2830
|
+
|
|
2831
|
+
it("should reject SAML response with XSW-style assertion injection in Extensions", async () => {
|
|
2832
|
+
const { auth, signInWithTestUser } = await getTestInstance({
|
|
2833
|
+
plugins: [sso()],
|
|
2834
|
+
});
|
|
2835
|
+
|
|
2836
|
+
const { headers } = await signInWithTestUser();
|
|
2837
|
+
|
|
2838
|
+
await auth.api.registerSSOProvider({
|
|
2839
|
+
body: {
|
|
2840
|
+
providerId: "xsw-injection-provider",
|
|
2841
|
+
issuer: "http://localhost:8081",
|
|
2842
|
+
domain: "http://localhost:8081",
|
|
2843
|
+
samlConfig: {
|
|
2844
|
+
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
|
|
2845
|
+
cert: certificate,
|
|
2846
|
+
callbackUrl: "http://localhost:3000/dashboard",
|
|
2847
|
+
wantAssertionsSigned: false,
|
|
2848
|
+
signatureAlgorithm: "sha256",
|
|
2849
|
+
digestAlgorithm: "sha256",
|
|
2850
|
+
idpMetadata: {
|
|
2851
|
+
metadata: idpMetadata,
|
|
2852
|
+
},
|
|
2853
|
+
spMetadata: {
|
|
2854
|
+
metadata: spMetadata,
|
|
2855
|
+
},
|
|
2856
|
+
identifierFormat:
|
|
2857
|
+
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
|
|
2858
|
+
},
|
|
2859
|
+
},
|
|
2860
|
+
headers,
|
|
2861
|
+
});
|
|
2862
|
+
|
|
2863
|
+
const xswInjectionResponse = `
|
|
2864
|
+
<saml2p:Response xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">
|
|
2865
|
+
<saml2:Issuer>http://localhost:8081</saml2:Issuer>
|
|
2866
|
+
<saml2p:Status>
|
|
2867
|
+
<saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
|
|
2868
|
+
</saml2p:Status>
|
|
2869
|
+
<saml2p:Extensions>
|
|
2870
|
+
<saml2:Assertion ID="injected-assertion">
|
|
2871
|
+
<saml2:Issuer>http://localhost:8081</saml2:Issuer>
|
|
2872
|
+
<saml2:Subject>
|
|
2873
|
+
<saml2:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">attacker@evil.com</saml2:NameID>
|
|
2874
|
+
</saml2:Subject>
|
|
2875
|
+
</saml2:Assertion>
|
|
2876
|
+
</saml2p:Extensions>
|
|
2877
|
+
<saml2:Assertion ID="legitimate-assertion">
|
|
2878
|
+
<saml2:Issuer>http://localhost:8081</saml2:Issuer>
|
|
2879
|
+
<saml2:Subject>
|
|
2880
|
+
<saml2:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">user@example.com</saml2:NameID>
|
|
2881
|
+
</saml2:Subject>
|
|
2882
|
+
</saml2:Assertion>
|
|
2883
|
+
</saml2p:Response>
|
|
2884
|
+
`;
|
|
2885
|
+
|
|
2886
|
+
const encodedResponse =
|
|
2887
|
+
Buffer.from(xswInjectionResponse).toString("base64");
|
|
2888
|
+
|
|
2889
|
+
await expect(
|
|
2890
|
+
auth.api.callbackSSOSAML({
|
|
2891
|
+
body: {
|
|
2892
|
+
SAMLResponse: encodedResponse,
|
|
2893
|
+
RelayState: "http://localhost:3000/dashboard",
|
|
2894
|
+
},
|
|
2895
|
+
params: {
|
|
2896
|
+
providerId: "xsw-injection-provider",
|
|
2897
|
+
},
|
|
2898
|
+
}),
|
|
2899
|
+
).rejects.toMatchObject({
|
|
2900
|
+
body: {
|
|
2901
|
+
code: "SAML_MULTIPLE_ASSERTIONS",
|
|
2902
|
+
},
|
|
2903
|
+
});
|
|
2904
|
+
});
|
|
2905
|
+
|
|
2906
|
+
it("should accept valid SAML response with exactly one assertion", async () => {
|
|
2907
|
+
const { auth, signInWithTestUser } = await getTestInstance({
|
|
2908
|
+
plugins: [sso()],
|
|
2909
|
+
});
|
|
2910
|
+
|
|
2911
|
+
const { headers } = await signInWithTestUser();
|
|
2912
|
+
|
|
2913
|
+
await auth.api.registerSSOProvider({
|
|
2914
|
+
body: {
|
|
2915
|
+
providerId: "single-assertion-provider",
|
|
2916
|
+
issuer: "http://localhost:8081",
|
|
2917
|
+
domain: "http://localhost:8081",
|
|
2918
|
+
samlConfig: {
|
|
2919
|
+
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
|
|
2920
|
+
cert: certificate,
|
|
2921
|
+
callbackUrl: "http://localhost:3000/dashboard",
|
|
2922
|
+
wantAssertionsSigned: false,
|
|
2923
|
+
signatureAlgorithm: "sha256",
|
|
2924
|
+
digestAlgorithm: "sha256",
|
|
2925
|
+
idpMetadata: {
|
|
2926
|
+
metadata: idpMetadata,
|
|
2927
|
+
},
|
|
2928
|
+
spMetadata: {
|
|
2929
|
+
metadata: spMetadata,
|
|
2930
|
+
},
|
|
2931
|
+
identifierFormat:
|
|
2932
|
+
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
|
|
2933
|
+
},
|
|
2934
|
+
},
|
|
2935
|
+
headers,
|
|
2936
|
+
});
|
|
2937
|
+
|
|
2938
|
+
let samlResponse: any;
|
|
2939
|
+
await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
|
|
2940
|
+
onSuccess: async (context) => {
|
|
2941
|
+
samlResponse = await context.data;
|
|
2942
|
+
},
|
|
2943
|
+
});
|
|
2944
|
+
|
|
2945
|
+
const response = await auth.handler(
|
|
2946
|
+
new Request(
|
|
2947
|
+
"http://localhost:3000/api/auth/sso/saml2/callback/single-assertion-provider",
|
|
2948
|
+
{
|
|
2949
|
+
method: "POST",
|
|
2950
|
+
headers: {
|
|
2951
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
2952
|
+
},
|
|
2953
|
+
body: new URLSearchParams({
|
|
2954
|
+
SAMLResponse: samlResponse.samlResponse,
|
|
2955
|
+
RelayState: "http://localhost:3000/dashboard",
|
|
2956
|
+
}),
|
|
2957
|
+
},
|
|
2958
|
+
),
|
|
2959
|
+
);
|
|
2960
|
+
|
|
2961
|
+
expect(response.status).toBe(302);
|
|
2962
|
+
expect(response.headers.get("location")).not.toContain("error");
|
|
2963
|
+
});
|
|
2964
|
+
});
|