@better-auth/sso 1.4.6 → 1.4.7-beta.3

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.
@@ -1,16 +1,16 @@
1
1
 
2
- > @better-auth/sso@1.4.6 build /home/runner/work/better-auth/better-auth/packages/sso
2
+ > @better-auth/sso@1.4.7-beta.3 build /home/runner/work/better-auth/better-auth/packages/sso
3
3
  > tsdown
4
4
 
5
- ℹ tsdown v0.17.0 powered by rolldown v1.0.0-beta.53
5
+ ℹ tsdown v0.17.2 powered by rolldown v1.0.0-beta.53
6
6
  ℹ config file: /home/runner/work/better-auth/better-auth/packages/sso/tsdown.config.ts
7
7
  ℹ entry: src/index.ts, src/client.ts
8
8
  ℹ tsconfig: tsconfig.json
9
9
  ℹ Build start
10
- ℹ dist/index.mjs 59.70 kB │ gzip: 10.49 kB
10
+ ℹ dist/index.mjs 65.14 kB │ gzip: 11.40 kB
11
11
  ℹ dist/client.mjs  0.15 kB │ gzip: 0.14 kB
12
- ℹ dist/client.d.mts  0.49 kB │ gzip: 0.30 kB
13
- ℹ dist/index.d.mts  0.21 kB │ gzip: 0.15 kB
14
- ℹ dist/index-D-JmJR9N.d.mts 25.42 kB │ gzip: 3.95 kB
15
- ℹ 5 files, total: 85.98 kB
16
- ✔ Build complete in 11585ms
12
+ ℹ dist/client.d.mts  0.49 kB │ gzip: 0.29 kB
13
+ ℹ dist/index.d.mts  0.43 kB │ gzip: 0.23 kB
14
+ ℹ dist/index-m7FISidt.d.mts 28.63 kB │ gzip: 5.07 kB
15
+ ℹ 5 files, total: 94.85 kB
16
+ ✔ Build complete in 11484ms
package/dist/client.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { t as SSOPlugin } from "./index-D-JmJR9N.mjs";
1
+ import { t as SSOPlugin } from "./index-m7FISidt.mjs";
2
2
 
3
3
  //#region src/client.d.ts
