@better-auth/sso 1.4.6 → 1.4.7-beta.3
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 +8 -8
- package/dist/client.d.mts +1 -1
- package/dist/{index-D-JmJR9N.d.mts → index-m7FISidt.d.mts} +95 -21
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +160 -49
- package/package.json +6 -5
- package/src/authn-request-store.ts +76 -0
- package/src/authn-request.test.ts +99 -0
- package/src/index.ts +19 -7
- package/src/routes/sso.ts +225 -74
- package/src/saml.test.ts +500 -33
- package/src/types.ts +55 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
createInMemoryAuthnRequestStore,
|
|
4
|
+
DEFAULT_AUTHN_REQUEST_TTL_MS,
|
|
5
|
+
} from "./authn-request-store";
|
|
6
|
+
|
|
7
|
+
describe("AuthnRequest Store", () => {
|
|
8
|
+
describe("In-Memory Store", () => {
|
|
9
|
+
it("should save and retrieve an AuthnRequest record", async () => {
|
|
10
|
+
const store = createInMemoryAuthnRequestStore();
|
|
11
|
+
|
|
12
|
+
const record = {
|
|
13
|
+
id: "_test-request-id-1",
|
|
14
|
+
providerId: "saml-provider-1",
|
|
15
|
+
createdAt: Date.now(),
|
|
16
|
+
expiresAt: Date.now() + DEFAULT_AUTHN_REQUEST_TTL_MS,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
await store.save(record);
|
|
20
|
+
const retrieved = await store.get(record.id);
|
|
21
|
+
|
|
22
|
+
expect(retrieved).toEqual(record);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("should return null for non-existent request ID", async () => {
|
|
26
|
+
const store = createInMemoryAuthnRequestStore();
|
|
27
|
+
|
|
28
|
+
const retrieved = await store.get("_non-existent-id");
|
|
29
|
+
|
|
30
|
+
expect(retrieved).toBeNull();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("should return null for expired request ID", async () => {
|
|
34
|
+
const store = createInMemoryAuthnRequestStore();
|
|
35
|
+
|
|
36
|
+
const record = {
|
|
37
|
+
id: "_expired-request-id",
|
|
38
|
+
providerId: "saml-provider-1",
|
|
39
|
+
createdAt: Date.now() - 10000,
|
|
40
|
+
expiresAt: Date.now() - 1000, // Already expired
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
await store.save(record);
|
|
44
|
+
const retrieved = await store.get(record.id);
|
|
45
|
+
|
|
46
|
+
expect(retrieved).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("should delete a request ID", async () => {
|
|
50
|
+
const store = createInMemoryAuthnRequestStore();
|
|
51
|
+
|
|
52
|
+
const record = {
|
|
53
|
+
id: "_delete-me",
|
|
54
|
+
providerId: "saml-provider-1",
|
|
55
|
+
createdAt: Date.now(),
|
|
56
|
+
expiresAt: Date.now() + DEFAULT_AUTHN_REQUEST_TTL_MS,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
await store.save(record);
|
|
60
|
+
await store.delete(record.id);
|
|
61
|
+
|
|
62
|
+
const retrieved = await store.get(record.id);
|
|
63
|
+
expect(retrieved).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("should handle multiple providers with different request IDs", async () => {
|
|
67
|
+
const store = createInMemoryAuthnRequestStore();
|
|
68
|
+
|
|
69
|
+
const record1 = {
|
|
70
|
+
id: "_request-provider-1",
|
|
71
|
+
providerId: "saml-provider-1",
|
|
72
|
+
createdAt: Date.now(),
|
|
73
|
+
expiresAt: Date.now() + DEFAULT_AUTHN_REQUEST_TTL_MS,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const record2 = {
|
|
77
|
+
id: "_request-provider-2",
|
|
78
|
+
providerId: "saml-provider-2",
|
|
79
|
+
createdAt: Date.now(),
|
|
80
|
+
expiresAt: Date.now() + DEFAULT_AUTHN_REQUEST_TTL_MS,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
await store.save(record1);
|
|
84
|
+
await store.save(record2);
|
|
85
|
+
|
|
86
|
+
const retrieved1 = await store.get(record1.id);
|
|
87
|
+
const retrieved2 = await store.get(record2.id);
|
|
88
|
+
|
|
89
|
+
expect(retrieved1?.providerId).toBe("saml-provider-1");
|
|
90
|
+
expect(retrieved2?.providerId).toBe("saml-provider-2");
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("DEFAULT_AUTHN_REQUEST_TTL_MS", () => {
|
|
95
|
+
it("should be 5 minutes in milliseconds", () => {
|
|
96
|
+
expect(DEFAULT_AUTHN_REQUEST_TTL_MS).toBe(5 * 60 * 1000);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import type { BetterAuthPlugin } from "better-auth";
|
|
2
2
|
import { XMLValidator } from "fast-xml-parser";
|
|
3
3
|
import * as saml from "samlify";
|
|
4
|
+
import type {
|
|
5
|
+
AuthnRequestRecord,
|
|
6
|
+
AuthnRequestStore,
|
|
7
|
+
} from "./authn-request-store";
|
|
8
|
+
import {
|
|
9
|
+
createInMemoryAuthnRequestStore,
|
|
10
|
+
DEFAULT_AUTHN_REQUEST_TTL_MS,
|
|
11
|
+
} from "./authn-request-store";
|
|
4
12
|
import {
|
|
5
13
|
requestDomainVerification,
|
|
6
14
|
verifyDomain,
|
|
@@ -16,6 +24,8 @@ import {
|
|
|
16
24
|
import type { OIDCConfig, SAMLConfig, SSOOptions, SSOProvider } from "./types";
|
|
17
25
|
|
|
18
26
|
export type { SAMLConfig, OIDCConfig, SSOOptions, SSOProvider };
|
|
27
|
+
export type { AuthnRequestStore, AuthnRequestRecord };
|
|
28
|
+
export { createInMemoryAuthnRequestStore, DEFAULT_AUTHN_REQUEST_TTL_MS };
|
|
19
29
|
|
|
20
30
|
const fastValidator = {
|
|
21
31
|
async validate(xml: string) {
|
|
@@ -71,19 +81,21 @@ export function sso<O extends SSOOptions>(
|
|
|
71
81
|
};
|
|
72
82
|
|
|
73
83
|
export function sso<O extends SSOOptions>(options?: O | undefined): any {
|
|
84
|
+
const optionsWithStore = options as O;
|
|
85
|
+
|
|
74
86
|
let endpoints = {
|
|
75
87
|
spMetadata: spMetadata(),
|
|
76
|
-
registerSSOProvider: registerSSOProvider(
|
|
77
|
-
signInSSO: signInSSO(
|
|
78
|
-
callbackSSO: callbackSSO(
|
|
79
|
-
callbackSSOSAML: callbackSSOSAML(
|
|
80
|
-
acsEndpoint: acsEndpoint(
|
|
88
|
+
registerSSOProvider: registerSSOProvider(optionsWithStore),
|
|
89
|
+
signInSSO: signInSSO(optionsWithStore),
|
|
90
|
+
callbackSSO: callbackSSO(optionsWithStore),
|
|
91
|
+
callbackSSOSAML: callbackSSOSAML(optionsWithStore),
|
|
92
|
+
acsEndpoint: acsEndpoint(optionsWithStore),
|
|
81
93
|
};
|
|
82
94
|
|
|
83
95
|
if (options?.domainVerification?.enabled) {
|
|
84
96
|
const domainVerificationEndpoints = {
|
|
85
|
-
requestDomainVerification: requestDomainVerification(
|
|
86
|
-
verifyDomain: verifyDomain(
|
|
97
|
+
requestDomainVerification: requestDomainVerification(optionsWithStore),
|
|
98
|
+
verifyDomain: verifyDomain(optionsWithStore),
|
|
87
99
|
};
|
|
88
100
|
|
|
89
101
|
endpoints = {
|
package/src/routes/sso.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { Account, Session, User, Verification } from "better-auth";
|
|
|
3
3
|
import {
|
|
4
4
|
createAuthorizationURL,
|
|
5
5
|
generateState,
|
|
6
|
+
HIDE_METADATA,
|
|
6
7
|
parseState,
|
|
7
8
|
validateAuthorizationCode,
|
|
8
9
|
validateToken,
|
|
@@ -21,9 +22,13 @@ import type { BindingContext } from "samlify/types/src/entity";
|
|
|
21
22
|
import type { IdentityProvider } from "samlify/types/src/entity-idp";
|
|
22
23
|
import type { FlowResult } from "samlify/types/src/flow";
|
|
23
24
|
import * as z from "zod/v4";
|
|
25
|
+
import type { AuthnRequestRecord } from "../authn-request-store";
|
|
26
|
+
import { DEFAULT_AUTHN_REQUEST_TTL_MS } from "../authn-request-store";
|
|
24
27
|
import type { OIDCConfig, SAMLConfig, SSOOptions, SSOProvider } from "../types";
|
|
28
|
+
|
|
25
29
|
import { safeJsonParse, validateEmailDomain } from "../utils";
|
|
26
30
|
|
|
31
|
+
const AUTHN_REQUEST_KEY_PREFIX = "saml-authn-request:";
|
|
27
32
|
const spMetadataQuerySchema = z.object({
|
|
28
33
|
providerId: z.string(),
|
|
29
34
|
format: z.enum(["xml", "json"]).default("xml"),
|
|
@@ -1054,12 +1059,40 @@ export const signInSSO = (options?: SSOOptions) => {
|
|
|
1054
1059
|
const loginRequest = sp.createLoginRequest(
|
|
1055
1060
|
idp,
|
|
1056
1061
|
"redirect",
|
|
1057
|
-
) as BindingContext & {
|
|
1062
|
+
) as BindingContext & {
|
|
1063
|
+
entityEndpoint: string;
|
|
1064
|
+
type: string;
|
|
1065
|
+
id: string;
|
|
1066
|
+
};
|
|
1058
1067
|
if (!loginRequest) {
|
|
1059
1068
|
throw new APIError("BAD_REQUEST", {
|
|
1060
1069
|
message: "Invalid SAML request",
|
|
1061
1070
|
});
|
|
1062
1071
|
}
|
|
1072
|
+
|
|
1073
|
+
const shouldSaveRequest =
|
|
1074
|
+
loginRequest.id &&
|
|
1075
|
+
(options?.saml?.authnRequestStore ||
|
|
1076
|
+
options?.saml?.enableInResponseToValidation);
|
|
1077
|
+
if (shouldSaveRequest) {
|
|
1078
|
+
const ttl = options?.saml?.requestTTL ?? DEFAULT_AUTHN_REQUEST_TTL_MS;
|
|
1079
|
+
const record: AuthnRequestRecord = {
|
|
1080
|
+
id: loginRequest.id,
|
|
1081
|
+
providerId: provider.providerId,
|
|
1082
|
+
createdAt: Date.now(),
|
|
1083
|
+
expiresAt: Date.now() + ttl,
|
|
1084
|
+
};
|
|
1085
|
+
if (options?.saml?.authnRequestStore) {
|
|
1086
|
+
await options.saml.authnRequestStore.save(record);
|
|
1087
|
+
} else {
|
|
1088
|
+
await ctx.context.internalAdapter.createVerificationValue({
|
|
1089
|
+
identifier: `${AUTHN_REQUEST_KEY_PREFIX}${record.id}`,
|
|
1090
|
+
value: JSON.stringify(record),
|
|
1091
|
+
expiresAt: new Date(record.expiresAt),
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1063
1096
|
return ctx.json({
|
|
1064
1097
|
url: `${loginRequest.context}&RelayState=${encodeURIComponent(
|
|
1065
1098
|
body.callbackURL,
|
|
@@ -1092,7 +1125,7 @@ export const callbackSSO = (options?: SSOOptions) => {
|
|
|
1092
1125
|
"application/json",
|
|
1093
1126
|
],
|
|
1094
1127
|
metadata: {
|
|
1095
|
-
|
|
1128
|
+
...HIDE_METADATA,
|
|
1096
1129
|
openapi: {
|
|
1097
1130
|
operationId: "handleSSOCallback",
|
|
1098
1131
|
summary: "Callback URL for SSO provider",
|
|
@@ -1107,7 +1140,7 @@ export const callbackSSO = (options?: SSOOptions) => {
|
|
|
1107
1140
|
},
|
|
1108
1141
|
},
|
|
1109
1142
|
async (ctx) => {
|
|
1110
|
-
const { code,
|
|
1143
|
+
const { code, error, error_description } = ctx.query;
|
|
1111
1144
|
const stateData = await parseState(ctx);
|
|
1112
1145
|
if (!stateData) {
|
|
1113
1146
|
const errorURL =
|
|
@@ -1461,7 +1494,7 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
|
|
|
1461
1494
|
method: "POST",
|
|
1462
1495
|
body: callbackSSOSAMLBodySchema,
|
|
1463
1496
|
metadata: {
|
|
1464
|
-
|
|
1497
|
+
...HIDE_METADATA,
|
|
1465
1498
|
allowedMediaTypes: [
|
|
1466
1499
|
"application/x-www-form-urlencoded",
|
|
1467
1500
|
"application/json",
|
|
@@ -1603,31 +1636,12 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
|
|
|
1603
1636
|
|
|
1604
1637
|
let parsedResponse: FlowResult;
|
|
1605
1638
|
try {
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
body: {
|
|
1613
|
-
SAMLResponse,
|
|
1614
|
-
RelayState: RelayState || undefined,
|
|
1615
|
-
},
|
|
1616
|
-
});
|
|
1617
|
-
} catch (parseError) {
|
|
1618
|
-
const nameIDMatch = decodedResponse.match(
|
|
1619
|
-
/<saml2:NameID[^>]*>([^<]+)<\/saml2:NameID>/,
|
|
1620
|
-
);
|
|
1621
|
-
if (!nameIDMatch) throw parseError;
|
|
1622
|
-
parsedResponse = {
|
|
1623
|
-
extract: {
|
|
1624
|
-
nameID: nameIDMatch[1],
|
|
1625
|
-
attributes: { nameID: nameIDMatch[1] },
|
|
1626
|
-
sessionIndex: {},
|
|
1627
|
-
conditions: {},
|
|
1628
|
-
},
|
|
1629
|
-
} as FlowResult;
|
|
1630
|
-
}
|
|
1639
|
+
parsedResponse = await sp.parseLoginResponse(idp, "post", {
|
|
1640
|
+
body: {
|
|
1641
|
+
SAMLResponse,
|
|
1642
|
+
RelayState: RelayState || undefined,
|
|
1643
|
+
},
|
|
1644
|
+
});
|
|
1631
1645
|
|
|
1632
1646
|
if (!parsedResponse?.extract) {
|
|
1633
1647
|
throw new Error("Invalid SAML response structure");
|
|
@@ -1646,6 +1660,93 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
|
|
|
1646
1660
|
}
|
|
1647
1661
|
|
|
1648
1662
|
const { extract } = parsedResponse!;
|
|
1663
|
+
|
|
1664
|
+
const inResponseTo = (extract as any).inResponseTo as string | undefined;
|
|
1665
|
+
const shouldValidateInResponseTo =
|
|
1666
|
+
options?.saml?.authnRequestStore ||
|
|
1667
|
+
options?.saml?.enableInResponseToValidation;
|
|
1668
|
+
|
|
1669
|
+
if (shouldValidateInResponseTo) {
|
|
1670
|
+
const allowIdpInitiated = options?.saml?.allowIdpInitiated !== false;
|
|
1671
|
+
|
|
1672
|
+
if (inResponseTo) {
|
|
1673
|
+
let storedRequest: AuthnRequestRecord | null = null;
|
|
1674
|
+
|
|
1675
|
+
if (options?.saml?.authnRequestStore) {
|
|
1676
|
+
storedRequest =
|
|
1677
|
+
await options.saml.authnRequestStore.get(inResponseTo);
|
|
1678
|
+
} else {
|
|
1679
|
+
const verification =
|
|
1680
|
+
await ctx.context.internalAdapter.findVerificationValue(
|
|
1681
|
+
`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`,
|
|
1682
|
+
);
|
|
1683
|
+
if (verification) {
|
|
1684
|
+
try {
|
|
1685
|
+
storedRequest = JSON.parse(
|
|
1686
|
+
verification.value,
|
|
1687
|
+
) as AuthnRequestRecord;
|
|
1688
|
+
} catch {
|
|
1689
|
+
storedRequest = null;
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
if (!storedRequest) {
|
|
1695
|
+
ctx.context.logger.error(
|
|
1696
|
+
"SAML InResponseTo validation failed: unknown or expired request ID",
|
|
1697
|
+
{ inResponseTo, providerId: provider.providerId },
|
|
1698
|
+
);
|
|
1699
|
+
const redirectUrl =
|
|
1700
|
+
RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
1701
|
+
throw ctx.redirect(
|
|
1702
|
+
`${redirectUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`,
|
|
1703
|
+
);
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
if (storedRequest.providerId !== provider.providerId) {
|
|
1707
|
+
ctx.context.logger.error(
|
|
1708
|
+
"SAML InResponseTo validation failed: provider mismatch",
|
|
1709
|
+
{
|
|
1710
|
+
inResponseTo,
|
|
1711
|
+
expectedProvider: storedRequest.providerId,
|
|
1712
|
+
actualProvider: provider.providerId,
|
|
1713
|
+
},
|
|
1714
|
+
);
|
|
1715
|
+
|
|
1716
|
+
if (options?.saml?.authnRequestStore) {
|
|
1717
|
+
await options.saml.authnRequestStore.delete(inResponseTo);
|
|
1718
|
+
} else {
|
|
1719
|
+
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
|
1720
|
+
`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`,
|
|
1721
|
+
);
|
|
1722
|
+
}
|
|
1723
|
+
const redirectUrl =
|
|
1724
|
+
RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
1725
|
+
throw ctx.redirect(
|
|
1726
|
+
`${redirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`,
|
|
1727
|
+
);
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
if (options?.saml?.authnRequestStore) {
|
|
1731
|
+
await options.saml.authnRequestStore.delete(inResponseTo);
|
|
1732
|
+
} else {
|
|
1733
|
+
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
|
1734
|
+
`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`,
|
|
1735
|
+
);
|
|
1736
|
+
}
|
|
1737
|
+
} else if (!allowIdpInitiated) {
|
|
1738
|
+
ctx.context.logger.error(
|
|
1739
|
+
"SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false",
|
|
1740
|
+
{ providerId: provider.providerId },
|
|
1741
|
+
);
|
|
1742
|
+
const redirectUrl =
|
|
1743
|
+
RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
1744
|
+
throw ctx.redirect(
|
|
1745
|
+
`${redirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`,
|
|
1746
|
+
);
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1649
1750
|
const attributes = extract.attributes || {};
|
|
1650
1751
|
const mapping = parsedSamlConfig.mapping ?? {};
|
|
1651
1752
|
|
|
@@ -1831,7 +1932,7 @@ export const acsEndpoint = (options?: SSOOptions) => {
|
|
|
1831
1932
|
params: acsEndpointParamsSchema,
|
|
1832
1933
|
body: acsEndpointBodySchema,
|
|
1833
1934
|
metadata: {
|
|
1834
|
-
|
|
1935
|
+
...HIDE_METADATA,
|
|
1835
1936
|
allowedMediaTypes: [
|
|
1836
1937
|
"application/x-www-form-urlencoded",
|
|
1837
1938
|
"application/json",
|
|
@@ -1960,50 +2061,12 @@ export const acsEndpoint = (options?: SSOOptions) => {
|
|
|
1960
2061
|
// Parse and validate SAML response
|
|
1961
2062
|
let parsedResponse: FlowResult;
|
|
1962
2063
|
try {
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
// Insert a success status if missing
|
|
1970
|
-
const insertPoint = decodedResponse.indexOf("</saml2:Issuer>");
|
|
1971
|
-
if (insertPoint !== -1) {
|
|
1972
|
-
decodedResponse =
|
|
1973
|
-
decodedResponse.slice(0, insertPoint + 14) +
|
|
1974
|
-
'<saml2:Status><saml2:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></saml2:Status>' +
|
|
1975
|
-
decodedResponse.slice(insertPoint + 14);
|
|
1976
|
-
}
|
|
1977
|
-
} else if (!decodedResponse.includes("saml2:Success")) {
|
|
1978
|
-
// Replace existing non-success status with success
|
|
1979
|
-
decodedResponse = decodedResponse.replace(
|
|
1980
|
-
/<saml2:StatusCode Value="[^"]+"/,
|
|
1981
|
-
'<saml2:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"',
|
|
1982
|
-
);
|
|
1983
|
-
}
|
|
1984
|
-
|
|
1985
|
-
try {
|
|
1986
|
-
parsedResponse = await sp.parseLoginResponse(idp, "post", {
|
|
1987
|
-
body: {
|
|
1988
|
-
SAMLResponse,
|
|
1989
|
-
RelayState: RelayState || undefined,
|
|
1990
|
-
},
|
|
1991
|
-
});
|
|
1992
|
-
} catch (parseError) {
|
|
1993
|
-
const nameIDMatch = decodedResponse.match(
|
|
1994
|
-
/<saml2:NameID[^>]*>([^<]+)<\/saml2:NameID>/,
|
|
1995
|
-
);
|
|
1996
|
-
// due to different spec. we have to make sure to handle that.
|
|
1997
|
-
if (!nameIDMatch) throw parseError;
|
|
1998
|
-
parsedResponse = {
|
|
1999
|
-
extract: {
|
|
2000
|
-
nameID: nameIDMatch[1],
|
|
2001
|
-
attributes: { nameID: nameIDMatch[1] },
|
|
2002
|
-
sessionIndex: {},
|
|
2003
|
-
conditions: {},
|
|
2004
|
-
},
|
|
2005
|
-
} as FlowResult;
|
|
2006
|
-
}
|
|
2064
|
+
parsedResponse = await sp.parseLoginResponse(idp, "post", {
|
|
2065
|
+
body: {
|
|
2066
|
+
SAMLResponse,
|
|
2067
|
+
RelayState: RelayState || undefined,
|
|
2068
|
+
},
|
|
2069
|
+
});
|
|
2007
2070
|
|
|
2008
2071
|
if (!parsedResponse?.extract) {
|
|
2009
2072
|
throw new Error("Invalid SAML response structure");
|
|
@@ -2022,6 +2085,94 @@ export const acsEndpoint = (options?: SSOOptions) => {
|
|
|
2022
2085
|
}
|
|
2023
2086
|
|
|
2024
2087
|
const { extract } = parsedResponse!;
|
|
2088
|
+
|
|
2089
|
+
const inResponseToAcs = (extract as any).inResponseTo as
|
|
2090
|
+
| string
|
|
2091
|
+
| undefined;
|
|
2092
|
+
const shouldValidateInResponseToAcs =
|
|
2093
|
+
options?.saml?.authnRequestStore ||
|
|
2094
|
+
options?.saml?.enableInResponseToValidation;
|
|
2095
|
+
|
|
2096
|
+
if (shouldValidateInResponseToAcs) {
|
|
2097
|
+
const allowIdpInitiated = options?.saml?.allowIdpInitiated !== false;
|
|
2098
|
+
|
|
2099
|
+
if (inResponseToAcs) {
|
|
2100
|
+
let storedRequest: AuthnRequestRecord | null = null;
|
|
2101
|
+
|
|
2102
|
+
if (options?.saml?.authnRequestStore) {
|
|
2103
|
+
storedRequest =
|
|
2104
|
+
await options.saml.authnRequestStore.get(inResponseToAcs);
|
|
2105
|
+
} else {
|
|
2106
|
+
const verification =
|
|
2107
|
+
await ctx.context.internalAdapter.findVerificationValue(
|
|
2108
|
+
`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`,
|
|
2109
|
+
);
|
|
2110
|
+
if (verification) {
|
|
2111
|
+
try {
|
|
2112
|
+
storedRequest = JSON.parse(
|
|
2113
|
+
verification.value,
|
|
2114
|
+
) as AuthnRequestRecord;
|
|
2115
|
+
} catch {
|
|
2116
|
+
storedRequest = null;
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
if (!storedRequest) {
|
|
2122
|
+
ctx.context.logger.error(
|
|
2123
|
+
"SAML InResponseTo validation failed: unknown or expired request ID",
|
|
2124
|
+
{ inResponseTo: inResponseToAcs, providerId },
|
|
2125
|
+
);
|
|
2126
|
+
const redirectUrl =
|
|
2127
|
+
RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2128
|
+
throw ctx.redirect(
|
|
2129
|
+
`${redirectUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`,
|
|
2130
|
+
);
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
if (storedRequest.providerId !== providerId) {
|
|
2134
|
+
ctx.context.logger.error(
|
|
2135
|
+
"SAML InResponseTo validation failed: provider mismatch",
|
|
2136
|
+
{
|
|
2137
|
+
inResponseTo: inResponseToAcs,
|
|
2138
|
+
expectedProvider: storedRequest.providerId,
|
|
2139
|
+
actualProvider: providerId,
|
|
2140
|
+
},
|
|
2141
|
+
);
|
|
2142
|
+
if (options?.saml?.authnRequestStore) {
|
|
2143
|
+
await options.saml.authnRequestStore.delete(inResponseToAcs);
|
|
2144
|
+
} else {
|
|
2145
|
+
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
|
2146
|
+
`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`,
|
|
2147
|
+
);
|
|
2148
|
+
}
|
|
2149
|
+
const redirectUrl =
|
|
2150
|
+
RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2151
|
+
throw ctx.redirect(
|
|
2152
|
+
`${redirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`,
|
|
2153
|
+
);
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
if (options?.saml?.authnRequestStore) {
|
|
2157
|
+
await options.saml.authnRequestStore.delete(inResponseToAcs);
|
|
2158
|
+
} else {
|
|
2159
|
+
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
|
2160
|
+
`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`,
|
|
2161
|
+
);
|
|
2162
|
+
}
|
|
2163
|
+
} else if (!allowIdpInitiated) {
|
|
2164
|
+
ctx.context.logger.error(
|
|
2165
|
+
"SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false",
|
|
2166
|
+
{ providerId },
|
|
2167
|
+
);
|
|
2168
|
+
const redirectUrl =
|
|
2169
|
+
RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2170
|
+
throw ctx.redirect(
|
|
2171
|
+
`${redirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`,
|
|
2172
|
+
);
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2025
2176
|
const attributes = extract.attributes || {};
|
|
2026
2177
|
const mapping = parsedSamlConfig.mapping ?? {};
|
|
2027
2178
|
|