@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/src/routes/sso.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { BetterFetchError, betterFetch } from "@better-fetch/fetch";
2
- import type { Account, Session, User, Verification } from "better-auth";
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 * as z from "zod/v4";
25
- import type { AuthnRequestRecord } from "../authn-request-store";
26
- import { DEFAULT_AUTHN_REQUEST_TTL_MS } from "../authn-request-store";
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
- if (options?.saml?.authnRequestStore) {
1233
- await options.saml.authnRequestStore.save(record);
1234
- } else {
1235
- await ctx.context.internalAdapter.createVerificationValue({
1236
- identifier: `${AUTHN_REQUEST_KEY_PREFIX}${record.id}`,
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
- if (
1578
- provider.organizationId &&
1579
- !options?.organizationProvisioning?.disabled
1580
- ) {
1581
- const isOrgPluginEnabled = ctx.context.options.plugins?.find(
1582
- (plugin) => plugin.id === "organization",
1583
- );
1584
- if (isOrgPluginEnabled) {
1585
- const isAlreadyMember = await ctx.context.adapter.findOne({
1586
- model: "member",
1587
- where: [
1588
- { field: "organizationId", value: provider.organizationId },
1589
- { field: "userId", value: user.id },
1590
- ],
1591
- });
1592
- if (!isAlreadyMember) {
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
- if (options?.saml?.authnRequestStore) {
1829
- storedRequest =
1830
- await options.saml.authnRequestStore.get(inResponseTo);
1831
- } else {
1832
- const verification =
1833
- await ctx.context.internalAdapter.findVerificationValue(
1834
- `${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`,
1835
- );
1836
- if (verification) {
1837
- try {
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
- if (options?.saml?.authnRequestStore) {
1877
- await options.saml.authnRequestStore.delete(inResponseTo);
1878
- } else {
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
- if (options?.saml?.authnRequestStore) {
1891
- await options.saml.authnRequestStore.delete(inResponseTo);
1892
- } else {
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
- // Find or create user
1952
- let user: User;
1953
- const existingUser = await ctx.context.adapter.findOne<User>({
1954
- model: "user",
1955
- where: [
1956
- {
1957
- field: "email",
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
- if (existingUser) {
1964
- const account = await ctx.context.adapter.findOne<Account>({
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
- user = await ctx.context.internalAdapter.createUser({
2004
- email: userInfo.email,
2005
- name: userInfo.name,
2006
- emailVerified: userInfo.emailVerified,
2007
- });
2008
- await ctx.context.internalAdapter.createAccount({
2009
- userId: user.id,
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
- // Run provision hooks
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
- // Handle organization provisioning
2027
- if (
2028
- provider.organizationId &&
2029
- !options?.organizationProvisioning?.disabled
2030
- ) {
2031
- const isOrgPluginEnabled = ctx.context.options.plugins?.find(
2032
- (plugin) => plugin.id === "organization",
2033
- );
2034
- if (isOrgPluginEnabled) {
2035
- const isAlreadyMember = await ctx.context.adapter.findOne({
2036
- model: "member",
2037
- where: [
2038
- { field: "organizationId", value: provider.organizationId },
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
- if (options?.saml?.authnRequestStore) {
2269
- storedRequest =
2270
- await options.saml.authnRequestStore.get(inResponseToAcs);
2271
- } else {
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()) {
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
- if (options?.saml?.authnRequestStore) {
2312
- await options.saml.authnRequestStore.delete(inResponseToAcs);
2313
- } else {
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
- if (options?.saml?.authnRequestStore) {
2326
- await options.saml.authnRequestStore.delete(inResponseToAcs);
2327
- } else {
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
- // Find or create user
2388
- let user: User;
2389
- const existingUser = await ctx.context.adapter.findOne<User>({
2390
- model: "user",
2391
- where: [
2392
- {
2393
- field: "email",
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
- if (existingUser) {
2400
- const account = await ctx.context.adapter.findOne<Account>({
2401
- model: "account",
2402
- where: [
2403
- { field: "userId", value: existingUser.id },
2404
- { field: "providerId", value: provider.providerId },
2405
- { field: "accountId", value: userInfo.id },
2406
- ],
2407
- });
2408
- if (!account) {
2409
- const isTrustedProvider =
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
- accessTokenExpiresAt: new Date(),
2445
- refreshTokenExpiresAt: new Date(),
2446
- scope: "",
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
- if (
2459
- provider.organizationId &&
2460
- !options?.organizationProvisioning?.disabled
2461
- ) {
2462
- const isOrgPluginEnabled = ctx.context.options.plugins?.find(
2463
- (plugin) => plugin.id === "organization",
2464
- );
2465
- if (isOrgPluginEnabled) {
2466
- const isAlreadyMember = await ctx.context.adapter.findOne({
2467
- model: "member",
2468
- where: [
2469
- { field: "organizationId", value: provider.organizationId },
2470
- { field: "userId", value: user.id },
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
  );