@better-auth/sso 1.4.7-beta.2 → 1.4.7-beta.4
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-GoyGoP_a.d.mts} +390 -21
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +559 -63
- 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 +46 -7
- package/src/oidc/discovery.test.ts +823 -0
- package/src/oidc/discovery.ts +355 -0
- package/src/oidc/errors.ts +86 -0
- package/src/oidc/index.ts +31 -0
- package/src/oidc/types.ts +210 -0
- package/src/oidc.test.ts +0 -164
- package/src/routes/sso.ts +415 -96
- package/src/saml.test.ts +781 -48
- package/src/types.ts +81 -0
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,100 @@ 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";
|
|
27
|
+
import type { HydratedOIDCConfig } from "../oidc";
|
|
28
|
+
import {
|
|
29
|
+
DiscoveryError,
|
|
30
|
+
discoverOIDCConfig,
|
|
31
|
+
mapDiscoveryErrorToAPIError,
|
|
32
|
+
} from "../oidc";
|
|
24
33
|
import type { OIDCConfig, SAMLConfig, SSOOptions, SSOProvider } from "../types";
|
|
34
|
+
|
|
25
35
|
import { safeJsonParse, validateEmailDomain } from "../utils";
|
|
26
36
|
|
|
37
|
+
const AUTHN_REQUEST_KEY_PREFIX = "saml-authn-request:";
|
|
38
|
+
|
|
39
|
+
/** Default clock skew tolerance: 5 minutes */
|
|
40
|
+
export const DEFAULT_CLOCK_SKEW_MS = 5 * 60 * 1000;
|
|
41
|
+
|
|
42
|
+
export interface TimestampValidationOptions {
|
|
43
|
+
clockSkew?: number;
|
|
44
|
+
requireTimestamps?: boolean;
|
|
45
|
+
logger?: {
|
|
46
|
+
warn: (message: string, data?: Record<string, unknown>) => void;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Conditions extracted from SAML assertion */
|
|
51
|
+
export interface SAMLConditions {
|
|
52
|
+
notBefore?: string;
|
|
53
|
+
notOnOrAfter?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Validates SAML assertion timestamp conditions (NotBefore/NotOnOrAfter).
|
|
58
|
+
* Prevents acceptance of expired or future-dated assertions.
|
|
59
|
+
* @throws {APIError} If timestamps are invalid, expired, or not yet valid
|
|
60
|
+
*/
|
|
61
|
+
export function validateSAMLTimestamp(
|
|
62
|
+
conditions: SAMLConditions | undefined,
|
|
63
|
+
options: TimestampValidationOptions = {},
|
|
64
|
+
): void {
|
|
65
|
+
const clockSkew = options.clockSkew ?? DEFAULT_CLOCK_SKEW_MS;
|
|
66
|
+
const hasTimestamps = conditions?.notBefore || conditions?.notOnOrAfter;
|
|
67
|
+
|
|
68
|
+
if (!hasTimestamps) {
|
|
69
|
+
if (options.requireTimestamps) {
|
|
70
|
+
throw new APIError("BAD_REQUEST", {
|
|
71
|
+
message: "SAML assertion missing required timestamp conditions",
|
|
72
|
+
details:
|
|
73
|
+
"Assertions must include NotBefore and/or NotOnOrAfter conditions",
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
// Log warning for missing timestamps when not required
|
|
77
|
+
options.logger?.warn(
|
|
78
|
+
"SAML assertion accepted without timestamp conditions",
|
|
79
|
+
{ hasConditions: !!conditions },
|
|
80
|
+
);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const now = Date.now();
|
|
85
|
+
|
|
86
|
+
if (conditions?.notBefore) {
|
|
87
|
+
const notBeforeTime = new Date(conditions.notBefore).getTime();
|
|
88
|
+
if (Number.isNaN(notBeforeTime)) {
|
|
89
|
+
throw new APIError("BAD_REQUEST", {
|
|
90
|
+
message: "SAML assertion has invalid NotBefore timestamp",
|
|
91
|
+
details: `Unable to parse NotBefore value: ${conditions.notBefore}`,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
if (now < notBeforeTime - clockSkew) {
|
|
95
|
+
throw new APIError("BAD_REQUEST", {
|
|
96
|
+
message: "SAML assertion is not yet valid",
|
|
97
|
+
details: `Current time is before NotBefore (with ${clockSkew}ms clock skew tolerance)`,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (conditions?.notOnOrAfter) {
|
|
103
|
+
const notOnOrAfterTime = new Date(conditions.notOnOrAfter).getTime();
|
|
104
|
+
if (Number.isNaN(notOnOrAfterTime)) {
|
|
105
|
+
throw new APIError("BAD_REQUEST", {
|
|
106
|
+
message: "SAML assertion has invalid NotOnOrAfter timestamp",
|
|
107
|
+
details: `Unable to parse NotOnOrAfter value: ${conditions.notOnOrAfter}`,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
if (now > notOnOrAfterTime + clockSkew) {
|
|
111
|
+
throw new APIError("BAD_REQUEST", {
|
|
112
|
+
message: "SAML assertion has expired",
|
|
113
|
+
details: `Current time is after NotOnOrAfter (with ${clockSkew}ms clock skew tolerance)`,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
27
119
|
const spMetadataQuerySchema = z.object({
|
|
28
120
|
providerId: z.string(),
|
|
29
121
|
format: z.enum(["xml", "json"]).default("xml"),
|
|
@@ -149,6 +241,13 @@ const ssoProviderBodySchema = z.object({
|
|
|
149
241
|
})
|
|
150
242
|
.optional(),
|
|
151
243
|
discoveryEndpoint: z.string().optional(),
|
|
244
|
+
skipDiscovery: z
|
|
245
|
+
.boolean()
|
|
246
|
+
.meta({
|
|
247
|
+
description:
|
|
248
|
+
"Skip OIDC discovery during registration. When true, you must provide authorizationEndpoint, tokenEndpoint, and jwksEndpoint manually.",
|
|
249
|
+
})
|
|
250
|
+
.optional(),
|
|
152
251
|
scopes: z
|
|
153
252
|
.array(z.string(), {})
|
|
154
253
|
.meta({
|
|
@@ -568,6 +667,80 @@ export const registerSSOProvider = <O extends SSOOptions>(options: O) => {
|
|
|
568
667
|
});
|
|
569
668
|
}
|
|
570
669
|
|
|
670
|
+
let hydratedOIDCConfig: HydratedOIDCConfig | null = null;
|
|
671
|
+
if (body.oidcConfig && !body.oidcConfig.skipDiscovery) {
|
|
672
|
+
try {
|
|
673
|
+
hydratedOIDCConfig = await discoverOIDCConfig({
|
|
674
|
+
issuer: body.issuer,
|
|
675
|
+
existingConfig: {
|
|
676
|
+
discoveryEndpoint: body.oidcConfig.discoveryEndpoint,
|
|
677
|
+
authorizationEndpoint: body.oidcConfig.authorizationEndpoint,
|
|
678
|
+
tokenEndpoint: body.oidcConfig.tokenEndpoint,
|
|
679
|
+
jwksEndpoint: body.oidcConfig.jwksEndpoint,
|
|
680
|
+
userInfoEndpoint: body.oidcConfig.userInfoEndpoint,
|
|
681
|
+
tokenEndpointAuthentication:
|
|
682
|
+
body.oidcConfig.tokenEndpointAuthentication,
|
|
683
|
+
},
|
|
684
|
+
});
|
|
685
|
+
} catch (error) {
|
|
686
|
+
if (error instanceof DiscoveryError) {
|
|
687
|
+
throw mapDiscoveryErrorToAPIError(error);
|
|
688
|
+
}
|
|
689
|
+
throw error;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const buildOIDCConfig = () => {
|
|
694
|
+
if (!body.oidcConfig) return null;
|
|
695
|
+
|
|
696
|
+
if (body.oidcConfig.skipDiscovery) {
|
|
697
|
+
return JSON.stringify({
|
|
698
|
+
issuer: body.issuer,
|
|
699
|
+
clientId: body.oidcConfig.clientId,
|
|
700
|
+
clientSecret: body.oidcConfig.clientSecret,
|
|
701
|
+
authorizationEndpoint: body.oidcConfig.authorizationEndpoint,
|
|
702
|
+
tokenEndpoint: body.oidcConfig.tokenEndpoint,
|
|
703
|
+
tokenEndpointAuthentication:
|
|
704
|
+
body.oidcConfig.tokenEndpointAuthentication ||
|
|
705
|
+
"client_secret_basic",
|
|
706
|
+
jwksEndpoint: body.oidcConfig.jwksEndpoint,
|
|
707
|
+
pkce: body.oidcConfig.pkce,
|
|
708
|
+
discoveryEndpoint:
|
|
709
|
+
body.oidcConfig.discoveryEndpoint ||
|
|
710
|
+
`${body.issuer}/.well-known/openid-configuration`,
|
|
711
|
+
mapping: body.oidcConfig.mapping,
|
|
712
|
+
scopes: body.oidcConfig.scopes,
|
|
713
|
+
userInfoEndpoint: body.oidcConfig.userInfoEndpoint,
|
|
714
|
+
overrideUserInfo:
|
|
715
|
+
ctx.body.overrideUserInfo ||
|
|
716
|
+
options?.defaultOverrideUserInfo ||
|
|
717
|
+
false,
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
if (!hydratedOIDCConfig) return null;
|
|
722
|
+
|
|
723
|
+
return JSON.stringify({
|
|
724
|
+
issuer: hydratedOIDCConfig.issuer,
|
|
725
|
+
clientId: body.oidcConfig.clientId,
|
|
726
|
+
clientSecret: body.oidcConfig.clientSecret,
|
|
727
|
+
authorizationEndpoint: hydratedOIDCConfig.authorizationEndpoint,
|
|
728
|
+
tokenEndpoint: hydratedOIDCConfig.tokenEndpoint,
|
|
729
|
+
tokenEndpointAuthentication:
|
|
730
|
+
hydratedOIDCConfig.tokenEndpointAuthentication,
|
|
731
|
+
jwksEndpoint: hydratedOIDCConfig.jwksEndpoint,
|
|
732
|
+
pkce: body.oidcConfig.pkce,
|
|
733
|
+
discoveryEndpoint: hydratedOIDCConfig.discoveryEndpoint,
|
|
734
|
+
mapping: body.oidcConfig.mapping,
|
|
735
|
+
scopes: body.oidcConfig.scopes,
|
|
736
|
+
userInfoEndpoint: hydratedOIDCConfig.userInfoEndpoint,
|
|
737
|
+
overrideUserInfo:
|
|
738
|
+
ctx.body.overrideUserInfo ||
|
|
739
|
+
options?.defaultOverrideUserInfo ||
|
|
740
|
+
false,
|
|
741
|
+
});
|
|
742
|
+
};
|
|
743
|
+
|
|
571
744
|
const provider = await ctx.context.adapter.create<
|
|
572
745
|
Record<string, any>,
|
|
573
746
|
SSOProvider<O>
|
|
@@ -577,29 +750,7 @@ export const registerSSOProvider = <O extends SSOOptions>(options: O) => {
|
|
|
577
750
|
issuer: body.issuer,
|
|
578
751
|
domain: body.domain,
|
|
579
752
|
domainVerified: false,
|
|
580
|
-
oidcConfig:
|
|
581
|
-
? JSON.stringify({
|
|
582
|
-
issuer: body.issuer,
|
|
583
|
-
clientId: body.oidcConfig.clientId,
|
|
584
|
-
clientSecret: body.oidcConfig.clientSecret,
|
|
585
|
-
authorizationEndpoint: body.oidcConfig.authorizationEndpoint,
|
|
586
|
-
tokenEndpoint: body.oidcConfig.tokenEndpoint,
|
|
587
|
-
tokenEndpointAuthentication:
|
|
588
|
-
body.oidcConfig.tokenEndpointAuthentication,
|
|
589
|
-
jwksEndpoint: body.oidcConfig.jwksEndpoint,
|
|
590
|
-
pkce: body.oidcConfig.pkce,
|
|
591
|
-
discoveryEndpoint:
|
|
592
|
-
body.oidcConfig.discoveryEndpoint ||
|
|
593
|
-
`${body.issuer}/.well-known/openid-configuration`,
|
|
594
|
-
mapping: body.oidcConfig.mapping,
|
|
595
|
-
scopes: body.oidcConfig.scopes,
|
|
596
|
-
userInfoEndpoint: body.oidcConfig.userInfoEndpoint,
|
|
597
|
-
overrideUserInfo:
|
|
598
|
-
ctx.body.overrideUserInfo ||
|
|
599
|
-
options?.defaultOverrideUserInfo ||
|
|
600
|
-
false,
|
|
601
|
-
})
|
|
602
|
-
: null,
|
|
753
|
+
oidcConfig: buildOIDCConfig(),
|
|
603
754
|
samlConfig: body.samlConfig
|
|
604
755
|
? JSON.stringify({
|
|
605
756
|
issuer: body.issuer,
|
|
@@ -1054,12 +1205,40 @@ export const signInSSO = (options?: SSOOptions) => {
|
|
|
1054
1205
|
const loginRequest = sp.createLoginRequest(
|
|
1055
1206
|
idp,
|
|
1056
1207
|
"redirect",
|
|
1057
|
-
) as BindingContext & {
|
|
1208
|
+
) as BindingContext & {
|
|
1209
|
+
entityEndpoint: string;
|
|
1210
|
+
type: string;
|
|
1211
|
+
id: string;
|
|
1212
|
+
};
|
|
1058
1213
|
if (!loginRequest) {
|
|
1059
1214
|
throw new APIError("BAD_REQUEST", {
|
|
1060
1215
|
message: "Invalid SAML request",
|
|
1061
1216
|
});
|
|
1062
1217
|
}
|
|
1218
|
+
|
|
1219
|
+
const shouldSaveRequest =
|
|
1220
|
+
loginRequest.id &&
|
|
1221
|
+
(options?.saml?.authnRequestStore ||
|
|
1222
|
+
options?.saml?.enableInResponseToValidation);
|
|
1223
|
+
if (shouldSaveRequest) {
|
|
1224
|
+
const ttl = options?.saml?.requestTTL ?? DEFAULT_AUTHN_REQUEST_TTL_MS;
|
|
1225
|
+
const record: AuthnRequestRecord = {
|
|
1226
|
+
id: loginRequest.id,
|
|
1227
|
+
providerId: provider.providerId,
|
|
1228
|
+
createdAt: Date.now(),
|
|
1229
|
+
expiresAt: Date.now() + ttl,
|
|
1230
|
+
};
|
|
1231
|
+
if (options?.saml?.authnRequestStore) {
|
|
1232
|
+
await options.saml.authnRequestStore.save(record);
|
|
1233
|
+
} else {
|
|
1234
|
+
await ctx.context.internalAdapter.createVerificationValue({
|
|
1235
|
+
identifier: `${AUTHN_REQUEST_KEY_PREFIX}${record.id}`,
|
|
1236
|
+
value: JSON.stringify(record),
|
|
1237
|
+
expiresAt: new Date(record.expiresAt),
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1063
1242
|
return ctx.json({
|
|
1064
1243
|
url: `${loginRequest.context}&RelayState=${encodeURIComponent(
|
|
1065
1244
|
body.callbackURL,
|
|
@@ -1092,7 +1271,7 @@ export const callbackSSO = (options?: SSOOptions) => {
|
|
|
1092
1271
|
"application/json",
|
|
1093
1272
|
],
|
|
1094
1273
|
metadata: {
|
|
1095
|
-
|
|
1274
|
+
...HIDE_METADATA,
|
|
1096
1275
|
openapi: {
|
|
1097
1276
|
operationId: "handleSSOCallback",
|
|
1098
1277
|
summary: "Callback URL for SSO provider",
|
|
@@ -1461,7 +1640,7 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
|
|
|
1461
1640
|
method: "POST",
|
|
1462
1641
|
body: callbackSSOSAMLBodySchema,
|
|
1463
1642
|
metadata: {
|
|
1464
|
-
|
|
1643
|
+
...HIDE_METADATA,
|
|
1465
1644
|
allowedMediaTypes: [
|
|
1466
1645
|
"application/x-www-form-urlencoded",
|
|
1467
1646
|
"application/json",
|
|
@@ -1603,31 +1782,12 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
|
|
|
1603
1782
|
|
|
1604
1783
|
let parsedResponse: FlowResult;
|
|
1605
1784
|
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
|
-
}
|
|
1785
|
+
parsedResponse = await sp.parseLoginResponse(idp, "post", {
|
|
1786
|
+
body: {
|
|
1787
|
+
SAMLResponse,
|
|
1788
|
+
RelayState: RelayState || undefined,
|
|
1789
|
+
},
|
|
1790
|
+
});
|
|
1631
1791
|
|
|
1632
1792
|
if (!parsedResponse?.extract) {
|
|
1633
1793
|
throw new Error("Invalid SAML response structure");
|
|
@@ -1646,6 +1806,106 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
|
|
|
1646
1806
|
}
|
|
1647
1807
|
|
|
1648
1808
|
const { extract } = parsedResponse!;
|
|
1809
|
+
|
|
1810
|
+
validateSAMLTimestamp((extract as any).conditions, {
|
|
1811
|
+
clockSkew: options?.saml?.clockSkew,
|
|
1812
|
+
requireTimestamps: options?.saml?.requireTimestamps,
|
|
1813
|
+
logger: ctx.context.logger,
|
|
1814
|
+
});
|
|
1815
|
+
|
|
1816
|
+
const inResponseTo = (extract as any).inResponseTo as string | undefined;
|
|
1817
|
+
const shouldValidateInResponseTo =
|
|
1818
|
+
options?.saml?.authnRequestStore ||
|
|
1819
|
+
options?.saml?.enableInResponseToValidation;
|
|
1820
|
+
|
|
1821
|
+
if (shouldValidateInResponseTo) {
|
|
1822
|
+
const allowIdpInitiated = options?.saml?.allowIdpInitiated !== false;
|
|
1823
|
+
|
|
1824
|
+
if (inResponseTo) {
|
|
1825
|
+
let storedRequest: AuthnRequestRecord | null = null;
|
|
1826
|
+
|
|
1827
|
+
if (options?.saml?.authnRequestStore) {
|
|
1828
|
+
storedRequest =
|
|
1829
|
+
await options.saml.authnRequestStore.get(inResponseTo);
|
|
1830
|
+
} else {
|
|
1831
|
+
const verification =
|
|
1832
|
+
await ctx.context.internalAdapter.findVerificationValue(
|
|
1833
|
+
`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`,
|
|
1834
|
+
);
|
|
1835
|
+
if (verification) {
|
|
1836
|
+
try {
|
|
1837
|
+
storedRequest = JSON.parse(
|
|
1838
|
+
verification.value,
|
|
1839
|
+
) as AuthnRequestRecord;
|
|
1840
|
+
// Validate expiration for database-stored records
|
|
1841
|
+
// Note: Cleanup of expired records is handled automatically by
|
|
1842
|
+
// findVerificationValue, but we still need to check expiration
|
|
1843
|
+
// since the record is returned before cleanup runs
|
|
1844
|
+
if (storedRequest && storedRequest.expiresAt < Date.now()) {
|
|
1845
|
+
storedRequest = null;
|
|
1846
|
+
}
|
|
1847
|
+
} catch {
|
|
1848
|
+
storedRequest = null;
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
if (!storedRequest) {
|
|
1854
|
+
ctx.context.logger.error(
|
|
1855
|
+
"SAML InResponseTo validation failed: unknown or expired request ID",
|
|
1856
|
+
{ inResponseTo, providerId: provider.providerId },
|
|
1857
|
+
);
|
|
1858
|
+
const redirectUrl =
|
|
1859
|
+
RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
1860
|
+
throw ctx.redirect(
|
|
1861
|
+
`${redirectUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`,
|
|
1862
|
+
);
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
if (storedRequest.providerId !== provider.providerId) {
|
|
1866
|
+
ctx.context.logger.error(
|
|
1867
|
+
"SAML InResponseTo validation failed: provider mismatch",
|
|
1868
|
+
{
|
|
1869
|
+
inResponseTo,
|
|
1870
|
+
expectedProvider: storedRequest.providerId,
|
|
1871
|
+
actualProvider: provider.providerId,
|
|
1872
|
+
},
|
|
1873
|
+
);
|
|
1874
|
+
|
|
1875
|
+
if (options?.saml?.authnRequestStore) {
|
|
1876
|
+
await options.saml.authnRequestStore.delete(inResponseTo);
|
|
1877
|
+
} else {
|
|
1878
|
+
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
|
1879
|
+
`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`,
|
|
1880
|
+
);
|
|
1881
|
+
}
|
|
1882
|
+
const redirectUrl =
|
|
1883
|
+
RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
1884
|
+
throw ctx.redirect(
|
|
1885
|
+
`${redirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`,
|
|
1886
|
+
);
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
if (options?.saml?.authnRequestStore) {
|
|
1890
|
+
await options.saml.authnRequestStore.delete(inResponseTo);
|
|
1891
|
+
} else {
|
|
1892
|
+
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
|
1893
|
+
`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`,
|
|
1894
|
+
);
|
|
1895
|
+
}
|
|
1896
|
+
} else if (!allowIdpInitiated) {
|
|
1897
|
+
ctx.context.logger.error(
|
|
1898
|
+
"SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false",
|
|
1899
|
+
{ providerId: provider.providerId },
|
|
1900
|
+
);
|
|
1901
|
+
const redirectUrl =
|
|
1902
|
+
RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
1903
|
+
throw ctx.redirect(
|
|
1904
|
+
`${redirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`,
|
|
1905
|
+
);
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1649
1909
|
const attributes = extract.attributes || {};
|
|
1650
1910
|
const mapping = parsedSamlConfig.mapping ?? {};
|
|
1651
1911
|
|
|
@@ -1831,7 +2091,7 @@ export const acsEndpoint = (options?: SSOOptions) => {
|
|
|
1831
2091
|
params: acsEndpointParamsSchema,
|
|
1832
2092
|
body: acsEndpointBodySchema,
|
|
1833
2093
|
metadata: {
|
|
1834
|
-
|
|
2094
|
+
...HIDE_METADATA,
|
|
1835
2095
|
allowedMediaTypes: [
|
|
1836
2096
|
"application/x-www-form-urlencoded",
|
|
1837
2097
|
"application/json",
|
|
@@ -1960,50 +2220,12 @@ export const acsEndpoint = (options?: SSOOptions) => {
|
|
|
1960
2220
|
// Parse and validate SAML response
|
|
1961
2221
|
let parsedResponse: FlowResult;
|
|
1962
2222
|
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
|
-
}
|
|
2223
|
+
parsedResponse = await sp.parseLoginResponse(idp, "post", {
|
|
2224
|
+
body: {
|
|
2225
|
+
SAMLResponse,
|
|
2226
|
+
RelayState: RelayState || undefined,
|
|
2227
|
+
},
|
|
2228
|
+
});
|
|
2007
2229
|
|
|
2008
2230
|
if (!parsedResponse?.extract) {
|
|
2009
2231
|
throw new Error("Invalid SAML response structure");
|
|
@@ -2022,6 +2244,103 @@ export const acsEndpoint = (options?: SSOOptions) => {
|
|
|
2022
2244
|
}
|
|
2023
2245
|
|
|
2024
2246
|
const { extract } = parsedResponse!;
|
|
2247
|
+
|
|
2248
|
+
validateSAMLTimestamp((extract as any).conditions, {
|
|
2249
|
+
clockSkew: options?.saml?.clockSkew,
|
|
2250
|
+
requireTimestamps: options?.saml?.requireTimestamps,
|
|
2251
|
+
logger: ctx.context.logger,
|
|
2252
|
+
});
|
|
2253
|
+
|
|
2254
|
+
const inResponseToAcs = (extract as any).inResponseTo as
|
|
2255
|
+
| string
|
|
2256
|
+
| undefined;
|
|
2257
|
+
const shouldValidateInResponseToAcs =
|
|
2258
|
+
options?.saml?.authnRequestStore ||
|
|
2259
|
+
options?.saml?.enableInResponseToValidation;
|
|
2260
|
+
|
|
2261
|
+
if (shouldValidateInResponseToAcs) {
|
|
2262
|
+
const allowIdpInitiated = options?.saml?.allowIdpInitiated !== false;
|
|
2263
|
+
|
|
2264
|
+
if (inResponseToAcs) {
|
|
2265
|
+
let storedRequest: AuthnRequestRecord | null = null;
|
|
2266
|
+
|
|
2267
|
+
if (options?.saml?.authnRequestStore) {
|
|
2268
|
+
storedRequest =
|
|
2269
|
+
await options.saml.authnRequestStore.get(inResponseToAcs);
|
|
2270
|
+
} else {
|
|
2271
|
+
const verification =
|
|
2272
|
+
await ctx.context.internalAdapter.findVerificationValue(
|
|
2273
|
+
`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`,
|
|
2274
|
+
);
|
|
2275
|
+
if (verification) {
|
|
2276
|
+
try {
|
|
2277
|
+
storedRequest = JSON.parse(
|
|
2278
|
+
verification.value,
|
|
2279
|
+
) as AuthnRequestRecord;
|
|
2280
|
+
if (storedRequest && storedRequest.expiresAt < Date.now()) {
|
|
2281
|
+
storedRequest = null;
|
|
2282
|
+
}
|
|
2283
|
+
} catch {
|
|
2284
|
+
storedRequest = null;
|
|
2285
|
+
}
|
|
2286
|
+
}
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2289
|
+
if (!storedRequest) {
|
|
2290
|
+
ctx.context.logger.error(
|
|
2291
|
+
"SAML InResponseTo validation failed: unknown or expired request ID",
|
|
2292
|
+
{ inResponseTo: inResponseToAcs, providerId },
|
|
2293
|
+
);
|
|
2294
|
+
const redirectUrl =
|
|
2295
|
+
RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2296
|
+
throw ctx.redirect(
|
|
2297
|
+
`${redirectUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`,
|
|
2298
|
+
);
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
if (storedRequest.providerId !== providerId) {
|
|
2302
|
+
ctx.context.logger.error(
|
|
2303
|
+
"SAML InResponseTo validation failed: provider mismatch",
|
|
2304
|
+
{
|
|
2305
|
+
inResponseTo: inResponseToAcs,
|
|
2306
|
+
expectedProvider: storedRequest.providerId,
|
|
2307
|
+
actualProvider: providerId,
|
|
2308
|
+
},
|
|
2309
|
+
);
|
|
2310
|
+
if (options?.saml?.authnRequestStore) {
|
|
2311
|
+
await options.saml.authnRequestStore.delete(inResponseToAcs);
|
|
2312
|
+
} else {
|
|
2313
|
+
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
|
2314
|
+
`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`,
|
|
2315
|
+
);
|
|
2316
|
+
}
|
|
2317
|
+
const redirectUrl =
|
|
2318
|
+
RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2319
|
+
throw ctx.redirect(
|
|
2320
|
+
`${redirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`,
|
|
2321
|
+
);
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2324
|
+
if (options?.saml?.authnRequestStore) {
|
|
2325
|
+
await options.saml.authnRequestStore.delete(inResponseToAcs);
|
|
2326
|
+
} else {
|
|
2327
|
+
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
|
2328
|
+
`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`,
|
|
2329
|
+
);
|
|
2330
|
+
}
|
|
2331
|
+
} else if (!allowIdpInitiated) {
|
|
2332
|
+
ctx.context.logger.error(
|
|
2333
|
+
"SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false",
|
|
2334
|
+
{ providerId },
|
|
2335
|
+
);
|
|
2336
|
+
const redirectUrl =
|
|
2337
|
+
RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2338
|
+
throw ctx.redirect(
|
|
2339
|
+
`${redirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`,
|
|
2340
|
+
);
|
|
2341
|
+
}
|
|
2342
|
+
}
|
|
2343
|
+
|
|
2025
2344
|
const attributes = extract.attributes || {};
|
|
2026
2345
|
const mapping = parsedSamlConfig.mapping ?? {};
|
|
2027
2346
|
|