4
4
  interface SSOClientOptions {
@@ -2,6 +2,42 @@ import * as z from "zod/v4";
2
2
  import { OAuth2Tokens, User } from "better-auth";
3
3
  import * as better_call0 from "better-call";
4
4
 
5
+ //#region src/authn-request-store.d.ts
6
+
7
+ /**
8
+ * AuthnRequest Store
9
+ *
10
+ * Tracks SAML AuthnRequest IDs to enable InResponseTo validation.
11
+ * This prevents:
12
+ * - Unsolicited SAML responses
13
+ * - Cross-provider response injection
14
+ * - Replay attacks
15
+ * - Expired login completions
16
+ */
17
+ interface AuthnRequestRecord {
18
+ id: string;
19
+ providerId: string;
20
+ createdAt: number;
21
+ expiresAt: number;
22
+ }
23
+ interface AuthnRequestStore {
24
+ save(record: AuthnRequestRecord): Promise<void>;
25
+ get(id: string): Promise<AuthnRequestRecord | null>;
26
+ delete(id: string): Promise<void>;
27
+ }
28
+ /**
29
+ * Default TTL for AuthnRequest records (5 minutes).
30
+ * This should be sufficient for most IdPs while protecting against stale requests.
31
+ */
32
+ declare const DEFAULT_AUTHN_REQUEST_TTL_MS: number;
33
+ /**
34
+ * In-memory implementation of AuthnRequestStore.
35
+ * ⚠️ Only suitable for testing or single-instance non-serverless deployments.
36
+ * For production, rely on the default behavior (uses verification table)
37
+ * or provide a custom Redis-backed store.
38
+ */
39
+ declare function createInMemoryAuthnRequestStore(): AuthnRequestStore;
40
+ //#endregion
5
41
  //#region src/types.d.ts
6
42
  interface OIDCMapping {
7
43
  id?: string | undefined;
@@ -216,7 +252,13 @@ interface SSOOptions {
216
252
  *
217
253
  * If you want to allow account linking for specific trusted providers, enable the `accountLinking` option in your auth config and specify those
218
254
  * providers in the `trustedProviders` list.
255
+ *
219
256
  * @default false
257
+ *
258
+ * @deprecated This option is discouraged for new projects. Relying on provider-level `email_verified` is a weaker
259
+ * trust signal compared to using `trustedProviders` in `accountLinking` or enabling `domainVerification` for SSO.
260
+ * Existing configurations will continue to work, but new integrations should use explicit trust mechanisms.
261
+ * This option may be removed in a future major version.
220
262
  */
221
263
  trustEmailVerified?: boolean | undefined;
222
264
  /**
@@ -237,6 +279,54 @@ interface SSOOptions {
237
279
  */
238
280
  tokenPrefix?: string;
239
281
  };
282
+ /**
283
+ * SAML security options for AuthnRequest/InResponseTo validation.
284
+ * This prevents unsolicited responses, replay attacks, and cross-provider injection.
285
+ */
286
+ saml?: {
287
+ /**
288
+ * Enable InResponseTo validation for SP-initiated SAML flows.
289
+ * When enabled, AuthnRequest IDs are tracked and validated against SAML responses.
290
+ *
291
+ * Storage behavior:
292
+ * - Uses `secondaryStorage` (e.g., Redis) if configured in your auth options
293
+ * - Falls back to the verification table in the database otherwise
294
+ *
295
+ * This works correctly in serverless environments without any additional configuration.
296
+ *
297
+ * @default false
298
+ */
299
+ enableInResponseToValidation?: boolean;
300
+ /**
301
+ * Allow IdP-initiated SSO (unsolicited SAML responses).
302
+ * When true, responses without InResponseTo are accepted.
303
+ * When false, all responses must correlate to a stored AuthnRequest.
304
+ *
305
+ * Only applies when InResponseTo validation is enabled.
306
+ *
307
+ * @default true
308
+ */
309
+ allowIdpInitiated?: boolean;
310
+ /**
311
+ * TTL for AuthnRequest records in milliseconds.
312
+ * Requests older than this will be rejected.
313
+ *
314
+ * Only applies when InResponseTo validation is enabled.
315
+ *
316
+ * @default 300000 (5 minutes)
317
+ */
318
+ requestTTL?: number;
319
+ /**
320
+ * Custom AuthnRequest store implementation.
321
+ * Use this to provide a custom storage backend (e.g., Redis-backed store).
322
+ *
323
+ * Providing a custom store automatically enables InResponseTo validation.
324
+ *
325
+ * Note: When not provided, the default storage (secondaryStorage with
326
+ * verification table fallback) is used automatically.
327
+ */
328
+ authnRequestStore?: AuthnRequestStore;
329
+ };
240
330
  }
241
331
  //#endregion
242
332
  //#region src/routes/domain-verification.d.ts
@@ -285,8 +375,6 @@ declare const requestDomainVerification: (options: SSOOptions) => better_call0.S
285
375
  };
286
376
  };
287
377
  }>)[];
288
- } & {
289
- use: any[];
290
378
  }, {
291
379
  domainVerificationToken: string;
292
380
  }>;
@@ -338,8 +426,6 @@ declare const verifyDomain: (options: SSOOptions) => better_call0.StrictEndpoint
338
426
  };
339
427
  };
340
428
  }>)[];
341
- } & {
342
- use: any[];
343
429
  }, void>;
344
430
  //#endregion
345
431
  //#region src/routes/sso.d.ts
@@ -364,8 +450,6 @@ declare const spMetadata: () => better_call0.StrictEndpoint<"/sso/saml2/sp/metad
364
450
  };
365
451
  };
366
452
  };
367
- } & {
368
- use: any[];
369
453
  }, Response>;
370
454
  declare const registerSSOProvider: <O extends SSOOptions>(options: O) => better_call0.StrictEndpoint<"/sso/register", {
371
455
  method: "POST";
@@ -629,8 +713,6 @@ declare const registerSSOProvider: <O extends SSOOptions>(options: O) => better_
629
713
  };
630
714
  };
631
715
  };
