@better-auth/sso 1.4.7-beta.2 → 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 +7 -7
- package/dist/client.d.mts +1 -1
- package/dist/{index-BWvN4yrs.d.mts → index-m7FISidt.d.mts} +101 -33
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +159 -48
- package/package.json +4 -4
- 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 +224 -73
- package/src/saml.test.ts +490 -1
- package/src/types.ts +49 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AuthnRequest Store
|
|
3
|
+
*
|
|
4
|
+
* Tracks SAML AuthnRequest IDs to enable InResponseTo validation.
|
|
5
|
+
* This prevents:
|
|
6
|
+
* - Unsolicited SAML responses
|
|
7
|
+
* - Cross-provider response injection
|
|
8
|
+
* - Replay attacks
|
|
9
|
+
* - Expired login completions
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface AuthnRequestRecord {
|
|
13
|
+
id: string;
|
|
14
|
+
providerId: string;
|
|
15
|
+
createdAt: number;
|
|
16
|
+
expiresAt: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface AuthnRequestStore {
|
|
20
|
+
save(record: AuthnRequestRecord): Promise<void>;
|
|
21
|
+
get(id: string): Promise<AuthnRequestRecord | null>;
|
|
22
|
+
delete(id: string): Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Default TTL for AuthnRequest records (5 minutes).
|
|
27
|
+
* This should be sufficient for most IdPs while protecting against stale requests.
|
|
28
|
+
*/
|
|
29
|
+
export const DEFAULT_AUTHN_REQUEST_TTL_MS = 5 * 60 * 1000;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* In-memory implementation of AuthnRequestStore.
|
|
33
|
+
* ⚠️ Only suitable for testing or single-instance non-serverless deployments.
|
|
34
|
+
* For production, rely on the default behavior (uses verification table)
|
|
35
|
+
* or provide a custom Redis-backed store.
|
|
36
|
+
*/
|
|
37
|
+
export function createInMemoryAuthnRequestStore(): AuthnRequestStore {
|
|
38
|
+
const store = new Map<string, AuthnRequestRecord>();
|
|
39
|
+
|
|
40
|
+
const cleanup = () => {
|
|
41
|
+
const now = Date.now();
|
|
42
|
+
for (const [id, record] of store.entries()) {
|
|
43
|
+
if (record.expiresAt < now) {
|
|
44
|
+
store.delete(id);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const cleanupInterval = setInterval(cleanup, 60 * 1000);
|
|
50
|
+
|
|
51
|
+
if (typeof cleanupInterval.unref === "function") {
|
|
52
|
+
cleanupInterval.unref();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
async save(record: AuthnRequestRecord): Promise<void> {
|
|
57
|
+
store.set(record.id, record);
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
async get(id: string): Promise<AuthnRequestRecord | null> {
|
|
61
|
+
const record = store.get(id);
|
|
62
|
+
if (!record) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
if (record.expiresAt < Date.now()) {
|
|
66
|
+
store.delete(id);
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
return record;
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
async delete(id: string): Promise<void> {
|
|
73
|
+
store.delete(id);
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -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",
|
|
@@ -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
|
|