@better-auth/sso 1.4.7-beta.4 → 1.4.8-beta.1
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-GoyGoP_a.d.mts → index-DNWhGQW-.d.mts} +94 -77
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +537 -286
- package/package.json +3 -3
- package/src/constants.ts +42 -0
- package/src/domain-verification.test.ts +1 -0
- package/src/index.ts +38 -11
- package/src/linking/index.ts +2 -0
- package/src/linking/org-assignment.ts +158 -0
- package/src/linking/types.ts +10 -0
- package/src/oidc/discovery.test.ts +359 -25
- package/src/oidc/discovery.ts +168 -29
- package/src/oidc/errors.ts +6 -0
- package/src/oidc/types.ts +9 -0
- package/src/oidc.test.ts +3 -0
- package/src/routes/sso.ts +339 -332
- package/src/saml/algorithms.test.ts +205 -0
- package/src/saml/algorithms.ts +259 -0
- package/src/saml/index.ts +9 -0
- package/src/saml.test.ts +351 -127
- package/src/types.ts +18 -16
- package/src/authn-request-store.ts +0 -76
- package/src/authn-request.test.ts +0 -99
package/src/routes/sso.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { BetterFetchError, betterFetch } from "@better-fetch/fetch";
|
|
2
|
-
import type {
|
|
2
|
+
import type { User, Verification } from "better-auth";
|
|
3
3
|
import {
|
|
4
4
|
createAuthorizationURL,
|
|
5
5
|
generateState,
|
|
@@ -16,29 +16,39 @@ import {
|
|
|
16
16
|
import { setSessionCookie } from "better-auth/cookies";
|
|
17
17
|
import { generateRandomString } from "better-auth/crypto";
|
|
18
18
|
import { handleOAuthUserInfo } from "better-auth/oauth2";
|
|
19
|
+
import { XMLParser } from "fast-xml-parser";
|
|
19
20
|
import { decodeJwt } from "jose";
|
|
20
21
|
import * as saml from "samlify";
|
|
21
22
|
import type { BindingContext } from "samlify/types/src/entity";
|
|
22
23
|
import type { IdentityProvider } from "samlify/types/src/entity-idp";
|
|
23
24
|
import type { FlowResult } from "samlify/types/src/flow";
|
|
24
|
-
import
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
import z from "zod/v4";
|
|
26
|
+
|
|
27
|
+
interface AuthnRequestRecord {
|
|
28
|
+
id: string;
|
|
29
|
+
providerId: string;
|
|
30
|
+
createdAt: number;
|
|
31
|
+
expiresAt: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
import {
|
|
35
|
+
AUTHN_REQUEST_KEY_PREFIX,
|
|
36
|
+
DEFAULT_ASSERTION_TTL_MS,
|
|
37
|
+
DEFAULT_AUTHN_REQUEST_TTL_MS,
|
|
38
|
+
DEFAULT_CLOCK_SKEW_MS,
|
|
39
|
+
USED_ASSERTION_KEY_PREFIX,
|
|
40
|
+
} from "../constants";
|
|
41
|
+
import { assignOrganizationFromProvider } from "../linking";
|
|
27
42
|
import type { HydratedOIDCConfig } from "../oidc";
|
|
28
43
|
import {
|
|
29
44
|
DiscoveryError,
|
|
30
45
|
discoverOIDCConfig,
|
|
31
46
|
mapDiscoveryErrorToAPIError,
|
|
32
47
|
} from "../oidc";
|
|
48
|
+
import { validateSAMLAlgorithms } from "../saml";
|
|
33
49
|
import type { OIDCConfig, SAMLConfig, SSOOptions, SSOProvider } from "../types";
|
|
34
|
-
|
|
35
50
|
import { safeJsonParse, validateEmailDomain } from "../utils";
|
|
36
51
|
|
|
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
52
|
export interface TimestampValidationOptions {
|
|
43
53
|
clockSkew?: number;
|
|
44
54
|
requireTimestamps?: boolean;
|
|
@@ -116,6 +126,34 @@ export function validateSAMLTimestamp(
|
|
|
116
126
|
}
|
|
117
127
|
}
|
|
118
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Extracts the Assertion ID from a SAML response XML.
|
|
131
|
+
* Returns null if the assertion ID cannot be found.
|
|
132
|
+
*/
|
|
133
|
+
function extractAssertionId(samlContent: string): string | null {
|
|
134
|
+
try {
|
|
135
|
+
const parser = new XMLParser({
|
|
136
|
+
ignoreAttributes: false,
|
|
137
|
+
attributeNamePrefix: "@_",
|
|
138
|
+
removeNSPrefix: true,
|
|
139
|
+
});
|
|
140
|
+
const parsed = parser.parse(samlContent);
|
|
141
|
+
|
|
142
|
+
const response = parsed.Response || parsed["samlp:Response"];
|
|
143
|
+
if (!response) return null;
|
|
144
|
+
|
|
145
|
+
const rawAssertion = response.Assertion || response["saml:Assertion"];
|
|
146
|
+
const assertion = Array.isArray(rawAssertion)
|
|
147
|
+
? rawAssertion[0]
|
|
148
|
+
: rawAssertion;
|
|
149
|
+
if (!assertion) return null;
|
|
150
|
+
|
|
151
|
+
return assertion["@_ID"] || null;
|
|
152
|
+
} catch {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
119
157
|
const spMetadataQuerySchema = z.object({
|
|
120
158
|
providerId: z.string(),
|
|
121
159
|
format: z.enum(["xml", "json"]).default("xml"),
|
|
@@ -681,6 +719,7 @@ export const registerSSOProvider = <O extends SSOOptions>(options: O) => {
|
|
|
681
719
|
tokenEndpointAuthentication:
|
|
682
720
|
body.oidcConfig.tokenEndpointAuthentication,
|
|
683
721
|
},
|
|
722
|
+
isTrustedOrigin: ctx.context.isTrustedOrigin,
|
|
684
723
|
});
|
|
685
724
|
} catch (error) {
|
|
686
725
|
if (error instanceof DiscoveryError) {
|
|
@@ -791,7 +830,7 @@ export const registerSSOProvider = <O extends SSOOptions>(options: O) => {
|
|
|
791
830
|
: `better-auth-token-${provider.providerId}`,
|
|
792
831
|
createdAt: new Date(),
|
|
793
832
|
updatedAt: new Date(),
|
|
794
|
-
value: domainVerificationToken,
|
|
833
|
+
value: domainVerificationToken as string,
|
|
795
834
|
expiresAt: new Date(Date.now() + 3600 * 24 * 7 * 1000), // 1 week
|
|
796
835
|
},
|
|
797
836
|
});
|
|
@@ -1217,9 +1256,7 @@ export const signInSSO = (options?: SSOOptions) => {
|
|
|
1217
1256
|
}
|
|
1218
1257
|
|
|
1219
1258
|
const shouldSaveRequest =
|
|
1220
|
-
loginRequest.id &&
|
|
1221
|
-
(options?.saml?.authnRequestStore ||
|
|
1222
|
-
options?.saml?.enableInResponseToValidation);
|
|
1259
|
+
loginRequest.id && options?.saml?.enableInResponseToValidation;
|
|
1223
1260
|
if (shouldSaveRequest) {
|
|
1224
1261
|
const ttl = options?.saml?.requestTTL ?? DEFAULT_AUTHN_REQUEST_TTL_MS;
|
|
1225
1262
|
const record: AuthnRequestRecord = {
|
|
@@ -1228,15 +1265,11 @@ export const signInSSO = (options?: SSOOptions) => {
|
|
|
1228
1265
|
createdAt: Date.now(),
|
|
1229
1266
|
expiresAt: Date.now() + ttl,
|
|
1230
1267
|
};
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
value: JSON.stringify(record),
|
|
1237
|
-
expiresAt: new Date(record.expiresAt),
|
|
1238
|
-
});
|
|
1239
|
-
}
|
|
1268
|
+
await ctx.context.internalAdapter.createVerificationValue({
|
|
1269
|
+
identifier: `${AUTHN_REQUEST_KEY_PREFIX}${record.id}`,
|
|
1270
|
+
value: JSON.stringify(record),
|
|
1271
|
+
expiresAt: new Date(record.expiresAt),
|
|
1272
|
+
});
|
|
1240
1273
|
}
|
|
1241
1274
|
|
|
1242
1275
|
return ctx.json({
|
|
@@ -1573,43 +1606,22 @@ export const callbackSSO = (options?: SSOOptions) => {
|
|
|
1573
1606
|
provider,
|
|
1574
1607
|
});
|
|
1575
1608
|
}
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
const role = options?.organizationProvisioning?.getRole
|
|
1593
|
-
? await options.organizationProvisioning.getRole({
|
|
1594
|
-
user,
|
|
1595
|
-
userInfo,
|
|
1596
|
-
token: tokenResponse,
|
|
1597
|
-
provider,
|
|
1598
|
-
})
|
|
1599
|
-
: options?.organizationProvisioning?.defaultRole || "member";
|
|
1600
|
-
await ctx.context.adapter.create({
|
|
1601
|
-
model: "member",
|
|
1602
|
-
data: {
|
|
1603
|
-
organizationId: provider.organizationId,
|
|
1604
|
-
userId: user.id,
|
|
1605
|
-
role,
|
|
1606
|
-
createdAt: new Date(),
|
|
1607
|
-
updatedAt: new Date(),
|
|
1608
|
-
},
|
|
1609
|
-
});
|
|
1610
|
-
}
|
|
1611
|
-
}
|
|
1612
|
-
}
|
|
1609
|
+
|
|
1610
|
+
await assignOrganizationFromProvider(ctx as any, {
|
|
1611
|
+
user,
|
|
1612
|
+
profile: {
|
|
1613
|
+
providerType: "oidc",
|
|
1614
|
+
providerId: provider.providerId,
|
|
1615
|
+
accountId: userInfo.id,
|
|
1616
|
+
email: userInfo.email,
|
|
1617
|
+
emailVerified: Boolean(userInfo.emailVerified),
|
|
1618
|
+
rawAttributes: userInfo,
|
|
1619
|
+
},
|
|
1620
|
+
provider,
|
|
1621
|
+
token: tokenResponse,
|
|
1622
|
+
provisioningOptions: options?.organizationProvisioning,
|
|
1623
|
+
});
|
|
1624
|
+
|
|
1613
1625
|
await setSessionCookie(ctx, {
|
|
1614
1626
|
session,
|
|
1615
1627
|
user,
|
|
@@ -1807,6 +1819,8 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
|
|
|
1807
1819
|
|
|
1808
1820
|
const { extract } = parsedResponse!;
|
|
1809
1821
|
|
|
1822
|
+
validateSAMLAlgorithms(parsedResponse, options?.saml?.algorithms);
|
|
1823
|
+
|
|
1810
1824
|
validateSAMLTimestamp((extract as any).conditions, {
|
|
1811
1825
|
clockSkew: options?.saml?.clockSkew,
|
|
1812
1826
|
requireTimestamps: options?.saml?.requireTimestamps,
|
|
@@ -1815,7 +1829,6 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
|
|
|
1815
1829
|
|
|
1816
1830
|
const inResponseTo = (extract as any).inResponseTo as string | undefined;
|
|
1817
1831
|
const shouldValidateInResponseTo =
|
|
1818
|
-
options?.saml?.authnRequestStore ||
|
|
1819
1832
|
options?.saml?.enableInResponseToValidation;
|
|
1820
1833
|
|
|
1821
1834
|
if (shouldValidateInResponseTo) {
|
|
@@ -1824,29 +1837,20 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
|
|
|
1824
1837
|
if (inResponseTo) {
|
|
1825
1838
|
let storedRequest: AuthnRequestRecord | null = null;
|
|
1826
1839
|
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
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 {
|
|
1840
|
+
const verification =
|
|
1841
|
+
await ctx.context.internalAdapter.findVerificationValue(
|
|
1842
|
+
`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`,
|
|
1843
|
+
);
|
|
1844
|
+
if (verification) {
|
|
1845
|
+
try {
|
|
1846
|
+
storedRequest = JSON.parse(
|
|
1847
|
+
verification.value,
|
|
1848
|
+
) as AuthnRequestRecord;
|
|
1849
|
+
if (storedRequest && storedRequest.expiresAt < Date.now()) {
|
|
1848
1850
|
storedRequest = null;
|
|
1849
1851
|
}
|
|
1852
|
+
} catch {
|
|
1853
|
+
storedRequest = null;
|
|
1850
1854
|
}
|
|
1851
1855
|
}
|
|
1852
1856
|
|
|
@@ -1872,13 +1876,9 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
|
|
|
1872
1876
|
},
|
|
1873
1877
|
);
|
|
1874
1878
|
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
|
1879
|
-
`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`,
|
|
1880
|
-
);
|
|
1881
|
-
}
|
|
1879
|
+
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
|
1880
|
+
`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`,
|
|
1881
|
+
);
|
|
1882
1882
|
const redirectUrl =
|
|
1883
1883
|
RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
1884
1884
|
throw ctx.redirect(
|
|
@@ -1886,13 +1886,9 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
|
|
|
1886
1886
|
);
|
|
1887
1887
|
}
|
|
1888
1888
|
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
|
1893
|
-
`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`,
|
|
1894
|
-
);
|
|
1895
|
-
}
|
|
1889
|
+
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
|
1890
|
+
`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`,
|
|
1891
|
+
);
|
|
1896
1892
|
} else if (!allowIdpInitiated) {
|
|
1897
1893
|
ctx.context.logger.error(
|
|
1898
1894
|
"SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false",
|
|
@@ -1906,6 +1902,76 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
|
|
|
1906
1902
|
}
|
|
1907
1903
|
}
|
|
1908
1904
|
|
|
1905
|
+
// Assertion Replay Protection
|
|
1906
|
+
const samlContent = (parsedResponse as any).samlContent as
|
|
1907
|
+
| string
|
|
1908
|
+
| undefined;
|
|
1909
|
+
const assertionId = samlContent ? extractAssertionId(samlContent) : null;
|
|
1910
|
+
|
|
1911
|
+
if (assertionId) {
|
|
1912
|
+
const issuer = idp.entityMeta.getEntityID();
|
|
1913
|
+
const conditions = (extract as any).conditions as
|
|
1914
|
+
| SAMLConditions
|
|
1915
|
+
| undefined;
|
|
1916
|
+
const clockSkew = options?.saml?.clockSkew ?? DEFAULT_CLOCK_SKEW_MS;
|
|
1917
|
+
const expiresAt = conditions?.notOnOrAfter
|
|
1918
|
+
? new Date(conditions.notOnOrAfter).getTime() + clockSkew
|
|
1919
|
+
: Date.now() + DEFAULT_ASSERTION_TTL_MS;
|
|
1920
|
+
|
|
1921
|
+
const existingAssertion =
|
|
1922
|
+
await ctx.context.internalAdapter.findVerificationValue(
|
|
1923
|
+
`${USED_ASSERTION_KEY_PREFIX}${assertionId}`,
|
|
1924
|
+
);
|
|
1925
|
+
|
|
1926
|
+
let isReplay = false;
|
|
1927
|
+
if (existingAssertion) {
|
|
1928
|
+
try {
|
|
1929
|
+
const stored = JSON.parse(existingAssertion.value);
|
|
1930
|
+
if (stored.expiresAt >= Date.now()) {
|
|
1931
|
+
isReplay = true;
|
|
1932
|
+
}
|
|
1933
|
+
} catch (error) {
|
|
1934
|
+
ctx.context.logger.warn("Failed to parse stored assertion record", {
|
|
1935
|
+
assertionId,
|
|
1936
|
+
error,
|
|
1937
|
+
});
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
if (isReplay) {
|
|
1942
|
+
ctx.context.logger.error(
|
|
1943
|
+
"SAML assertion replay detected: assertion ID already used",
|
|
1944
|
+
{
|
|
1945
|
+
assertionId,
|
|
1946
|
+
issuer,
|
|
1947
|
+
providerId: provider.providerId,
|
|
1948
|
+
},
|
|
1949
|
+
);
|
|
1950
|
+
const redirectUrl =
|
|
1951
|
+
RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
1952
|
+
throw ctx.redirect(
|
|
1953
|
+
`${redirectUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`,
|
|
1954
|
+
);
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
await ctx.context.internalAdapter.createVerificationValue({
|
|
1958
|
+
identifier: `${USED_ASSERTION_KEY_PREFIX}${assertionId}`,
|
|
1959
|
+
value: JSON.stringify({
|
|
1960
|
+
assertionId,
|
|
1961
|
+
issuer,
|
|
1962
|
+
providerId: provider.providerId,
|
|
1963
|
+
usedAt: Date.now(),
|
|
1964
|
+
expiresAt,
|
|
1965
|
+
}),
|
|
1966
|
+
expiresAt: new Date(expiresAt),
|
|
1967
|
+
});
|
|
1968
|
+
} else {
|
|
1969
|
+
ctx.context.logger.warn(
|
|
1970
|
+
"Could not extract assertion ID for replay protection",
|
|
1971
|
+
{ providerId: provider.providerId },
|
|
1972
|
+
);
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1909
1975
|
const attributes = extract.attributes || {};
|
|
1910
1976
|
const mapping = parsedSamlConfig.mapping ?? {};
|
|
1911
1977
|
|
|
@@ -1947,73 +2013,43 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
|
|
|
1947
2013
|
});
|
|
1948
2014
|
}
|
|
1949
2015
|
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
{
|
|
1956
|
-
|
|
1957
|
-
value: userInfo.email,
|
|
1958
|
-
},
|
|
1959
|
-
],
|
|
1960
|
-
});
|
|
2016
|
+
const isTrustedProvider: boolean =
|
|
2017
|
+
!!ctx.context.options.account?.accountLinking?.trustedProviders?.includes(
|
|
2018
|
+
provider.providerId,
|
|
2019
|
+
) ||
|
|
2020
|
+
("domainVerified" in provider &&
|
|
2021
|
+
!!(provider as { domainVerified?: boolean }).domainVerified &&
|
|
2022
|
+
validateEmailDomain(userInfo.email as string, provider.domain));
|
|
1961
2023
|
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
model: "account",
|
|
1965
|
-
where: [
|
|
1966
|
-
{ field: "userId", value: existingUser.id },
|
|
1967
|
-
{ field: "providerId", value: provider.providerId },
|
|
1968
|
-
{ field: "accountId", value: userInfo.id },
|
|
1969
|
-
],
|
|
1970
|
-
});
|
|
1971
|
-
if (!account) {
|
|
1972
|
-
const isTrustedProvider =
|
|
1973
|
-
ctx.context.options.account?.accountLinking?.trustedProviders?.includes(
|
|
1974
|
-
provider.providerId,
|
|
1975
|
-
) ||
|
|
1976
|
-
("domainVerified" in provider &&
|
|
1977
|
-
provider.domainVerified &&
|
|
1978
|
-
validateEmailDomain(userInfo.email, provider.domain));
|
|
1979
|
-
if (!isTrustedProvider) {
|
|
1980
|
-
const redirectUrl =
|
|
1981
|
-
RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
1982
|
-
throw ctx.redirect(`${redirectUrl}?error=account_not_linked`);
|
|
1983
|
-
}
|
|
1984
|
-
await ctx.context.internalAdapter.createAccount({
|
|
1985
|
-
userId: existingUser.id,
|
|
1986
|
-
providerId: provider.providerId,
|
|
1987
|
-
accountId: userInfo.id,
|
|
1988
|
-
accessToken: "",
|
|
1989
|
-
refreshToken: "",
|
|
1990
|
-
});
|
|
1991
|
-
}
|
|
1992
|
-
user = existingUser;
|
|
1993
|
-
} else {
|
|
1994
|
-
// if implicit sign up is disabled, we should not create a new user nor a new account.
|
|
1995
|
-
if (options?.disableImplicitSignUp) {
|
|
1996
|
-
throw new APIError("UNAUTHORIZED", {
|
|
1997
|
-
message:
|
|
1998
|
-
"User not found and implicit sign up is disabled for this provider",
|
|
1999
|
-
});
|
|
2000
|
-
}
|
|
2024
|
+
const callbackUrl =
|
|
2025
|
+
RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2001
2026
|
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2027
|
+
const result = await handleOAuthUserInfo(ctx, {
|
|
2028
|
+
userInfo: {
|
|
2029
|
+
email: userInfo.email as string,
|
|
2030
|
+
name: (userInfo.name || userInfo.email) as string,
|
|
2031
|
+
id: userInfo.id as string,
|
|
2032
|
+
emailVerified: Boolean(userInfo.emailVerified),
|
|
2033
|
+
},
|
|
2034
|
+
account: {
|
|
2009
2035
|
providerId: provider.providerId,
|
|
2010
|
-
accountId: userInfo.id,
|
|
2036
|
+
accountId: userInfo.id as string,
|
|
2011
2037
|
accessToken: "",
|
|
2012
2038
|
refreshToken: "",
|
|
2013
|
-
}
|
|
2039
|
+
},
|
|
2040
|
+
callbackURL: callbackUrl,
|
|
2041
|
+
disableSignUp: options?.disableImplicitSignUp,
|
|
2042
|
+
isTrustedProvider,
|
|
2043
|
+
});
|
|
2044
|
+
|
|
2045
|
+
if (result.error) {
|
|
2046
|
+
throw ctx.redirect(
|
|
2047
|
+
`${callbackUrl}?error=${result.error.split(" ").join("_")}`,
|
|
2048
|
+
);
|
|
2014
2049
|
}
|
|
2015
2050
|
|
|
2016
|
-
|
|
2051
|
+
const { session, user } = result.data!;
|
|
2052
|
+
|
|
2017
2053
|
if (options?.provisionUser) {
|
|
2018
2054
|
await options.provisionUser({
|
|
2019
2055
|
user: user as User & Record<string, any>,
|
|
@@ -2022,53 +2058,21 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
|
|
|
2022
2058
|
});
|
|
2023
2059
|
}
|
|
2024
2060
|
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
{ field: "userId", value: user.id },
|
|
2039
|
-
],
|
|
2040
|
-
});
|
|
2041
|
-
if (!isAlreadyMember) {
|
|
2042
|
-
const role = options?.organizationProvisioning?.getRole
|
|
2043
|
-
? await options.organizationProvisioning.getRole({
|
|
2044
|
-
user,
|
|
2045
|
-
userInfo,
|
|
2046
|
-
provider,
|
|
2047
|
-
})
|
|
2048
|
-
: options?.organizationProvisioning?.defaultRole || "member";
|
|
2049
|
-
await ctx.context.adapter.create({
|
|
2050
|
-
model: "member",
|
|
2051
|
-
data: {
|
|
2052
|
-
organizationId: provider.organizationId,
|
|
2053
|
-
userId: user.id,
|
|
2054
|
-
role,
|
|
2055
|
-
createdAt: new Date(),
|
|
2056
|
-
updatedAt: new Date(),
|
|
2057
|
-
},
|
|
2058
|
-
});
|
|
2059
|
-
}
|
|
2060
|
-
}
|
|
2061
|
-
}
|
|
2061
|
+
await assignOrganizationFromProvider(ctx as any, {
|
|
2062
|
+
user,
|
|
2063
|
+
profile: {
|
|
2064
|
+
providerType: "saml",
|
|
2065
|
+
providerId: provider.providerId,
|
|
2066
|
+
accountId: userInfo.id as string,
|
|
2067
|
+
email: userInfo.email as string,
|
|
2068
|
+
emailVerified: Boolean(userInfo.emailVerified),
|
|
2069
|
+
rawAttributes: attributes,
|
|
2070
|
+
},
|
|
2071
|
+
provider,
|
|
2072
|
+
provisioningOptions: options?.organizationProvisioning,
|
|
2073
|
+
});
|
|
2062
2074
|
|
|
2063
|
-
// Create session and set cookie
|
|
2064
|
-
let session: Session = await ctx.context.internalAdapter.createSession(
|
|
2065
|
-
user.id,
|
|
2066
|
-
);
|
|
2067
2075
|
await setSessionCookie(ctx, { session, user });
|
|
2068
|
-
|
|
2069
|
-
// Redirect to callback URL
|
|
2070
|
-
const callbackUrl =
|
|
2071
|
-
RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2072
2076
|
throw ctx.redirect(callbackUrl);
|
|
2073
2077
|
},
|
|
2074
2078
|
);
|
|
@@ -2245,6 +2249,8 @@ export const acsEndpoint = (options?: SSOOptions) => {
|
|
|
2245
2249
|
|
|
2246
2250
|
const { extract } = parsedResponse!;
|
|
2247
2251
|
|
|
2252
|
+
validateSAMLAlgorithms(parsedResponse, options?.saml?.algorithms);
|
|
2253
|
+
|
|
2248
2254
|
validateSAMLTimestamp((extract as any).conditions, {
|
|
2249
2255
|
clockSkew: options?.saml?.clockSkew,
|
|
2250
2256
|
requireTimestamps: options?.saml?.requireTimestamps,
|
|
@@ -2255,7 +2261,6 @@ export const acsEndpoint = (options?: SSOOptions) => {
|
|
|
2255
2261
|
| string
|
|
2256
2262
|
| undefined;
|
|
2257
2263
|
const shouldValidateInResponseToAcs =
|
|
2258
|
-
options?.saml?.authnRequestStore ||
|
|
2259
2264
|
options?.saml?.enableInResponseToValidation;
|
|
2260
2265
|
|
|
2261
2266
|
if (shouldValidateInResponseToAcs) {
|
|
@@ -2264,25 +2269,20 @@ export const acsEndpoint = (options?: SSOOptions) => {
|
|
|
2264
2269
|
if (inResponseToAcs) {
|
|
2265
2270
|
let storedRequest: AuthnRequestRecord | null = null;
|
|
2266
2271
|
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
storedRequest = JSON.parse(
|
|
2278
|
-
verification.value,
|
|
2279
|
-
) as AuthnRequestRecord;
|
|
2280
|
-
if (storedRequest && storedRequest.expiresAt < Date.now()) {
|
|
2281
|
-
storedRequest = null;
|
|
2282
|
-
}
|
|
2283
|
-
} catch {
|
|
2272
|
+
const verification =
|
|
2273
|
+
await ctx.context.internalAdapter.findVerificationValue(
|
|
2274
|
+
`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`,
|
|
2275
|
+
);
|
|
2276
|
+
if (verification) {
|
|
2277
|
+
try {
|
|
2278
|
+
storedRequest = JSON.parse(
|
|
2279
|
+
verification.value,
|
|
2280
|
+
) as AuthnRequestRecord;
|
|
2281
|
+
if (storedRequest && storedRequest.expiresAt < Date.now()) {
|
|
2284
2282
|
storedRequest = null;
|
|
2285
2283
|
}
|
|
2284
|
+
} catch {
|
|
2285
|
+
storedRequest = null;
|
|
2286
2286
|
}
|
|
2287
2287
|
}
|
|
2288
2288
|
|
|
@@ -2307,13 +2307,9 @@ export const acsEndpoint = (options?: SSOOptions) => {
|
|
|
2307
2307
|
actualProvider: providerId,
|
|
2308
2308
|
},
|
|
2309
2309
|
);
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
|
2314
|
-
`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`,
|
|
2315
|
-
);
|
|
2316
|
-
}
|
|
2310
|
+
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
|
2311
|
+
`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`,
|
|
2312
|
+
);
|
|
2317
2313
|
const redirectUrl =
|
|
2318
2314
|
RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2319
2315
|
throw ctx.redirect(
|
|
@@ -2321,13 +2317,9 @@ export const acsEndpoint = (options?: SSOOptions) => {
|
|
|
2321
2317
|
);
|
|
2322
2318
|
}
|
|
2323
2319
|
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
|
2328
|
-
`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`,
|
|
2329
|
-
);
|
|
2330
|
-
}
|
|
2320
|
+
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
|
2321
|
+
`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`,
|
|
2322
|
+
);
|
|
2331
2323
|
} else if (!allowIdpInitiated) {
|
|
2332
2324
|
ctx.context.logger.error(
|
|
2333
2325
|
"SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false",
|
|
@@ -2341,6 +2333,76 @@ export const acsEndpoint = (options?: SSOOptions) => {
|
|
|
2341
2333
|
}
|
|
2342
2334
|
}
|
|
2343
2335
|
|
|
2336
|
+
// Assertion Replay Protection
|
|
2337
|
+
const samlContentAcs = Buffer.from(SAMLResponse, "base64").toString(
|
|
2338
|
+
"utf-8",
|
|
2339
|
+
);
|
|
2340
|
+
const assertionIdAcs = extractAssertionId(samlContentAcs);
|
|
2341
|
+
|
|
2342
|
+
if (assertionIdAcs) {
|
|
2343
|
+
const issuer = idp.entityMeta.getEntityID();
|
|
2344
|
+
const conditions = (extract as any).conditions as
|
|
2345
|
+
| SAMLConditions
|
|
2346
|
+
| undefined;
|
|
2347
|
+
const clockSkew = options?.saml?.clockSkew ?? DEFAULT_CLOCK_SKEW_MS;
|
|
2348
|
+
const expiresAt = conditions?.notOnOrAfter
|
|
2349
|
+
? new Date(conditions.notOnOrAfter).getTime() + clockSkew
|
|
2350
|
+
: Date.now() + DEFAULT_ASSERTION_TTL_MS;
|
|
2351
|
+
|
|
2352
|
+
const existingAssertion =
|
|
2353
|
+
await ctx.context.internalAdapter.findVerificationValue(
|
|
2354
|
+
`${USED_ASSERTION_KEY_PREFIX}${assertionIdAcs}`,
|
|
2355
|
+
);
|
|
2356
|
+
|
|
2357
|
+
let isReplay = false;
|
|
2358
|
+
if (existingAssertion) {
|
|
2359
|
+
try {
|
|
2360
|
+
const stored = JSON.parse(existingAssertion.value);
|
|
2361
|
+
if (stored.expiresAt >= Date.now()) {
|
|
2362
|
+
isReplay = true;
|
|
2363
|
+
}
|
|
2364
|
+
} catch (error) {
|
|
2365
|
+
ctx.context.logger.warn("Failed to parse stored assertion record", {
|
|
2366
|
+
assertionId: assertionIdAcs,
|
|
2367
|
+
error,
|
|
2368
|
+
});
|
|
2369
|
+
}
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
if (isReplay) {
|
|
2373
|
+
ctx.context.logger.error(
|
|
2374
|
+
"SAML assertion replay detected: assertion ID already used",
|
|
2375
|
+
{
|
|
2376
|
+
assertionId: assertionIdAcs,
|
|
2377
|
+
issuer,
|
|
2378
|
+
providerId,
|
|
2379
|
+
},
|
|
2380
|
+
);
|
|
2381
|
+
const redirectUrl =
|
|
2382
|
+
RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2383
|
+
throw ctx.redirect(
|
|
2384
|
+
`${redirectUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`,
|
|
2385
|
+
);
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
await ctx.context.internalAdapter.createVerificationValue({
|
|
2389
|
+
identifier: `${USED_ASSERTION_KEY_PREFIX}${assertionIdAcs}`,
|
|
2390
|
+
value: JSON.stringify({
|
|
2391
|
+
assertionId: assertionIdAcs,
|
|
2392
|
+
issuer,
|
|
2393
|
+
providerId,
|
|
2394
|
+
usedAt: Date.now(),
|
|
2395
|
+
expiresAt,
|
|
2396
|
+
}),
|
|
2397
|
+
expiresAt: new Date(expiresAt),
|
|
2398
|
+
});
|
|
2399
|
+
} else {
|
|
2400
|
+
ctx.context.logger.warn(
|
|
2401
|
+
"Could not extract assertion ID for replay protection",
|
|
2402
|
+
{ providerId },
|
|
2403
|
+
);
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2344
2406
|
const attributes = extract.attributes || {};
|
|
2345
2407
|
const mapping = parsedSamlConfig.mapping ?? {};
|
|
2346
2408
|
|
|
@@ -2383,69 +2445,43 @@ export const acsEndpoint = (options?: SSOOptions) => {
|
|
|
2383
2445
|
});
|
|
2384
2446
|
}
|
|
2385
2447
|
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
{
|
|
2392
|
-
|
|
2393
|
-
value: userInfo.email,
|
|
2394
|
-
},
|
|
2395
|
-
],
|
|
2396
|
-
});
|
|
2448
|
+
const isTrustedProvider: boolean =
|
|
2449
|
+
!!ctx.context.options.account?.accountLinking?.trustedProviders?.includes(
|
|
2450
|
+
provider.providerId,
|
|
2451
|
+
) ||
|
|
2452
|
+
("domainVerified" in provider &&
|
|
2453
|
+
!!(provider as { domainVerified?: boolean }).domainVerified &&
|
|
2454
|
+
validateEmailDomain(userInfo.email as string, provider.domain));
|
|
2397
2455
|
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
ctx.context.options.account?.accountLinking?.trustedProviders?.includes(
|
|
2410
|
-
provider.providerId,
|
|
2411
|
-
) ||
|
|
2412
|
-
("domainVerified" in provider &&
|
|
2413
|
-
provider.domainVerified &&
|
|
2414
|
-
validateEmailDomain(userInfo.email, provider.domain));
|
|
2415
|
-
if (!isTrustedProvider) {
|
|
2416
|
-
throw ctx.redirect(
|
|
2417
|
-
`${parsedSamlConfig.callbackUrl}?error=account_not_found`,
|
|
2418
|
-
);
|
|
2419
|
-
}
|
|
2420
|
-
await ctx.context.internalAdapter.createAccount({
|
|
2421
|
-
userId: existingUser.id,
|
|
2422
|
-
providerId: provider.providerId,
|
|
2423
|
-
accountId: userInfo.id,
|
|
2424
|
-
accessToken: "",
|
|
2425
|
-
refreshToken: "",
|
|
2426
|
-
});
|
|
2427
|
-
}
|
|
2428
|
-
user = existingUser;
|
|
2429
|
-
} else {
|
|
2430
|
-
user = await ctx.context.internalAdapter.createUser({
|
|
2431
|
-
email: userInfo.email,
|
|
2432
|
-
name: userInfo.name,
|
|
2433
|
-
emailVerified: options?.trustEmailVerified
|
|
2434
|
-
? userInfo.emailVerified || false
|
|
2435
|
-
: false,
|
|
2436
|
-
});
|
|
2437
|
-
await ctx.context.internalAdapter.createAccount({
|
|
2438
|
-
userId: user.id,
|
|
2456
|
+
const callbackUrl =
|
|
2457
|
+
RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2458
|
+
|
|
2459
|
+
const result = await handleOAuthUserInfo(ctx, {
|
|
2460
|
+
userInfo: {
|
|
2461
|
+
email: userInfo.email as string,
|
|
2462
|
+
name: (userInfo.name || userInfo.email) as string,
|
|
2463
|
+
id: userInfo.id as string,
|
|
2464
|
+
emailVerified: Boolean(userInfo.emailVerified),
|
|
2465
|
+
},
|
|
2466
|
+
account: {
|
|
2439
2467
|
providerId: provider.providerId,
|
|
2440
|
-
accountId: userInfo.id,
|
|
2468
|
+
accountId: userInfo.id as string,
|
|
2441
2469
|
accessToken: "",
|
|
2442
2470
|
refreshToken: "",
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2471
|
+
},
|
|
2472
|
+
callbackURL: callbackUrl,
|
|
2473
|
+
disableSignUp: options?.disableImplicitSignUp,
|
|
2474
|
+
isTrustedProvider,
|
|
2475
|
+
});
|
|
2476
|
+
|
|
2477
|
+
if (result.error) {
|
|
2478
|
+
throw ctx.redirect(
|
|
2479
|
+
`${callbackUrl}?error=${result.error.split(" ").join("_")}`,
|
|
2480
|
+
);
|
|
2447
2481
|
}
|
|
2448
2482
|
|
|
2483
|
+
const { session, user } = result.data!;
|
|
2484
|
+
|
|
2449
2485
|
if (options?.provisionUser) {
|
|
2450
2486
|
await options.provisionUser({
|
|
2451
2487
|
user: user as User & Record<string, any>,
|
|
@@ -2454,50 +2490,21 @@ export const acsEndpoint = (options?: SSOOptions) => {
|
|
|
2454
2490
|
});
|
|
2455
2491
|
}
|
|
2456
2492
|
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
],
|
|
2471
|
-
});
|
|
2472
|
-
if (!isAlreadyMember) {
|
|
2473
|
-
const role = options?.organizationProvisioning?.getRole
|
|
2474
|
-
? await options.organizationProvisioning.getRole({
|
|
2475
|
-
user,
|
|
2476
|
-
userInfo,
|
|
2477
|
-
provider,
|
|
2478
|
-
})
|
|
2479
|
-
: options?.organizationProvisioning?.defaultRole || "member";
|
|
2480
|
-
await ctx.context.adapter.create({
|
|
2481
|
-
model: "member",
|
|
2482
|
-
data: {
|
|
2483
|
-
organizationId: provider.organizationId,
|
|
2484
|
-
userId: user.id,
|
|
2485
|
-
role,
|
|
2486
|
-
createdAt: new Date(),
|
|
2487
|
-
updatedAt: new Date(),
|
|
2488
|
-
},
|
|
2489
|
-
});
|
|
2490
|
-
}
|
|
2491
|
-
}
|
|
2492
|
-
}
|
|
2493
|
+
await assignOrganizationFromProvider(ctx as any, {
|
|
2494
|
+
user,
|
|
2495
|
+
profile: {
|
|
2496
|
+
providerType: "saml",
|
|
2497
|
+
providerId: provider.providerId,
|
|
2498
|
+
accountId: userInfo.id as string,
|
|
2499
|
+
email: userInfo.email as string,
|
|
2500
|
+
emailVerified: Boolean(userInfo.emailVerified),
|
|
2501
|
+
rawAttributes: attributes,
|
|
2502
|
+
},
|
|
2503
|
+
provider,
|
|
2504
|
+
provisioningOptions: options?.organizationProvisioning,
|
|
2505
|
+
});
|
|
2493
2506
|
|
|
2494
|
-
let session: Session = await ctx.context.internalAdapter.createSession(
|
|
2495
|
-
user.id,
|
|
2496
|
-
);
|
|
2497
2507
|
await setSessionCookie(ctx, { session, user });
|
|
2498
|
-
|
|
2499
|
-
const callbackUrl =
|
|
2500
|
-
RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2501
2508
|
throw ctx.redirect(callbackUrl);
|
|
2502
2509
|
},
|
|
2503
2510
|
);
|