632
- } & {
633
- use: any[];
634
716
  }, O["domainVerification"] extends {
635
717
  enabled: true;
636
718
  } ? {
@@ -651,8 +733,8 @@ declare const signInSSO: (options?: SSOOptions) => better_call0.StrictEndpoint<"
651
733
  loginHint: z.ZodOptional<z.ZodString>;
652
734
  requestSignUp: z.ZodOptional<z.ZodBoolean>;
653
735
  providerType: z.ZodOptional<z.ZodEnum<{
654
- oidc: "oidc";
655
736
  saml: "saml";
737
+ oidc: "oidc";
656
738
  }>>;
657
739
  }, z.core.$strip>;
658
740
  metadata: {
@@ -727,8 +809,6 @@ declare const signInSSO: (options?: SSOOptions) => better_call0.StrictEndpoint<"
727
809
  };
728
810
  };
729
811
  };
730
- } & {
731
- use: any[];
732
812
  }, {
733
813
  url: string;
734
814
  redirect: boolean;
@@ -743,7 +823,6 @@ declare const callbackSSO: (options?: SSOOptions) => better_call0.StrictEndpoint
743
823
  }, z.core.$strip>;
744
824
  allowedMediaTypes: string[];
745
825
  metadata: {
746
- isAction: false;
747
826
  openapi: {
748
827
  operationId: string;
749
828
  summary: string;
@@ -754,9 +833,8 @@ declare const callbackSSO: (options?: SSOOptions) => better_call0.StrictEndpoint
754
833
  };
755
834
  };
756
835
  };
836
+ scope: "server";
757
837
  };
758
- } & {
759
- use: any[];
760
838
  }, never>;
761
839
  declare const callbackSSOSAML: (options?: SSOOptions) => better_call0.StrictEndpoint<"/sso/saml2/callback/:providerId", {
762
840
  method: "POST";
@@ -765,7 +843,6 @@ declare const callbackSSOSAML: (options?: SSOOptions) => better_call0.StrictEndp
765
843
  RelayState: z.ZodOptional<z.ZodString>;
766
844
  }, z.core.$strip>;
767
845
  metadata: {
768
- isAction: false;
769
846
  allowedMediaTypes: string[];
770
847
  openapi: {
771
848
  operationId: string;
@@ -783,9 +860,8 @@ declare const callbackSSOSAML: (options?: SSOOptions) => better_call0.StrictEndp
783
860
  };
784
861
  };
785
862
  };
863
+ scope: "server";
786
864
  };
787
- } & {
788
- use: any[];
789
865
  }, never>;
790
866
  declare const acsEndpoint: (options?: SSOOptions) => better_call0.StrictEndpoint<"/sso/saml2/sp/acs/:providerId", {
791
867
  method: "POST";
@@ -797,7 +873,6 @@ declare const acsEndpoint: (options?: SSOOptions) => better_call0.StrictEndpoint
797
873
  RelayState: z.ZodOptional<z.ZodString>;
798
874
  }, z.core.$strip>;
799
875
  metadata: {
800
- isAction: false;
801
876
  allowedMediaTypes: string[];
802
877
  openapi: {
803
878
  operationId: string;
@@ -809,9 +884,8 @@ declare const acsEndpoint: (options?: SSOOptions) => better_call0.StrictEndpoint
809
884
  };
810
885
  };
811
886
  };
887
+ scope: "server";
812
888
  };
813
- } & {
814
- use: any[];
815
889
  }, never>;
816
890
  //#endregion
817
891
  //#region src/index.d.ts
@@ -850,4 +924,4 @@ declare function sso<O extends SSOOptions>(options?: O | undefined): {
850
924
  endpoints: SSOEndpoints<O>;
851
925
  };
852
926
  //#endregion
