@better-auth/sso 1.4.7-beta.2 → 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,5 +1,5 @@
1
1
 
2
- > @better-auth/sso@1.4.7-beta.2 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
5
  ℹ tsdown v0.17.2 powered by rolldown v1.0.0-beta.53
@@ -7,10 +7,10 @@
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.48 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-BWvN4yrs.d.mts 25.84 kB │ gzip: 4.13 kB
15
- ℹ 5 files, total: 86.39 kB
16
- ✔ Build complete in 12102ms
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-BWvN4yrs.mjs";
1
+ import { t as SSOPlugin } from "./index-m7FISidt.mjs";
2
2
 
3
3
  //#region src/client.d.ts
4
4
  interface SSOClientOptions {
@@ -1,7 +1,43 @@
1
1
  import * as z from "zod/v4";
2
2
  import { OAuth2Tokens, User } from "better-auth";
3
- import * as better_call7 from "better-call";
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;
@@ -243,10 +279,58 @@ interface SSOOptions {
243
279
  */
244
280
  tokenPrefix?: string;
245
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
+ };
246
330
  }
247
331
  //#endregion
248
332
  //#region src/routes/domain-verification.d.ts
249
- declare const requestDomainVerification: (options: SSOOptions) => better_call7.StrictEndpoint<"/sso/request-domain-verification", {
333
+ declare const requestDomainVerification: (options: SSOOptions) => better_call0.StrictEndpoint<"/sso/request-domain-verification", {
250
334
  method: "POST";
251
335
  body: z.ZodObject<{
252
336
  providerId: z.ZodString;
@@ -268,7 +352,7 @@ declare const requestDomainVerification: (options: SSOOptions) => better_call7.S
268
352
  };
269
353
  };
270
354
  };
