@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/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"),
@@ -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
- if (options?.saml?.authnRequestStore) {
1232
- await options.saml.authnRequestStore.save(record);
1233
- } else {
1234
- await ctx.context.internalAdapter.createVerificationValue({
1235
- identifier: `${AUTHN_REQUEST_KEY_PREFIX}${record.id}`,
1236
- value: JSON.stringify(record),
1237
- expiresAt: new Date(record.expiresAt),
1238
- });
1239
- }
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
- if (
1577
- provider.organizationId &&
1578
- !options?.organizationProvisioning?.disabled
1579
- ) {
1580
- const isOrgPluginEnabled = ctx.context.options.plugins?.find(
1581
- (plugin) => plugin.id === "organization",
1582
- );
1583
- if (isOrgPluginEnabled) {
1584
- const isAlreadyMember = await ctx.context.adapter.findOne({
1585
- model: "member",
1586
- where: [
1587
- { field: "organizationId", value: provider.organizationId },
1588
- { field: "userId", value: user.id },
1589
- ],
1590
- });
1591
- if (!isAlreadyMember) {
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
- if (options?.saml?.authnRequestStore) {
1828
- storedRequest =
1829
- await options.saml.authnRequestStore.get(inResponseTo);
1830
- } else {
1831
- const verification =
1832
- await ctx.context.internalAdapter.findVerificationValue(
1833
- `${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`,
1834
- );
1835
- if (verification) {
1836
- try {
1837
- storedRequest = JSON.parse(
1838
- verification.value,
1839
- ) as AuthnRequestRecord;
1840
- // Validate expiration for database-stored records
1841
- // Note: Cleanup of expired records is handled automatically by
1842
- // findVerificationValue, but we still need to check expiration
1843
- // since the record is returned before cleanup runs
1844
- if (storedRequest && storedRequest.expiresAt < Date.now()) {
1845
- storedRequest = null;
1846
- }
1847
- } catch {
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
- if (options?.saml?.authnRequestStore) {
1876
- await options.saml.authnRequestStore.delete(inResponseTo);
1877
- } else {
1878
- await ctx.context.internalAdapter.deleteVerificationByIdentifier(
1879
- `${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`,
1880
- );
1881
- }
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
- if (options?.saml?.authnRequestStore) {
1890
- await options.saml.authnRequestStore.delete(inResponseTo);
1891
- } else {
1892
- await ctx.context.internalAdapter.deleteVerificationByIdentifier(
1893
- `${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`,
1894
- );
1895
- }
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
- // Find or create user
1951
- let user: User;
1952
- const existingUser = await ctx.context.adapter.findOne<User>({
1953
- model: "user",
1954
- where: [
1955
- {
1956
- field: "email",
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
- if (existingUser) {
1963
- const account = await ctx.context.adapter.findOne<Account>({
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
- user = await ctx.context.internalAdapter.createUser({
2003
- email: userInfo.email,
2004
- name: userInfo.name,
2005
- emailVerified: userInfo.emailVerified,
2006
- });
2007
- await ctx.context.internalAdapter.createAccount({
2008
- 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: {
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
- // Run provision hooks
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
- // Handle organization provisioning
2026
- if (
2027
- provider.organizationId &&
2028
- !options?.organizationProvisioning?.disabled
2029
- ) {
2030
- const isOrgPluginEnabled = ctx.context.options.plugins?.find(
2031
- (plugin) => plugin.id === "organization",
2032
- );
2033
- if (isOrgPluginEnabled) {
2034
- const isAlreadyMember = await ctx.context.adapter.findOne({
2035
- model: "member",
2036
- where: [
2037
- { field: "organizationId", value: provider.organizationId },
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
- if (options?.saml?.authnRequestStore) {
2268
- storedRequest =
2269
- await options.saml.authnRequestStore.get(inResponseToAcs);
2270
- } else {
2271
- const verification =
2272
- await ctx.context.internalAdapter.findVerificationValue(
2273
- `${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`,
2274
- );
2275
- if (verification) {
2276
- try {
2277
- storedRequest = JSON.parse(
2278
- verification.value,
2279
- ) as AuthnRequestRecord;
2280
- if (storedRequest && storedRequest.expiresAt < Date.now()) {
2281
- storedRequest = null;
2282
- }
2283
- } catch {
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
- if (options?.saml?.authnRequestStore) {
2311
- await options.saml.authnRequestStore.delete(inResponseToAcs);
2312
- } else {
2313
- await ctx.context.internalAdapter.deleteVerificationByIdentifier(
2314
- `${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`,
2315
- );
2316
- }
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
- if (options?.saml?.authnRequestStore) {
2325
- await options.saml.authnRequestStore.delete(inResponseToAcs);
2326
- } else {
2327
- await ctx.context.internalAdapter.deleteVerificationByIdentifier(
2328
- `${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`,
2329
- );
2330
- }
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
- // Find or create user
2387
- let user: User;
2388
- const existingUser = await ctx.context.adapter.findOne<User>({
2389
- model: "user",
2390
- where: [
2391
- {
2392
- field: "email",
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
- if (existingUser) {
2399
- const account = await ctx.context.adapter.findOne<Account>({
2400
- model: "account",
2401
- where: [
2402
- { field: "userId", value: existingUser.id },
2403
- { field: "providerId", value: provider.providerId },
2404
- { field: "accountId", value: userInfo.id },
2405
- ],
2406
- });
2407
- if (!account) {
2408
- const isTrustedProvider =
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
- accessTokenExpiresAt: new Date(),
2444
- refreshTokenExpiresAt: new Date(),
2445
- scope: "",
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
- if (
2458
- provider.organizationId &&
2459
- !options?.organizationProvisioning?.disabled
2460
- ) {
2461
- const isOrgPluginEnabled = ctx.context.options.plugins?.find(
2462
- (plugin) => plugin.id === "organization",
2463
- );
2464
- if (isOrgPluginEnabled) {
2465
- const isAlreadyMember = await ctx.context.adapter.findOne({
2466
- model: "member",
2467
- where: [
2468
- { field: "organizationId", value: provider.organizationId },
2469
- { field: "userId", value: user.id },
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
  );