853
- export { SSOOptions as a, SAMLConfig as i, sso as n, SSOProvider as o, OIDCConfig as r, SSOPlugin as t };
927
+ export { SSOOptions as a, AuthnRequestStore as c, SAMLConfig as i, DEFAULT_AUTHN_REQUEST_TTL_MS as l, sso as n, SSOProvider as o, OIDCConfig as r, AuthnRequestRecord as s, SSOPlugin as t, createInMemoryAuthnRequestStore as u };
package/dist/index.d.mts CHANGED
@@ -1,2 +1,2 @@
1
- import { a as SSOOptions, i as SAMLConfig, n as sso, o as SSOProvider, r as OIDCConfig, t as SSOPlugin } from "./index-D-JmJR9N.mjs";
2
- export { OIDCConfig, SAMLConfig, SSOOptions, SSOPlugin, SSOProvider, sso };
1
+ import { a as SSOOptions, c as AuthnRequestStore, i as SAMLConfig, l as DEFAULT_AUTHN_REQUEST_TTL_MS, n as sso, o as SSOProvider, r as OIDCConfig, s as AuthnRequestRecord, t as SSOPlugin, u as createInMemoryAuthnRequestStore } from "./index-m7FISidt.mjs";
2
+ export { AuthnRequestRecord, AuthnRequestStore, DEFAULT_AUTHN_REQUEST_TTL_MS, OIDCConfig, SAMLConfig, SSOOptions, SSOPlugin, SSOProvider, createInMemoryAuthnRequestStore, sso };
package/dist/index.mjs CHANGED
@@ -4,11 +4,51 @@ import { APIError, createAuthEndpoint, sessionMiddleware } from "better-auth/api
4
4
  import { generateRandomString } from "better-auth/crypto";
5
5
  import * as z from "zod/v4";
6
6
  import { BetterFetchError, betterFetch } from "@better-fetch/fetch";
7
- import { createAuthorizationURL, generateState, parseState, validateAuthorizationCode, validateToken } from "better-auth";
7
+ import { HIDE_METADATA, createAuthorizationURL, generateState, parseState, validateAuthorizationCode, validateToken } from "better-auth";
8
8
  import { setSessionCookie } from "better-auth/cookies";
9
9
  import { handleOAuthUserInfo } from "better-auth/oauth2";
10
10
  import { decodeJwt } from "jose";
11
11
 
12
+ //#region src/authn-request-store.ts
13
+ /**
14
+ * Default TTL for AuthnRequest records (5 minutes).
15
+ * This should be sufficient for most IdPs while protecting against stale requests.
16
+ */
17
+ const DEFAULT_AUTHN_REQUEST_TTL_MS = 300 * 1e3;
18
+ /**
19
+ * In-memory implementation of AuthnRequestStore.
20
+ * ⚠️ Only suitable for testing or single-instance non-serverless deployments.
21
+ * For production, rely on the default behavior (uses verification table)
22
+ * or provide a custom Redis-backed store.
23
+ */
24
+ function createInMemoryAuthnRequestStore() {
25
+ const store = /* @__PURE__ */ new Map();
26
+ const cleanup = () => {
27
+ const now = Date.now();
28
+ for (const [id, record] of store.entries()) if (record.expiresAt < now) store.delete(id);
29
+ };
30
+ const cleanupInterval = setInterval(cleanup, 60 * 1e3);
31
+ if (typeof cleanupInterval.unref === "function") cleanupInterval.unref();
32
+ return {
33
+ async save(record) {
34
+ store.set(record.id, record);
35
+ },
36
+ async get(id) {
37
+ const record = store.get(id);
38
+ if (!record) return null;
39
+ if (record.expiresAt < Date.now()) {
40
+ store.delete(id);
41
+ return null;
42
+ }
43
+ return record;
44
+ },
45
+ async delete(id) {
46
+ store.delete(id);
47
+ }
48
+ };
49
+ }
50
+
51
+ //#endregion
12
52
  //#region src/routes/domain-verification.ts
13
53
  const domainVerificationBodySchema = z.object({ providerId: z.string() });