271
- use: ((inputContext: better_call7.MiddlewareInputContext<better_call7.MiddlewareOptions>) => Promise<{
355
+ use: ((inputContext: better_call0.MiddlewareInputContext<better_call0.MiddlewareOptions>) => Promise<{
272
356
  session: {
273
357
  session: Record<string, any> & {
274
358
  id: string;
@@ -291,12 +375,10 @@ declare const requestDomainVerification: (options: SSOOptions) => better_call7.S
291
375
  };
292
376
  };
293
377
  }>)[];
294
- } & {
295
- use: any[];
296
378
  }, {
297
379
  domainVerificationToken: string;
298
380
  }>;
299
- declare const verifyDomain: (options: SSOOptions) => better_call7.StrictEndpoint<"/sso/verify-domain", {
381
+ declare const verifyDomain: (options: SSOOptions) => better_call0.StrictEndpoint<"/sso/verify-domain", {
300
382
  method: "POST";
301
383
  body: z.ZodObject<{
302
384
  providerId: z.ZodString;
@@ -321,7 +403,7 @@ declare const verifyDomain: (options: SSOOptions) => better_call7.StrictEndpoint
321
403
  };
322
404
  };
323
405
  };
324
- use: ((inputContext: better_call7.MiddlewareInputContext<better_call7.MiddlewareOptions>) => Promise<{
406
+ use: ((inputContext: better_call0.MiddlewareInputContext<better_call0.MiddlewareOptions>) => Promise<{
325
407
  session: {
326
408
  session: Record<string, any> & {
327
409
  id: string;
@@ -344,12 +426,10 @@ declare const verifyDomain: (options: SSOOptions) => better_call7.StrictEndpoint
344
426
  };
345
427
  };
346
428
  }>)[];
347
- } & {
348
- use: any[];
349
429
  }, void>;
350
430
  //#endregion
351
431
  //#region src/routes/sso.d.ts
352
- declare const spMetadata: () => better_call7.StrictEndpoint<"/sso/saml2/sp/metadata", {
432
+ declare const spMetadata: () => better_call0.StrictEndpoint<"/sso/saml2/sp/metadata", {
353
433
  method: "GET";
354
434
  query: z.ZodObject<{
355
435
  providerId: z.ZodString;
@@ -370,10 +450,8 @@ declare const spMetadata: () => better_call7.StrictEndpoint<"/sso/saml2/sp/metad
370
450
  };
371
451
  };
372
452
  };
373
- } & {
374
- use: any[];
375
453
  }, Response>;
376
- declare const registerSSOProvider: <O extends SSOOptions>(options: O) => better_call7.StrictEndpoint<"/sso/register", {
454
+ declare const registerSSOProvider: <O extends SSOOptions>(options: O) => better_call0.StrictEndpoint<"/sso/register", {
377
455
  method: "POST";
378
456
  body: z.ZodObject<{
379
457
  providerId: z.ZodString;
@@ -451,7 +529,7 @@ declare const registerSSOProvider: <O extends SSOOptions>(options: O) => better_
451
529
  organizationId: z.ZodOptional<z.ZodString>;
452
530
  overrideUserInfo: z.ZodOptional<z.ZodDefault<z.ZodBoolean>>;
453
531
  }, z.core.$strip>;
454
- use: ((inputContext: better_call7.MiddlewareInputContext<better_call7.MiddlewareOptions>) => Promise<{
532
+ use: ((inputContext: better_call0.MiddlewareInputContext<better_call0.MiddlewareOptions>) => Promise<{
455
533
  session: {
456
534
  session: Record<string, any> & {
457
535
  id: string;
@@ -635,15 +713,13 @@ declare const registerSSOProvider: <O extends SSOOptions>(options: O) => better_
635
713
  };
636
714
  };
637
715
  };
638
- } & {
639
- use: any[];
640
716
  }, O["domainVerification"] extends {
641
717
  enabled: true;
642
718
  } ? {
643
719
  domainVerified: boolean;
644
720
  domainVerificationToken: string;
645
721
  } & SSOProvider<O> : SSOProvider<O>>;
646
- declare const signInSSO: (options?: SSOOptions) => better_call7.StrictEndpoint<"/sign-in/sso", {
722
+ declare const signInSSO: (options?: SSOOptions) => better_call0.StrictEndpoint<"/sign-in/sso", {
647
723
  method: "POST";
648
724
  body: z.ZodObject<{
649
725
  email: z.ZodOptional<z.ZodString>;
@@ -657,8 +733,8 @@ declare const signInSSO: (options?: SSOOptions) => better_call7.StrictEndpoint<"
657
733
  loginHint: z.ZodOptional<z.ZodString>;
658
734
  requestSignUp: z.ZodOptional<z.ZodBoolean>;
659
735
  providerType: z.ZodOptional<z.ZodEnum<{
660
- oidc: "oidc";
661
736
  saml: "saml";
737
+ oidc: "oidc";
662
738
  }>>;
663
739
  }, z.core.$strip>;
664
740
  metadata: {
@@ -733,13 +809,11 @@ declare const signInSSO: (options?: SSOOptions) => better_call7.StrictEndpoint<"
733
809
  };
734
810
  };
735
811
  };
736
- } & {
737
- use: any[];
738
812
  }, {
739
813
  url: string;
740
814
  redirect: boolean;
741
815
  }>;
742
- declare const callbackSSO: (options?: SSOOptions) => better_call7.StrictEndpoint<"/sso/callback/:providerId", {
816
+ declare const callbackSSO: (options?: SSOOptions) => better_call0.StrictEndpoint<"/sso/callback/:providerId", {
743
817
  method: "GET";
744
818
  query: z.ZodObject<{
745
819
  code: z.ZodOptional<z.ZodString>;
@@ -749,7 +823,6 @@ declare const callbackSSO: (options?: SSOOptions) => better_call7.StrictEndpoint
749
823
  }, z.core.$strip>;
750
824
  allowedMediaTypes: string[];
751
825
  metadata: {
752
- isAction: false;
753
826
  openapi: {
754
827
  operationId: string;
755
828
  summary: string;
@@ -760,18 +833,16 @@ declare const callbackSSO: (options?: SSOOptions) => better_call7.StrictEndpoint
760
833
  };
761
834
  };
762
835
  };
836
+ scope: "server";
763
837
  };
764
- } & {
765
- use: any[];
766
838
  }, never>;
767
- declare const callbackSSOSAML: (options?: SSOOptions) => better_call7.StrictEndpoint<"/sso/saml2/callback/:providerId", {
839
+ declare const callbackSSOSAML: (options?: SSOOptions) => better_call0.StrictEndpoint<"/sso/saml2/callback/:providerId", {
768
840
  method: "POST";
769
841
  body: z.ZodObject<{
770
842
  SAMLResponse: z.ZodString;
771
843
  RelayState: z.ZodOptional<z.ZodString>;
772
844
  }, z.core.$strip>;
773
845
  metadata: {
774
- isAction: false;
775
846
  allowedMediaTypes: string[];
776
847
  openapi: {
777
848
  operationId: string;
@@ -789,11 +860,10 @@ declare const callbackSSOSAML: (options?: SSOOptions) => better_call7.StrictEndp
789
860
  };
790
861
  };
791
862
  };
863
+ scope: "server";
792
864
  };
793
- } & {
794
- use: any[];
795
865
  }, never>;
796
- declare const acsEndpoint: (options?: SSOOptions) => better_call7.StrictEndpoint<"/sso/saml2/sp/acs/:providerId", {
866
+ declare const acsEndpoint: (options?: SSOOptions) => better_call0.StrictEndpoint<"/sso/saml2/sp/acs/:providerId", {
797
867
  method: "POST";
798
868
  params: z.ZodObject<{
799
869
  providerId: z.ZodOptional<z.ZodString>;
@@ -803,7 +873,6 @@ declare const acsEndpoint: (options?: SSOOptions) => better_call7.StrictEndpoint
803
873
  RelayState: z.ZodOptional<z.ZodString>;
804
874
  }, z.core.$strip>;
805
875
  metadata: {
806
- isAction: false;
807
876
  allowedMediaTypes: string[];
808
877
  openapi: {
809
878
  operationId: string;
@@ -815,9 +884,8 @@ declare const acsEndpoint: (options?: SSOOptions) => better_call7.StrictEndpoint
815
884
  };
816
885
  };
817
886
  };
887
+ scope: "server";
818
888
  };
819
- } & {
820
- use: any[];
821
889
  }, never>;
822
890
  //#endregion
823
891
  //#region src/index.d.ts
@@ -856,4 +924,4 @@ declare function sso<O extends SSOOptions>(options?: O | undefined): {
856
924
  endpoints: SSOEndpoints<O>;
857
925
  };
858
926
  //#endregion
859
- 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-BWvN4yrs.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",
@@ -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.7-beta.2",
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",
@@ -66,10 +66,10 @@
66
66
  "express": "^5.1.0",
67
67
  "oauth2-mock-server": "^8.2.0",
68
68
  "tsdown": "^0.17.2",
69
- "better-auth": "1.4.7-beta.2"
69
+ "better-auth": "1.4.7-beta.3"
70
70
  },
71
71
  "peerDependencies": {
72
- "better-auth": "1.4.7-beta.2"
72
+ "better-auth": "1.4.7-beta.3"
73
73
  },
74
74
  "scripts": {
75
75
  "test": "vitest",