@better-auth/sso 1.4.7 → 1.4.8-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +6 -6
- package/dist/client.d.mts +1 -1
- package/dist/{index-B9WMxRdD.d.mts → index-DNWhGQW-.d.mts} +81 -69
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +464 -265
- package/package.json +3 -3
- package/src/constants.ts +42 -0
- package/src/domain-verification.test.ts +1 -0
- package/src/index.ts +39 -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/routes/sso.ts +338 -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 +350 -127
- package/src/types.ts +24 -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"),
|
|
@@ -792,7 +830,7 @@ export const registerSSOProvider = <O extends SSOOptions>(options: O) => {
|
|
|
792
830
|
: `better-auth-token-${provider.providerId}`,
|
|
793
831
|
createdAt: new Date(),
|
|
794
832
|
updatedAt: new Date(),
|
|
795
|
-
value: domainVerificationToken,
|
|
833
|
+
value: domainVerificationToken as string,
|
|
796
834
|
expiresAt: new Date(Date.now() + 3600 * 24 * 7 * 1000), // 1 week
|
|
797
835
|
},
|
|
798
836
|
});
|
|
@@ -1218,9 +1256,7 @@ export const signInSSO = (options?: SSOOptions) => {
|
|
|
1218
1256
|
}
|
|
1219
1257
|
|
|
1220
1258
|
const shouldSaveRequest =
|
|
1221
|
-
loginRequest.id &&
|
|
1222
|
-
(options?.saml?.authnRequestStore ||
|
|
1223
|
-
options?.saml?.enableInResponseToValidation);
|
|
1259
|
+
loginRequest.id && options?.saml?.enableInResponseToValidation;
|
|
1224
1260
|
if (shouldSaveRequest) {
|
|
1225
1261
|
const ttl = options?.saml?.requestTTL ?? DEFAULT_AUTHN_REQUEST_TTL_MS;
|
|
1226
1262
|
const record: AuthnRequestRecord = {
|
|
@@ -1229,15 +1265,11 @@ export const signInSSO = (options?: SSOOptions) => {
|
|
|
1229
1265
|
createdAt: Date.now(),
|
|
1230
1266
|
expiresAt: Date.now() + ttl,
|
|
1231
1267
|
};
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
value: JSON.stringify(record),
|
|
1238
|
-
expiresAt: new Date(record.expiresAt),
|
|
1239
|
-
});
|
|
1240
|
-
}
|
|
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
|
+
});
|
|
1241
1273
|
}
|
|
1242
1274
|
|
|
1243
1275
|
return ctx.json({
|
|
@@ -1574,43 +1606,22 @@ export const callbackSSO = (options?: SSOOptions) => {
|
|
|
1574
1606
|
provider,
|
|
1575
1607
|
});
|
|
1576
1608
|
}
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
const role = options?.organizationProvisioning?.getRole
|
|
1594
|
-
? await options.organizationProvisioning.getRole({
|
|
1595
|
-
user,
|
|
1596
|
-
userInfo,
|
|
1597
|
-
token: tokenResponse,
|
|
1598
|
-
provider,
|
|
1599
|
-
})
|
|
1600
|
-
: options?.organizationProvisioning?.defaultRole || "member";
|
|
1601
|
-
await ctx.context.adapter.create({
|
|
1602
|
-
model: "member",
|
|
1603
|
-
data: {
|
|
1604
|
-
organizationId: provider.organizationId,
|
|
1605
|
-
userId: user.id,
|
|
1606
|
-
role,
|
|
1607
|
-
createdAt: new Date(),
|
|
1608
|
-
updatedAt: new Date(),
|
|
1609
|
-
},
|
|
1610
|
-
});
|
|
1611
|
-
}
|
|
1612
|
-
}
|
|
1613
|
-
}
|
|
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
|
+
|
|
1614
1625
|
await setSessionCookie(ctx, {
|
|
1615
1626
|
session,
|
|
1616
1627
|
user,
|
|
@@ -1808,6 +1819,8 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
|
|
|
1808
1819
|
|
|
1809
1820
|
const { extract } = parsedResponse!;
|
|
1810
1821
|
|
|
1822
|
+
validateSAMLAlgorithms(parsedResponse, options?.saml?.algorithms);
|
|
1823
|
+
|
|
1811
1824
|
validateSAMLTimestamp((extract as any).conditions, {
|
|
1812
1825
|
clockSkew: options?.saml?.clockSkew,
|
|
1813
1826
|
requireTimestamps: options?.saml?.requireTimestamps,
|
|
@@ -1816,7 +1829,6 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
|
|
|
1816
1829
|
|
|
1817
1830
|
const inResponseTo = (extract as any).inResponseTo as string | undefined;
|
|
1818
1831
|
const shouldValidateInResponseTo =
|
|
1819
|
-
options?.saml?.authnRequestStore ||
|
|
1820
1832
|
options?.saml?.enableInResponseToValidation;
|
|
1821
1833
|
|
|
1822
1834
|
if (shouldValidateInResponseTo) {
|
|
@@ -1825,29 +1837,20 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
|
|
|
1825
1837
|
if (inResponseTo) {
|
|
1826
1838
|
let storedRequest: AuthnRequestRecord | null = null;
|
|
1827
1839
|
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
storedRequest = JSON.parse(
|
|
1839
|
-
verification.value,
|
|
1840
|
-
) as AuthnRequestRecord;
|
|
1841
|
-
// Validate expiration for database-stored records
|
|
1842
|
-
// Note: Cleanup of expired records is handled automatically by
|
|
1843
|
-
// findVerificationValue, but we still need to check expiration
|
|
1844
|
-
// since the record is returned before cleanup runs
|
|
1845
|
-
if (storedRequest && storedRequest.expiresAt < Date.now()) {
|
|
1846
|
-
storedRequest = null;
|
|
1847
|
-
}
|
|
1848
|
-
} 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()) {
|
|
1849
1850
|
storedRequest = null;
|
|
1850
1851
|
}
|
|
1852
|
+
} catch {
|
|
1853
|
+
storedRequest = null;
|
|
1851
1854
|
}
|
|
1852
1855
|
}
|
|
1853
1856
|
|
|
@@ -1873,13 +1876,9 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
|
|
|
1873
1876
|
},
|
|
1874
1877
|
);
|
|
1875
1878
|
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
|
1880
|
-
`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`,
|
|
1881
|
-
);
|
|
1882
|
-
}
|
|
1879
|
+
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
|
1880
|
+
`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`,
|
|
1881
|
+
);
|
|
1883
1882
|
const redirectUrl =
|
|
1884
1883
|
RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
1885
1884
|
throw ctx.redirect(
|
|
@@ -1887,13 +1886,9 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
|
|
|
1887
1886
|
);
|
|
1888
1887
|
}
|
|
1889
1888
|
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
|
1894
|
-
`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`,
|
|
1895
|
-
);
|
|
1896
|
-
}
|
|
1889
|
+
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
|
1890
|
+
`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`,
|
|
1891
|
+
);
|
|
1897
1892
|
} else if (!allowIdpInitiated) {
|
|
1898
1893
|
ctx.context.logger.error(
|
|
1899
1894
|
"SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false",
|
|
@@ -1907,6 +1902,76 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
|
|
|
1907
1902
|
}
|
|
1908
1903
|
}
|
|
1909
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
|
+
|
|
1910
1975
|
const attributes = extract.attributes || {};
|
|
1911
1976
|
const mapping = parsedSamlConfig.mapping ?? {};
|
|
1912
1977
|
|
|
@@ -1948,73 +2013,43 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
|
|
|
1948
2013
|
});
|
|
1949
2014
|
}
|
|
1950
2015
|
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
{
|
|
1957
|
-
|
|
1958
|
-
value: userInfo.email,
|
|
1959
|
-
},
|
|
1960
|
-
],
|
|
1961
|
-
});
|
|
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));
|
|
1962
2023
|
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
model: "account",
|
|
1966
|
-
where: [
|
|
1967
|
-
{ field: "userId", value: existingUser.id },
|
|
1968
|
-
{ field: "providerId", value: provider.providerId },
|
|
1969
|
-
{ field: "accountId", value: userInfo.id },
|
|
1970
|
-
],
|
|
1971
|
-
});
|
|
1972
|
-
if (!account) {
|
|
1973
|
-
const isTrustedProvider =
|
|
1974
|
-
ctx.context.options.account?.accountLinking?.trustedProviders?.includes(
|
|
1975
|
-
provider.providerId,
|
|
1976
|
-
) ||
|
|
1977
|
-
("domainVerified" in provider &&
|
|
1978
|
-
provider.domainVerified &&
|
|
1979
|
-
validateEmailDomain(userInfo.email, provider.domain));
|
|
1980
|
-
if (!isTrustedProvider) {
|
|
1981
|
-
const redirectUrl =
|
|
1982
|
-
RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
1983
|
-
throw ctx.redirect(`${redirectUrl}?error=account_not_linked`);
|
|
1984
|
-
}
|
|
1985
|
-
await ctx.context.internalAdapter.createAccount({
|
|
1986
|
-
userId: existingUser.id,
|
|
1987
|
-
providerId: provider.providerId,
|
|
1988
|
-
accountId: userInfo.id,
|
|
1989
|
-
accessToken: "",
|
|
1990
|
-
refreshToken: "",
|
|
1991
|
-
});
|
|
1992
|
-
}
|
|
1993
|
-
user = existingUser;
|
|
1994
|
-
} else {
|
|
1995
|
-
// if implicit sign up is disabled, we should not create a new user nor a new account.
|
|
1996
|
-
if (options?.disableImplicitSignUp) {
|
|
1997
|
-
throw new APIError("UNAUTHORIZED", {
|
|
1998
|
-
message:
|
|
1999
|
-
"User not found and implicit sign up is disabled for this provider",
|
|
2000
|
-
});
|
|
2001
|
-
}
|
|
2024
|
+
const callbackUrl =
|
|
2025
|
+
RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2002
2026
|
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
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: {
|
|
2010
2035
|
providerId: provider.providerId,
|
|
2011
|
-
accountId: userInfo.id,
|
|
2036
|
+
accountId: userInfo.id as string,
|
|
2012
2037
|
accessToken: "",
|
|
2013
2038
|
refreshToken: "",
|
|
2014
|
-
}
|
|
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
|
+
);
|
|
2015
2049
|
}
|
|
2016
2050
|
|
|
2017
|
-
|
|
2051
|
+
const { session, user } = result.data!;
|
|
2052
|
+
|
|
2018
2053
|
if (options?.provisionUser) {
|
|
2019
2054
|
await options.provisionUser({
|
|
2020
2055
|
user: user as User & Record<string, any>,
|
|
@@ -2023,53 +2058,21 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
|
|
|
2023
2058
|
});
|
|
2024
2059
|
}
|
|
2025
2060
|
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
{ field: "userId", value: user.id },
|
|
2040
|
-
],
|
|
2041
|
-
});
|
|
2042
|
-
if (!isAlreadyMember) {
|
|
2043
|
-
const role = options?.organizationProvisioning?.getRole
|
|
2044
|
-
? await options.organizationProvisioning.getRole({
|
|
2045
|
-
user,
|
|
2046
|
-
userInfo,
|
|
2047
|
-
provider,
|
|
2048
|
-
})
|
|
2049
|
-
: options?.organizationProvisioning?.defaultRole || "member";
|
|
2050
|
-
await ctx.context.adapter.create({
|
|
2051
|
-
model: "member",
|
|
2052
|
-
data: {
|
|
2053
|
-
organizationId: provider.organizationId,
|
|
2054
|
-
userId: user.id,
|
|
2055
|
-
role,
|
|
2056
|
-
createdAt: new Date(),
|
|
2057
|
-
updatedAt: new Date(),
|
|
2058
|
-
},
|
|
2059
|
-
});
|
|
2060
|
-
}
|
|
2061
|
-
}
|
|
2062
|
-
}
|
|
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
|
+
});
|
|
2063
2074
|
|
|
2064
|
-
// Create session and set cookie
|
|
2065
|
-
let session: Session = await ctx.context.internalAdapter.createSession(
|
|
2066
|
-
user.id,
|
|
2067
|
-
);
|
|
2068
2075
|
await setSessionCookie(ctx, { session, user });
|
|
2069
|
-
|
|
2070
|
-
// Redirect to callback URL
|
|
2071
|
-
const callbackUrl =
|
|
2072
|
-
RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2073
2076
|
throw ctx.redirect(callbackUrl);
|
|
2074
2077
|
},
|
|
2075
2078
|
);
|
|
@@ -2246,6 +2249,8 @@ export const acsEndpoint = (options?: SSOOptions) => {
|
|
|
2246
2249
|
|
|
2247
2250
|
const { extract } = parsedResponse!;
|
|
2248
2251
|
|
|
2252
|
+
validateSAMLAlgorithms(parsedResponse, options?.saml?.algorithms);
|
|
2253
|
+
|
|
2249
2254
|
validateSAMLTimestamp((extract as any).conditions, {
|
|
2250
2255
|
clockSkew: options?.saml?.clockSkew,
|
|
2251
2256
|
requireTimestamps: options?.saml?.requireTimestamps,
|
|
@@ -2256,7 +2261,6 @@ export const acsEndpoint = (options?: SSOOptions) => {
|
|
|
2256
2261
|
| string
|
|
2257
2262
|
| undefined;
|
|
2258
2263
|
const shouldValidateInResponseToAcs =
|
|
2259
|
-
options?.saml?.authnRequestStore ||
|
|
2260
2264
|
options?.saml?.enableInResponseToValidation;
|
|
2261
2265
|
|
|
2262
2266
|
if (shouldValidateInResponseToAcs) {
|
|
@@ -2265,25 +2269,20 @@ export const acsEndpoint = (options?: SSOOptions) => {
|
|
|
2265
2269
|
if (inResponseToAcs) {
|
|
2266
2270
|
let storedRequest: AuthnRequestRecord | null = null;
|
|
2267
2271
|
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
storedRequest = JSON.parse(
|
|
2279
|
-
verification.value,
|
|
2280
|
-
) as AuthnRequestRecord;
|
|
2281
|
-
if (storedRequest && storedRequest.expiresAt < Date.now()) {
|
|
2282
|
-
storedRequest = null;
|
|
2283
|
-
}
|
|
2284
|
-
} 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()) {
|
|
2285
2282
|
storedRequest = null;
|
|
2286
2283
|
}
|
|
2284
|
+
} catch {
|
|
2285
|
+
storedRequest = null;
|
|
2287
2286
|
}
|
|
2288
2287
|
}
|
|
2289
2288
|
|
|
@@ -2308,13 +2307,9 @@ export const acsEndpoint = (options?: SSOOptions) => {
|
|
|
2308
2307
|
actualProvider: providerId,
|
|
2309
2308
|
},
|
|
2310
2309
|
);
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
|
2315
|
-
`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`,
|
|
2316
|
-
);
|
|
2317
|
-
}
|
|
2310
|
+
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
|
2311
|
+
`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`,
|
|
2312
|
+
);
|
|
2318
2313
|
const redirectUrl =
|
|
2319
2314
|
RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2320
2315
|
throw ctx.redirect(
|
|
@@ -2322,13 +2317,9 @@ export const acsEndpoint = (options?: SSOOptions) => {
|
|
|
2322
2317
|
);
|
|
2323
2318
|
}
|
|
2324
2319
|
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
|
2329
|
-
`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`,
|
|
2330
|
-
);
|
|
2331
|
-
}
|
|
2320
|
+
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
|
2321
|
+
`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`,
|
|
2322
|
+
);
|
|
2332
2323
|
} else if (!allowIdpInitiated) {
|
|
2333
2324
|
ctx.context.logger.error(
|
|
2334
2325
|
"SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false",
|
|
@@ -2342,6 +2333,76 @@ export const acsEndpoint = (options?: SSOOptions) => {
|
|
|
2342
2333
|
}
|
|
2343
2334
|
}
|
|
2344
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
|
+
|
|
2345
2406
|
const attributes = extract.attributes || {};
|
|
2346
2407
|
const mapping = parsedSamlConfig.mapping ?? {};
|
|
2347
2408
|
|
|
@@ -2384,69 +2445,43 @@ export const acsEndpoint = (options?: SSOOptions) => {
|
|
|
2384
2445
|
});
|
|
2385
2446
|
}
|
|
2386
2447
|
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
{
|
|
2393
|
-
|
|
2394
|
-
value: userInfo.email,
|
|
2395
|
-
},
|
|
2396
|
-
],
|
|
2397
|
-
});
|
|
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));
|
|
2398
2455
|
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
ctx.context.options.account?.accountLinking?.trustedProviders?.includes(
|
|
2411
|
-
provider.providerId,
|
|
2412
|
-
) ||
|
|
2413
|
-
("domainVerified" in provider &&
|
|
2414
|
-
provider.domainVerified &&
|
|
2415
|
-
validateEmailDomain(userInfo.email, provider.domain));
|
|
2416
|
-
if (!isTrustedProvider) {
|
|
2417
|
-
throw ctx.redirect(
|
|
2418
|
-
`${parsedSamlConfig.callbackUrl}?error=account_not_found`,
|
|
2419
|
-
);
|
|
2420
|
-
}
|
|
2421
|
-
await ctx.context.internalAdapter.createAccount({
|
|
2422
|
-
userId: existingUser.id,
|
|
2423
|
-
providerId: provider.providerId,
|
|
2424
|
-
accountId: userInfo.id,
|
|
2425
|
-
accessToken: "",
|
|
2426
|
-
refreshToken: "",
|
|
2427
|
-
});
|
|
2428
|
-
}
|
|
2429
|
-
user = existingUser;
|
|
2430
|
-
} else {
|
|
2431
|
-
user = await ctx.context.internalAdapter.createUser({
|
|
2432
|
-
email: userInfo.email,
|
|
2433
|
-
name: userInfo.name,
|
|
2434
|
-
emailVerified: options?.trustEmailVerified
|
|
2435
|
-
? userInfo.emailVerified || false
|
|
2436
|
-
: false,
|
|
2437
|
-
});
|
|
2438
|
-
await ctx.context.internalAdapter.createAccount({
|
|
2439
|
-
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: {
|
|
2440
2467
|
providerId: provider.providerId,
|
|
2441
|
-
accountId: userInfo.id,
|
|
2468
|
+
accountId: userInfo.id as string,
|
|
2442
2469
|
accessToken: "",
|
|
2443
2470
|
refreshToken: "",
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
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
|
+
);
|
|
2448
2481
|
}
|
|
2449
2482
|
|
|
2483
|
+
const { session, user } = result.data!;
|
|
2484
|
+
|
|
2450
2485
|
if (options?.provisionUser) {
|
|
2451
2486
|
await options.provisionUser({
|
|
2452
2487
|
user: user as User & Record<string, any>,
|
|
@@ -2455,50 +2490,21 @@ export const acsEndpoint = (options?: SSOOptions) => {
|
|
|
2455
2490
|
});
|
|
2456
2491
|
}
|
|
2457
2492
|
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
],
|
|
2472
|
-
});
|
|
2473
|
-
if (!isAlreadyMember) {
|
|
2474
|
-
const role = options?.organizationProvisioning?.getRole
|
|
2475
|
-
? await options.organizationProvisioning.getRole({
|
|
2476
|
-
user,
|
|
2477
|
-
userInfo,
|
|
2478
|
-
provider,
|
|
2479
|
-
})
|
|
2480
|
-
: options?.organizationProvisioning?.defaultRole || "member";
|
|
2481
|
-
await ctx.context.adapter.create({
|
|
2482
|
-
model: "member",
|
|
2483
|
-
data: {
|
|
2484
|
-
organizationId: provider.organizationId,
|
|
2485
|
-
userId: user.id,
|
|
2486
|
-
role,
|
|
2487
|
-
createdAt: new Date(),
|
|
2488
|
-
updatedAt: new Date(),
|
|
2489
|
-
},
|
|
2490
|
-
});
|
|
2491
|
-
}
|
|
2492
|
-
}
|
|
2493
|
-
}
|
|
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
|
+
});
|
|
2494
2506
|
|
|
2495
|
-
let session: Session = await ctx.context.internalAdapter.createSession(
|
|
2496
|
-
user.id,
|
|
2497
|
-
);
|
|
2498
2507
|
await setSessionCookie(ctx, { session, user });
|
|
2499
|
-
|
|
2500
|
-
const callbackUrl =
|
|
2501
|
-
RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2502
2508
|
throw ctx.redirect(callbackUrl);
|
|
2503
2509
|
},
|
|
2504
2510
|
);
|