14
54
  const requestDomainVerification = (options) => {
@@ -213,6 +253,7 @@ const validateEmailDomain = (email, domain) => {
213
253
 
214
254
  //#endregion
215
255
  //#region src/routes/sso.ts
256
+ const AUTHN_REQUEST_KEY_PREFIX = "saml-authn-request:";
216
257
  const spMetadataQuerySchema = z.object({
217
258
  providerId: z.string(),
218
259
  format: z.enum(["xml", "json"]).default("xml")
@@ -781,6 +822,21 @@ const signInSSO = (options) => {
781
822
  });
782
823
  const loginRequest = sp.createLoginRequest(idp, "redirect");
783
824
  if (!loginRequest) throw new APIError("BAD_REQUEST", { message: "Invalid SAML request" });
825
+ if (loginRequest.id && (options?.saml?.authnRequestStore || options?.saml?.enableInResponseToValidation)) {
826
+ const ttl = options?.saml?.requestTTL ?? DEFAULT_AUTHN_REQUEST_TTL_MS;
827
+ const record = {
828
+ id: loginRequest.id,
829
+ providerId: provider.providerId,
830
+ createdAt: Date.now(),
831
+ expiresAt: Date.now() + ttl
832
+ };
833
+ if (options?.saml?.authnRequestStore) await options.saml.authnRequestStore.save(record);
834
+ else await ctx.context.internalAdapter.createVerificationValue({
835
+ identifier: `${AUTHN_REQUEST_KEY_PREFIX}${record.id}`,
836
+ value: JSON.stringify(record),
837
+ expiresAt: new Date(record.expiresAt)
838
+ });
839
+ }
784
840
  return ctx.json({
785
841
  url: `${loginRequest.context}&RelayState=${encodeURIComponent(body.callbackURL)}`,
786
842
  redirect: true
@@ -801,7 +857,7 @@ const callbackSSO = (options) => {
801
857
  query: callbackSSOQuerySchema,
802
858
  allowedMediaTypes: ["application/x-www-form-urlencoded", "application/json"],
803
859
  metadata: {
804
- isAction: false,
860
+ ...HIDE_METADATA,
805
861
  openapi: {
806
862
  operationId: "handleSSOCallback",
807
863
  summary: "Callback URL for SSO provider",
@@ -810,7 +866,7 @@ const callbackSSO = (options) => {
810
866
  }
811
867
  }
812
868
  }, async (ctx) => {
813
- const { code, state, error, error_description } = ctx.query;
869
+ const { code, error, error_description } = ctx.query;
814
870
  const stateData = await parseState(ctx);
815
871
  if (!stateData) {
816
872
  const errorURL$1 = ctx.context.options.onAPIError?.errorURL || `${ctx.context.baseURL}/error`;
@@ -986,7 +1042,7 @@ const callbackSSOSAML = (options) => {
986
1042
  method: "POST",
987
1043
  body: callbackSSOSAMLBodySchema,
988
1044
  metadata: {
989
- isAction: false,
1045
+ ...HIDE_METADATA,
990
1046
  allowedMediaTypes: ["application/x-www-form-urlencoded", "application/json"],
991
1047
  openapi: {
992
1048
  operationId: "handleSAMLCallback",
@@ -1069,22 +1125,10 @@ const callbackSSOSAML = (options) => {
1069
1125
  });
1070
1126
  let parsedResponse;
1071
1127
  try {
1072
- const decodedResponse = Buffer.from(SAMLResponse, "base64").toString("utf-8");
1073
- try {
1074
- parsedResponse = await sp.parseLoginResponse(idp, "post", { body: {
1075
- SAMLResponse,
1076
- RelayState: RelayState || void 0
1077
- } });
1078
- } catch (parseError) {
1079
- const nameIDMatch = decodedResponse.match(/<saml2:NameID[^>]*>([^<]+)<\/saml2:NameID>/);
1080
- if (!nameIDMatch) throw parseError;
1081
- parsedResponse = { extract: {
1082
- nameID: nameIDMatch[1],
1083
- attributes: { nameID: nameIDMatch[1] },
1084
- sessionIndex: {},
1085
- conditions: {}
1086
- } };
1087
- }
1128
+ parsedResponse = await sp.parseLoginResponse(idp, "post", { body: {
1129
+ SAMLResponse,
1130
+ RelayState: RelayState || void 0
1131
+ } });
1088
1132
  if (!parsedResponse?.extract) throw new Error("Invalid SAML response structure");
1089
1133
  } catch (error) {
1090
1134
  ctx.context.logger.error("SAML response validation failed", {
@@ -1097,6 +1141,47 @@ const callbackSSOSAML = (options) => {
1097
1141
  });
1098
1142
  }
1099
1143
  const { extract } = parsedResponse;
1144
+ const inResponseTo = extract.inResponseTo;
1145
+ if (options?.saml?.authnRequestStore || options?.saml?.enableInResponseToValidation) {
1146
+ const allowIdpInitiated = options?.saml?.allowIdpInitiated !== false;
1147
+ if (inResponseTo) {
1148
+ let storedRequest = null;
1149
+ if (options?.saml?.authnRequestStore) storedRequest = await options.saml.authnRequestStore.get(inResponseTo);
1150
+ else {
1151
+ const verification = await ctx.context.internalAdapter.findVerificationValue(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
1152
+ if (verification) try {
1153
+ storedRequest = JSON.parse(verification.value);
1154
+ } catch {
1155
+ storedRequest = null;
1156
+ }
1157
+ }
1158
+ if (!storedRequest) {
1159
+ ctx.context.logger.error("SAML InResponseTo validation failed: unknown or expired request ID", {
1160
+ inResponseTo,
1161
+ providerId: provider.providerId
1162
+ });
1163
+ const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1164
+ throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`);
1165
+ }
1166
+ if (storedRequest.providerId !== provider.providerId) {
1167
+ ctx.context.logger.error("SAML InResponseTo validation failed: provider mismatch", {
1168
+ inResponseTo,
1169
+ expectedProvider: storedRequest.providerId,
1170
+ actualProvider: provider.providerId
1171
+ });
1172
+ if (options?.saml?.authnRequestStore) await options.saml.authnRequestStore.delete(inResponseTo);
1173
+ else await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
1174
+ const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1175
+ throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`);
1176
+ }
1177
+ if (options?.saml?.authnRequestStore) await options.saml.authnRequestStore.delete(inResponseTo);
1178
+ else await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
1179
+ } else if (!allowIdpInitiated) {
1180
+ ctx.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId: provider.providerId });
1181
+ const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1182
+ throw ctx.redirect(`${redirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`);
1183
+ }
1184
+ }
1100
1185
  const attributes = extract.attributes || {};
1101
1186
  const mapping = parsedSamlConfig.mapping ?? {};
1102
1187
  const userInfo = {
@@ -1223,7 +1308,7 @@ const acsEndpoint = (options) => {
1223
1308
  params: acsEndpointParamsSchema,
1224
1309
  body: acsEndpointBodySchema,
1225
1310
  metadata: {
1226
- isAction: false,
1311
+ ...HIDE_METADATA,
1227
1312
  allowedMediaTypes: ["application/x-www-form-urlencoded", "application/json"],
1228
1313
  openapi: {
1229
1314
  operationId: "handleSAMLAssertionConsumerService",
@@ -1285,26 +1370,10 @@ const acsEndpoint = (options) => {
1285
1370
  }) : saml.IdentityProvider({ metadata: idpData.metadata });
1286
1371
  let parsedResponse;
1287
1372
  try {
1288
- let decodedResponse = Buffer.from(SAMLResponse, "base64").toString("utf-8");
1289
- if (!decodedResponse.includes("StatusCode")) {
1290
- const insertPoint = decodedResponse.indexOf("</saml2:Issuer>");
1291
- if (insertPoint !== -1) decodedResponse = decodedResponse.slice(0, insertPoint + 14) + "<saml2:Status><saml2:StatusCode Value=\"urn:oasis:names:tc:SAML:2.0:status:Success\"/></saml2:Status>" + decodedResponse.slice(insertPoint + 14);
1292
- } else if (!decodedResponse.includes("saml2:Success")) decodedResponse = decodedResponse.replace(/<saml2:StatusCode Value="[^"]+"/, "<saml2:StatusCode Value=\"urn:oasis:names:tc:SAML:2.0:status:Success\"");
1293
- try {
1294
- parsedResponse = await sp.parseLoginResponse(idp, "post", { body: {
1295
- SAMLResponse,
1296
- RelayState: RelayState || void 0
1297
- } });
1298
- } catch (parseError) {
1299
- const nameIDMatch = decodedResponse.match(/<saml2:NameID[^>]*>([^<]+)<\/saml2:NameID>/);
1300
- if (!nameIDMatch) throw parseError;
1301
- parsedResponse = { extract: {
1302
- nameID: nameIDMatch[1],
1303
- attributes: { nameID: nameIDMatch[1] },
1304
- sessionIndex: {},
1305
- conditions: {}
1306
- } };
1307
- }
1373
+ parsedResponse = await sp.parseLoginResponse(idp, "post", { body: {
1374
+ SAMLResponse,
1375
+ RelayState: RelayState || void 0
1376
+ } });
1308
1377
  if (!parsedResponse?.extract) throw new Error("Invalid SAML response structure");
1309
1378
  } catch (error) {
1310
1379
  ctx.context.logger.error("SAML response validation failed", {
@@ -1317,6 +1386,47 @@ const acsEndpoint = (options) => {
1317
1386
  });
1318
1387
  }
1319
1388
  const { extract } = parsedResponse;
1389
+ const inResponseToAcs = extract.inResponseTo;
1390
+ if (options?.saml?.authnRequestStore || options?.saml?.enableInResponseToValidation) {
1391
+ const allowIdpInitiated = options?.saml?.allowIdpInitiated !== false;
1392
+ if (inResponseToAcs) {
1393
+ let storedRequest = null;
1394
+ if (options?.saml?.authnRequestStore) storedRequest = await options.saml.authnRequestStore.get(inResponseToAcs);
1395
+ else {
1396
+ const verification = await ctx.context.internalAdapter.findVerificationValue(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`);
1397
+ if (verification) try {
1398
+ storedRequest = JSON.parse(verification.value);
1399
+ } catch {
1400
+ storedRequest = null;
1401
+ }
1402
+ }
1403
+ if (!storedRequest) {
1404
+ ctx.context.logger.error("SAML InResponseTo validation failed: unknown or expired request ID", {
1405
+ inResponseTo: inResponseToAcs,
1406
+ providerId
1407
+ });
1408
+ const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1409
+ throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`);
1410
+ }
1411
+ if (storedRequest.providerId !== providerId) {
1412
+ ctx.context.logger.error("SAML InResponseTo validation failed: provider mismatch", {
1413
+ inResponseTo: inResponseToAcs,
1414
+ expectedProvider: storedRequest.providerId,
1415
+ actualProvider: providerId
1416
+ });
1417
+ if (options?.saml?.authnRequestStore) await options.saml.authnRequestStore.delete(inResponseToAcs);
1418
+ else await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`);
1419
+ const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1420
+ throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`);
1421
+ }
1422
+ if (options?.saml?.authnRequestStore) await options.saml.authnRequestStore.delete(inResponseToAcs);
1423
+ else await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`);
1424
+ } else if (!allowIdpInitiated) {
1425
+ ctx.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId });
1426
+ const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1427
+ throw ctx.redirect(`${redirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`);
1428
+ }
1429
+ }
1320
1430
  const attributes = extract.attributes || {};
1321
1431
  const mapping = parsedSamlConfig.mapping ?? {};
1322
1432
  const userInfo = {
@@ -1439,18 +1549,19 @@ saml.setSchemaValidator({ async validate(xml) {
1439
1549
  throw "ERR_INVALID_XML";
1440
1550
  } });
1441
1551
  function sso(options) {
1552
+ const optionsWithStore = options;
1442
1553
  let endpoints = {
1443
1554
  spMetadata: spMetadata(),
1444
- registerSSOProvider: registerSSOProvider(options),
1445
- signInSSO: signInSSO(options),
1446
- callbackSSO: callbackSSO(options),
1447
- callbackSSOSAML: callbackSSOSAML(options),
1448
- acsEndpoint: acsEndpoint(options)
1555
+ registerSSOProvider: registerSSOProvider(optionsWithStore),
1556
+ signInSSO: signInSSO(optionsWithStore),
1557
+ callbackSSO: callbackSSO(optionsWithStore),
1558
+ callbackSSOSAML: callbackSSOSAML(optionsWithStore),
1559
+ acsEndpoint: acsEndpoint(optionsWithStore)
1449
1560
  };
1450
1561
  if (options?.domainVerification?.enabled) {
1451
1562
  const domainVerificationEndpoints = {
1452
- requestDomainVerification: requestDomainVerification(options),
1453
- verifyDomain: verifyDomain(options)
1563
+ requestDomainVerification: requestDomainVerification(optionsWithStore),
1564
+ verifyDomain: verifyDomain(optionsWithStore)
1454
1565
  };
1455
1566
  endpoints = {
1456
1567
  ...endpoints,
@@ -1512,4 +1623,4 @@ function sso(options) {
1512
1623
  }
1513
1624
 
1514
1625
  //#endregion
1515
- export { sso };
1626
+ export { DEFAULT_AUTHN_REQUEST_TTL_MS, createInMemoryAuthnRequestStore, sso };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@better-auth/sso",
3
3
  "author": "Bereket Engida",
4
- "version": "1.4.6",
4
+ "version": "1.4.7-beta.3",
5
5
  "type": "module",
6
6
  "main": "dist/index.mjs",
7
7
  "types": "dist/index.d.mts",
@@ -52,7 +52,7 @@
52
52
  }
53
53
  },
54
54
  "dependencies": {
55
- "@better-fetch/fetch": "1.1.18",
55
+ "@better-fetch/fetch": "1.1.21",
56
56
  "fast-xml-parser": "^5.2.5",
57
57
  "jose": "^6.1.0",
58
58
  "samlify": "^2.10.1",
@@ -65,16 +65,17 @@
65
65
  "body-parser": "^2.2.1",
66
66
  "express": "^5.1.0",
67
67
  "oauth2-mock-server": "^8.2.0",
68
- "tsdown": "^0.17.0",
69
- "better-auth": "1.4.6"
68
+ "tsdown": "^0.17.2",
69
+ "better-auth": "1.4.7-beta.3"
70
70
  },
71
71
  "peerDependencies": {
72
- "better-auth": "1.4.6"
72
+ "better-auth": "1.4.7-beta.3"
73
73
  },
74
74
  "scripts": {
75
75
  "test": "vitest",
76
76
  "coverage": "vitest run --coverage",
77
77
  "lint:package": "publint run --strict",
78
+ "lint:types": "attw --profile esm-only --pack .",
78
79
  "build": "tsdown",
79
80
  "dev": "tsdown --watch",
80
81
  "typecheck": "tsc --project tsconfig.json"
@@ -0,0 +1,76 @@
1
+ /**
2
+ * AuthnRequest Store
3
+ *
4
+ * Tracks SAML AuthnRequest IDs to enable InResponseTo validation.
5
+ * This prevents:
6
+ * - Unsolicited SAML responses
7
+ * - Cross-provider response injection
8
+ * - Replay attacks
9
+ * - Expired login completions
10
+ */
11
+
12
+ export interface AuthnRequestRecord {
13
+ id: string;
14
+ providerId: string;
15
+ createdAt: number;
16
+ expiresAt: number;
17
+ }
18
+
19
+ export interface AuthnRequestStore {
20
+ save(record: AuthnRequestRecord): Promise<void>;
21
+ get(id: string): Promise<AuthnRequestRecord | null>;
22
+ delete(id: string): Promise<void>;
23
+ }
24
+
25
+ /**
26
+ * Default TTL for AuthnRequest records (5 minutes).
27
+ * This should be sufficient for most IdPs while protecting against stale requests.
28
+ */
29
+ export const DEFAULT_AUTHN_REQUEST_TTL_MS = 5 * 60 * 1000;
30
+
31
+ /**
32
+ * In-memory implementation of AuthnRequestStore.
33
+ * ⚠️ Only suitable for testing or single-instance non-serverless deployments.
34
+ * For production, rely on the default behavior (uses verification table)
35
+ * or provide a custom Redis-backed store.
36
+ */
37
+ export function createInMemoryAuthnRequestStore(): AuthnRequestStore {
38
+ const store = new Map<string, AuthnRequestRecord>();
39
+
40
+ const cleanup = () => {
41
+ const now = Date.now();
42
+ for (const [id, record] of store.entries()) {
43
+ if (record.expiresAt < now) {
44
+ store.delete(id);
45
+ }
46
+ }
47
+ };
48
+
49
+ const cleanupInterval = setInterval(cleanup, 60 * 1000);
50
+
51
+ if (typeof cleanupInterval.unref === "function") {
52
+ cleanupInterval.unref();
53
+ }
54
+
55
+ return {
56
+ async save(record: AuthnRequestRecord): Promise<void> {
57
+ store.set(record.id, record);
58
+ },
59
+
60
+ async get(id: string): Promise<AuthnRequestRecord | null> {
61
+ const record = store.get(id);
62
+ if (!record) {
63
+ return null;
64
+ }
65
+ if (record.expiresAt < Date.now()) {
66
+ store.delete(id);
67
+ return null;
68
+ }
69
+ return record;
70
+ },
71
+
72
+ async delete(id: string): Promise<void> {
73
+ store.delete(id);
74
+ },
75
+ };
76
+ }