@better-auth/sso 1.6.3 → 1.7.0-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/dist/client.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { t as SSOPlugin } from "./index-DyoL-0jp.mjs";
1
+ import { t as SSOPlugin } from "./index-CagV4mMx.mjs";
2
2
 
3
3
  //#region src/client.d.ts
4
4
  interface SSOClientOptions {
package/dist/client.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { t as PACKAGE_VERSION } from "./version-1sp6DKT-.mjs";
1
+ import { t as PACKAGE_VERSION } from "./version-C22JHwcK.mjs";
2
2
  //#region src/client.ts
3
3
  const ssoClient = (options) => {
4
4
  return {
@@ -64,28 +64,46 @@ interface OIDCConfig {
64
64
  issuer: string;
65
65
  pkce: boolean;
66
66
  clientId: string;
67
- clientSecret: string;
67
+ /** Required for client_secret_basic/client_secret_post. Optional for private_key_jwt. */
68
+ clientSecret?: string;
68
69
  authorizationEndpoint?: string | undefined;
69
70
  discoveryEndpoint: string;
70
71
  userInfoEndpoint?: string | undefined;
71
72
  scopes?: string[] | undefined;
72
73
  overrideUserInfo?: boolean | undefined;
73
74
  tokenEndpoint?: string | undefined;
74
- tokenEndpointAuthentication?: ("client_secret_post" | "client_secret_basic") | undefined;
75
+ tokenEndpointAuthentication?: ("client_secret_post" | "client_secret_basic" | "private_key_jwt") | undefined;
76
+ /** Key ID for private_key_jwt key resolution */
77
+ privateKeyId?: string | undefined;
78
+ /** Signing algorithm for private_key_jwt. @default "RS256" */
79
+ privateKeyAlgorithm?: string | undefined;
75
80
  jwksEndpoint?: string | undefined;
76
81
  mapping?: OIDCMapping | undefined;
77
82
  }
78
83
  interface SAMLConfig {
84
+ /**
85
+ * SP Entity ID. Used as the `entityID` in SP metadata when
86
+ * `spMetadata.entityID` is not set. Also used as the expected
87
+ * audience for SAML assertion validation when `audience` is not set.
88
+ */
79
89
  issuer: string;
90
+ /**
91
+ * IdP SSO URL. Used as the redirect destination when
92
+ * `idpMetadata.metadata` is not provided. Ignored when
93
+ * IdP metadata XML is set (the SSO URL is extracted from the XML).
94
+ */
80
95
  entryPoint: string;
96
+ /**
97
+ * IdP signing certificate. Used to verify SAML response signatures
98
+ * when `idpMetadata.metadata` is not provided. Ignored when IdP
99
+ * metadata XML is set (the certificate is extracted from the XML).
100
+ * When both this and `idpMetadata.cert` are set, `idpMetadata.cert` takes precedence.
101
+ */
81
102
  cert: string;
82
- callbackUrl: string;
83
103
  audience?: string | undefined;
84
104
  idpMetadata?: {
85
105
  metadata?: string;
86
106
  entityID?: string;
87
- entityURL?: string;
88
- redirectURL?: string;
89
107
  cert?: string;
90
108
  privateKey?: string;
91
109
  privateKeyPass?: string;
@@ -101,7 +119,12 @@ interface SAMLConfig {
101
119
  Location: string;
102
120
  }>;
103
121
  } | undefined;
104
- spMetadata: {
122
+ /**
123
+ * SP metadata configuration. All fields are optional; when omitted,
124
+ * SP metadata is auto-generated from `issuer`, `wantAssertionsSigned`,
125
+ * `authnRequestsSigned`, and `identifierFormat`.
126
+ */
127
+ spMetadata?: {
105
128
  metadata?: string | undefined;
106
129
  entityID?: string | undefined;
107
130
  binding?: string | undefined;
@@ -111,14 +134,17 @@ interface SAMLConfig {
111
134
  encPrivateKey?: string | undefined;
112
135
  encPrivateKeyPass?: string | undefined;
113
136
  };
137
+ /**
138
+ * Request signed assertions from the IdP. When true, the SP metadata
139
+ * advertises `WantAssertionsSigned="true"` and samlify will reject
140
+ * unsigned assertions.
141
+ */
114
142
  wantAssertionsSigned?: boolean | undefined;
115
143
  authnRequestsSigned?: boolean | undefined;
116
144
  signatureAlgorithm?: string | undefined;
117
145
  digestAlgorithm?: string | undefined;
118
146
  identifierFormat?: string | undefined;
119
147
  privateKey?: string | undefined;
120
- decryptionPvk?: string | undefined;
121
- additionalParams?: Record<string, any> | undefined;
122
148
  mapping?: SAMLMapping | undefined;
123
149
  }
124
150
  type BaseSSOProvider = {
@@ -214,6 +240,14 @@ interface SSOOptions {
214
240
  * OIDC configuration
215
241
  */
216
242
  oidcConfig?: OIDCConfig;
243
+ /**
244
+ * Private key for `private_key_jwt` authentication.
245
+ * Only used with defaultSSO — not stored in DB.
246
+ */
247
+ privateKey?: {
248
+ privateKeyJwk?: JsonWebKey;
249
+ privateKeyPem?: string;
250
+ };
217
251
  }> | undefined;
218
252
  /**
219
253
  * Override user info with the provider info.
@@ -306,6 +340,21 @@ interface SSOOptions {
306
340
  * per-provider callback URLs. Can be a path or a full URL.
307
341
  */
308
342
  redirectURI?: string;
343
+ /**
344
+ * Callback to resolve private key material for private_key_jwt authentication.
345
+ * Called during token exchange when a provider uses tokenEndpointAuthentication: "private_key_jwt".
346
+ * Keeps private keys out of the database — supports HSM/KMS/Vault integration.
347
+ */
348
+ resolvePrivateKey?: (params: {
349
+ providerId: string;
350
+ keyId?: string;
351
+ issuer: string;
352
+ }) => Promise<{
353
+ privateKeyJwk?: JsonWebKey;
354
+ privateKeyPem?: string;
355
+ kid?: string;
356
+ algorithm?: string;
357
+ }>;
309
358
  /**
310
359
  * SAML security options for AuthnRequest/InResponseTo validation.
311
360
  * This prevents unsolicited responses, replay attacks, and cross-provider injection.
@@ -329,9 +378,13 @@ interface SSOOptions {
329
378
  * When true, responses without InResponseTo are accepted.
330
379
  * When false, all responses must correlate to a stored AuthnRequest.
331
380
  *
381
+ * IdP-initiated SSO is a known attack vector — the SAML2Int
382
+ * interoperability profile recommends against it. Only enable
383
+ * this if your IdP requires it and you understand the risks.
384
+ *
332
385
  * Only applies when InResponseTo validation is enabled.
333
386
  *
334
- * @default true
387
+ * @default false
335
388
  */
336
389
  allowIdpInitiated?: boolean;
337
390
  /**
@@ -578,11 +631,10 @@ declare const listSSOProviders: () => better_call0.StrictEndpoint<"/sso/provider
578
631
  userInfoEndpoint: string | undefined;
579
632
  jwksEndpoint: string | undefined;
580
633
  scopes: string[] | undefined;
581
- tokenEndpointAuthentication: "client_secret_post" | "client_secret_basic" | undefined;
634
+ tokenEndpointAuthentication: "client_secret_post" | "client_secret_basic" | "private_key_jwt" | undefined;
582
635
  } | undefined;
583
636
  samlConfig: {
584
637
  entryPoint: string;
585
- callbackUrl: string;
586
638
  audience: string | undefined;
587
639
  wantAssertionsSigned: boolean | undefined;
588
640
  authnRequestsSigned: boolean | undefined;
@@ -663,11 +715,10 @@ declare const getSSOProvider: () => better_call0.StrictEndpoint<"/sso/get-provid
663
715
  userInfoEndpoint: string | undefined;
664
716
  jwksEndpoint: string | undefined;
665
717
  scopes: string[] | undefined;
666
- tokenEndpointAuthentication: "client_secret_post" | "client_secret_basic" | undefined;
718
+ tokenEndpointAuthentication: "client_secret_post" | "client_secret_basic" | "private_key_jwt" | undefined;
667
719
  } | undefined;
668
720
  samlConfig: {
669
721
  entryPoint: string;
670
- callbackUrl: string;
671
722
  audience: string | undefined;
672
723
  wantAssertionsSigned: boolean | undefined;
673
724
  authnRequestsSigned: boolean | undefined;
@@ -722,7 +773,10 @@ declare const updateSSOProvider: (options: SSOOptions) => better_call0.StrictEnd
722
773
  tokenEndpointAuthentication: z.ZodOptional<z.ZodEnum<{
723
774
  client_secret_post: "client_secret_post";
724
775
  client_secret_basic: "client_secret_basic";
776
+ private_key_jwt: "private_key_jwt";
725
777
  }>>;
778
+ privateKeyId: z.ZodOptional<z.ZodString>;
779
+ privateKeyAlgorithm: z.ZodOptional<z.ZodString>;
726
780
  jwksEndpoint: z.ZodOptional<z.ZodString>;
727
781
  discoveryEndpoint: z.ZodOptional<z.ZodString>;
728
782
  scopes: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -740,7 +794,6 @@ declare const updateSSOProvider: (options: SSOOptions) => better_call0.StrictEnd
740
794
  samlConfig: z.ZodOptional<z.ZodObject<{
741
795
  entryPoint: z.ZodOptional<z.ZodString>;
742
796
  cert: z.ZodOptional<z.ZodString>;
743
- callbackUrl: z.ZodOptional<z.ZodString>;
744
797
  audience: z.ZodOptional<z.ZodString>;
745
798
  idpMetadata: z.ZodOptional<z.ZodObject<{
746
799
  metadata: z.ZodOptional<z.ZodString>;
@@ -772,8 +825,6 @@ declare const updateSSOProvider: (options: SSOOptions) => better_call0.StrictEnd
772
825
  digestAlgorithm: z.ZodOptional<z.ZodString>;
773
826
  identifierFormat: z.ZodOptional<z.ZodString>;
774
827
  privateKey: z.ZodOptional<z.ZodString>;
775
- decryptionPvk: z.ZodOptional<z.ZodString>;
776
- additionalParams: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodAny>>;
777
828
  mapping: z.ZodOptional<z.ZodObject<{
778
829
  id: z.ZodOptional<z.ZodString>;
779
830
  email: z.ZodOptional<z.ZodString>;
@@ -820,11 +871,10 @@ declare const updateSSOProvider: (options: SSOOptions) => better_call0.StrictEnd
820
871
  userInfoEndpoint: string | undefined;
821
872
  jwksEndpoint: string | undefined;
822
873
  scopes: string[] | undefined;
823
- tokenEndpointAuthentication: "client_secret_post" | "client_secret_basic" | undefined;
874
+ tokenEndpointAuthentication: "client_secret_post" | "client_secret_basic" | "private_key_jwt" | undefined;
824
875
  } | undefined;
825
876
  samlConfig: {
826
877
  entryPoint: string;
827
- callbackUrl: string;
828
878
  audience: string | undefined;
829
879
  wantAssertionsSigned: boolean | undefined;
830
880
  authnRequestsSigned: boolean | undefined;
@@ -892,35 +942,11 @@ declare const deleteSSOProvider: () => better_call0.StrictEndpoint<"/sso/delete-
892
942
  success: boolean;
893
943
  }>;
894
944
  //#endregion
895
- //#region src/saml/timestamp.d.ts
896
- interface TimestampValidationOptions {
897
- clockSkew?: number;
898
- requireTimestamps?: boolean;
899
- logger?: {
900
- warn: (message: string, data?: Record<string, unknown>) => void;
901
- };
902
- }
903
- /** Conditions extracted from SAML assertion */
904
- interface SAMLConditions {
905
- notBefore?: string;
906
- notOnOrAfter?: string;
907
- }
908
- /**
909
- * Validates SAML assertion timestamp conditions (NotBefore/NotOnOrAfter).
910
- * Prevents acceptance of expired or future-dated assertions.
911
- * @throws {APIError} If timestamps are invalid, expired, or not yet valid
912
- */
913
- declare function validateSAMLTimestamp(conditions: SAMLConditions | undefined, options?: TimestampValidationOptions): void;
914
- //#endregion
915
945
  //#region src/routes/sso.d.ts
916
946
  declare const spMetadata: (options?: SSOOptions) => better_call0.StrictEndpoint<"/sso/saml2/sp/metadata", {
917
947
  method: "GET";
918
948
  query: z.ZodObject<{
919
949
  providerId: z.ZodString;
920
- format: z.ZodDefault<z.ZodEnum<{
921
- json: "json";
922
- xml: "xml";
923
- }>>;
924
950
  }, z.core.$strip>;
925
951
  metadata: {
926
952
  openapi: {
@@ -943,14 +969,17 @@ declare const registerSSOProvider: <O extends SSOOptions>(options: O) => better_
943
969
  domain: z.ZodString;
944
970
  oidcConfig: z.ZodOptional<z.ZodObject<{
945
971
  clientId: z.ZodString;
946
- clientSecret: z.ZodString;
972
+ clientSecret: z.ZodOptional<z.ZodString>;
947
973
  authorizationEndpoint: z.ZodOptional<z.ZodString>;
948
974
  tokenEndpoint: z.ZodOptional<z.ZodString>;
949
975
  userInfoEndpoint: z.ZodOptional<z.ZodString>;
950
976
  tokenEndpointAuthentication: z.ZodOptional<z.ZodEnum<{
951
977
  client_secret_post: "client_secret_post";
952
978
  client_secret_basic: "client_secret_basic";
979
+ private_key_jwt: "private_key_jwt";
953
980
  }>>;
981
+ privateKeyId: z.ZodOptional<z.ZodString>;
982
+ privateKeyAlgorithm: z.ZodOptional<z.ZodString>;
954
983
  jwksEndpoint: z.ZodOptional<z.ZodString>;
955
984
  discoveryEndpoint: z.ZodOptional<z.ZodString>;
956
985
  skipDiscovery: z.ZodOptional<z.ZodBoolean>;
@@ -968,7 +997,6 @@ declare const registerSSOProvider: <O extends SSOOptions>(options: O) => better_
968
997
  samlConfig: z.ZodOptional<z.ZodObject<{
969
998
  entryPoint: z.ZodString;
970
999
  cert: z.ZodString;
971
- callbackUrl: z.ZodString;
972
1000
  audience: z.ZodOptional<z.ZodString>;
973
1001
  idpMetadata: z.ZodOptional<z.ZodObject<{
974
1002
  metadata: z.ZodOptional<z.ZodString>;
@@ -984,7 +1012,7 @@ declare const registerSSOProvider: <O extends SSOOptions>(options: O) => better_
984
1012
  Location: z.ZodString;
985
1013
  }, z.core.$strip>>>;
986
1014
  }, z.core.$strip>>;
987
- spMetadata: z.ZodObject<{
1015
+ spMetadata: z.ZodOptional<z.ZodObject<{
988
1016
  metadata: z.ZodOptional<z.ZodString>;
989
1017
  entityID: z.ZodOptional<z.ZodString>;
990
1018
  binding: z.ZodOptional<z.ZodString>;
@@ -993,15 +1021,13 @@ declare const registerSSOProvider: <O extends SSOOptions>(options: O) => better_
993
1021
  isAssertionEncrypted: z.ZodOptional<z.ZodBoolean>;
994
1022
  encPrivateKey: z.ZodOptional<z.ZodString>;
995
1023
  encPrivateKeyPass: z.ZodOptional<z.ZodString>;
996
- }, z.core.$strip>;
1024
+ }, z.core.$strip>>;
997
1025
  wantAssertionsSigned: z.ZodOptional<z.ZodBoolean>;
998
1026
  authnRequestsSigned: z.ZodOptional<z.ZodBoolean>;
999
1027
  signatureAlgorithm: z.ZodOptional<z.ZodString>;
1000
1028
  digestAlgorithm: z.ZodOptional<z.ZodString>;
1001
1029
  identifierFormat: z.ZodOptional<z.ZodString>;
1002
1030
  privateKey: z.ZodOptional<z.ZodString>;
1003
- decryptionPvk: z.ZodOptional<z.ZodString>;
1004
- additionalParams: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodAny>>;
1005
1031
  mapping: z.ZodOptional<z.ZodObject<{
1006
1032
  id: z.ZodString;
1007
1033
  email: z.ZodString;
@@ -1358,7 +1384,7 @@ declare const callbackSSOShared: (options?: SSOOptions) => better_call0.StrictEn
1358
1384
  }, z.core.$strip>;
1359
1385
  allowedMediaTypes: readonly ["application/x-www-form-urlencoded", "application/json"];
1360
1386
  }, void>;
1361
- declare const callbackSSOSAML: (options?: SSOOptions) => better_call0.StrictEndpoint<"/sso/saml2/callback/:providerId", {
1387
+ declare const acsEndpoint: (options?: SSOOptions) => better_call0.StrictEndpoint<"/sso/saml2/sp/acs/:providerId", {
1362
1388
  method: ("POST" | "GET")[];
1363
1389
  body: z.ZodOptional<z.ZodObject<{
1364
1390
  SAMLResponse: z.ZodString;
@@ -1380,28 +1406,7 @@ declare const callbackSSOSAML: (options?: SSOOptions) => better_call0.StrictEndp
1380
1406
  "400": {
1381
1407
  description: string;
1382
1408
  };
1383
- "401": {
1384
- description: string;
1385
- };
1386
- };
1387
- };
1388
- scope: "server";
1389
- };
1390
- }, never>;
1391
- declare const acsEndpoint: (options?: SSOOptions) => better_call0.StrictEndpoint<"/sso/saml2/sp/acs/:providerId", {
1392
- method: "POST";
1393
- body: z.ZodObject<{
1394
- SAMLResponse: z.ZodString;
1395
- RelayState: z.ZodOptional<z.ZodString>;
1396
- }, z.core.$strip>;
1397
- metadata: {
1398
- allowedMediaTypes: string[];
1399
- openapi: {
1400
- operationId: string;
1401
- summary: string;
1402
- description: string;
1403
- responses: {
1404
- "302": {
1409
+ "404": {
1405
1410
  description: string;
1406
1411
  };
1407
1412
  };
@@ -1485,6 +1490,26 @@ declare const DEFAULT_MAX_SAML_RESPONSE_SIZE: number;
1485
1490
  */
1486
1491
  declare const DEFAULT_MAX_SAML_METADATA_SIZE: number;
1487
1492
  //#endregion
1493
+ //#region src/saml/timestamp.d.ts
1494
+ interface TimestampValidationOptions {
1495
+ clockSkew?: number;
1496
+ requireTimestamps?: boolean;
1497
+ logger?: {
1498
+ warn: (message: string, data?: Record<string, unknown>) => void;
1499
+ };
1500
+ }
1501
+ /** Conditions extracted from SAML assertion */
1502
+ interface SAMLConditions {
1503
+ notBefore?: string;
1504
+ notOnOrAfter?: string;
1505
+ }
1506
+ /**
1507
+ * Validates SAML assertion timestamp conditions (NotBefore/NotOnOrAfter).
1508
+ * Prevents acceptance of expired or future-dated assertions.
1509
+ * @throws {APIError} If timestamps are invalid, expired, or not yet valid
1510
+ */
1511
+ declare function validateSAMLTimestamp(conditions: SAMLConditions | undefined, options?: TimestampValidationOptions): void;
1512
+ //#endregion
1488
1513
  //#region src/oidc/types.d.ts
1489
1514
  /**
1490
1515
  * OIDC Discovery Types
@@ -1593,7 +1618,7 @@ interface HydratedOIDCConfig {
1593
1618
  /** URL of the userinfo endpoint (optional) */
1594
1619
  userInfoEndpoint?: string;
1595
1620
  /** Token endpoint authentication method */
1596
- tokenEndpointAuthentication?: "client_secret_basic" | "client_secret_post";
1621
+ tokenEndpointAuthentication?: "client_secret_basic" | "client_secret_post" | "private_key_jwt";
1597
1622
  /** Scopes supported by the IdP */
1598
1623
  scopesSupported?: string[];
1599
1624
  }
@@ -1717,7 +1742,7 @@ declare function normalizeUrl(name: string, endpoint: string, issuer: string): s
1717
1742
  * @param existing - Existing authentication method from config
1718
1743
  * @returns The selected authentication method
1719
1744
  */
1720
- declare function selectTokenEndpointAuthMethod(doc: OIDCDiscoveryDocument, existing?: "client_secret_basic" | "client_secret_post"): "client_secret_basic" | "client_secret_post";
1745
+ declare function selectTokenEndpointAuthMethod(doc: OIDCDiscoveryDocument, existing?: "client_secret_basic" | "client_secret_post" | "private_key_jwt"): "client_secret_basic" | "client_secret_post" | "private_key_jwt";
1721
1746
  /**
1722
1747
  * Check if a provider configuration needs runtime discovery.
1723
1748
  *
@@ -1750,7 +1775,6 @@ type SSOEndpoints<O extends SSOOptions> = {
1750
1775
  signInSSO: ReturnType<typeof signInSSO>;
1751
1776
  callbackSSO: ReturnType<typeof callbackSSO>;
1752
1777
  callbackSSOShared: ReturnType<typeof callbackSSOShared>;
1753
- callbackSSOSAML: ReturnType<typeof callbackSSOSAML>;
1754
1778
  acsEndpoint: ReturnType<typeof acsEndpoint>;
1755
1779
  sloEndpoint: ReturnType<typeof sloEndpoint>;
1756
1780
  initiateSLO: ReturnType<typeof initiateSLO>;
@@ -1786,4 +1810,4 @@ declare function sso<O extends SSOOptions>(options?: O | undefined): {
1786
1810
  options: NoInfer<O>;
1787
1811
  };
1788
1812
  //#endregion
1789
- export { DataEncryptionAlgorithm as A, TimestampValidationOptions as C, SSOOptions as D, SAMLConfig as E, DigestAlgorithm as M, KeyEncryptionAlgorithm as N, SSOProvider as O, SignatureAlgorithm as P, SAMLConditions as S, OIDCConfig as T, REQUIRED_DISCOVERY_FIELDS as _, fetchDiscoveryDocument as a, DEFAULT_MAX_SAML_METADATA_SIZE as b, normalizeUrl as c, validateDiscoveryUrl as d, DiscoverOIDCConfigParams as f, OIDCDiscoveryDocument as g, HydratedOIDCConfig as h, discoverOIDCConfig as i, DeprecatedAlgorithmBehavior as j, AlgorithmValidationOptions as k, selectTokenEndpointAuthMethod as l, DiscoveryErrorCode as m, sso as n, needsRuntimeDiscovery as o, DiscoveryError as p, computeDiscoveryUrl as r, normalizeDiscoveryUrls as s, SSOPlugin as t, validateDiscoveryDocument as u, RequiredDiscoveryField as v, validateSAMLTimestamp as w, DEFAULT_MAX_SAML_RESPONSE_SIZE as x, DEFAULT_CLOCK_SKEW_MS as y };
1813
+ export { DataEncryptionAlgorithm as A, DEFAULT_MAX_SAML_METADATA_SIZE as C, SSOOptions as D, SAMLConfig as E, DigestAlgorithm as M, KeyEncryptionAlgorithm as N, SSOProvider as O, SignatureAlgorithm as P, DEFAULT_CLOCK_SKEW_MS as S, OIDCConfig as T, REQUIRED_DISCOVERY_FIELDS as _, fetchDiscoveryDocument as a, TimestampValidationOptions as b, normalizeUrl as c, validateDiscoveryUrl as d, DiscoverOIDCConfigParams as f, OIDCDiscoveryDocument as g, HydratedOIDCConfig as h, discoverOIDCConfig as i, DeprecatedAlgorithmBehavior as j, AlgorithmValidationOptions as k, selectTokenEndpointAuthMethod as l, DiscoveryErrorCode as m, sso as n, needsRuntimeDiscovery as o, DiscoveryError as p, computeDiscoveryUrl as r, normalizeDiscoveryUrls as s, SSOPlugin as t, validateDiscoveryDocument as u, RequiredDiscoveryField as v, DEFAULT_MAX_SAML_RESPONSE_SIZE as w, validateSAMLTimestamp as x, SAMLConditions as y };
package/dist/index.d.mts CHANGED
@@ -1,2 +1,2 @@
1
- import { A as DataEncryptionAlgorithm, C as TimestampValidationOptions, D as SSOOptions, E as SAMLConfig, M as DigestAlgorithm, N as KeyEncryptionAlgorithm, O as SSOProvider, P as SignatureAlgorithm, S as SAMLConditions, T as OIDCConfig, _ as REQUIRED_DISCOVERY_FIELDS, a as fetchDiscoveryDocument, b as DEFAULT_MAX_SAML_METADATA_SIZE, c as normalizeUrl, d as validateDiscoveryUrl, f as DiscoverOIDCConfigParams, g as OIDCDiscoveryDocument, h as HydratedOIDCConfig, i as discoverOIDCConfig, j as DeprecatedAlgorithmBehavior, k as AlgorithmValidationOptions, l as selectTokenEndpointAuthMethod, m as DiscoveryErrorCode, n as sso, o as needsRuntimeDiscovery, p as DiscoveryError, r as computeDiscoveryUrl, s as normalizeDiscoveryUrls, t as SSOPlugin, u as validateDiscoveryDocument, v as RequiredDiscoveryField, w as validateSAMLTimestamp, x as DEFAULT_MAX_SAML_RESPONSE_SIZE, y as DEFAULT_CLOCK_SKEW_MS } from "./index-DyoL-0jp.mjs";
1
+ import { A as DataEncryptionAlgorithm, C as DEFAULT_MAX_SAML_METADATA_SIZE, D as SSOOptions, E as SAMLConfig, M as DigestAlgorithm, N as KeyEncryptionAlgorithm, O as SSOProvider, P as SignatureAlgorithm, S as DEFAULT_CLOCK_SKEW_MS, T as OIDCConfig, _ as REQUIRED_DISCOVERY_FIELDS, a as fetchDiscoveryDocument, b as TimestampValidationOptions, c as normalizeUrl, d as validateDiscoveryUrl, f as DiscoverOIDCConfigParams, g as OIDCDiscoveryDocument, h as HydratedOIDCConfig, i as discoverOIDCConfig, j as DeprecatedAlgorithmBehavior, k as AlgorithmValidationOptions, l as selectTokenEndpointAuthMethod, m as DiscoveryErrorCode, n as sso, o as needsRuntimeDiscovery, p as DiscoveryError, r as computeDiscoveryUrl, s as normalizeDiscoveryUrls, t as SSOPlugin, u as validateDiscoveryDocument, v as RequiredDiscoveryField, w as DEFAULT_MAX_SAML_RESPONSE_SIZE, x as validateSAMLTimestamp, y as SAMLConditions } from "./index-CagV4mMx.mjs";
2
2
  export { AlgorithmValidationOptions, DEFAULT_CLOCK_SKEW_MS, DEFAULT_MAX_SAML_METADATA_SIZE, DEFAULT_MAX_SAML_RESPONSE_SIZE, DataEncryptionAlgorithm, DeprecatedAlgorithmBehavior, DigestAlgorithm, DiscoverOIDCConfigParams, DiscoveryError, DiscoveryErrorCode, HydratedOIDCConfig, KeyEncryptionAlgorithm, OIDCConfig, OIDCDiscoveryDocument, REQUIRED_DISCOVERY_FIELDS, RequiredDiscoveryField, SAMLConditions, SAMLConfig, SSOOptions, SSOPlugin, SSOProvider, SignatureAlgorithm, TimestampValidationOptions, computeDiscoveryUrl, discoverOIDCConfig, fetchDiscoveryDocument, needsRuntimeDiscovery, normalizeDiscoveryUrls, normalizeUrl, selectTokenEndpointAuthMethod, sso, validateDiscoveryDocument, validateDiscoveryUrl, validateSAMLTimestamp };
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { t as PACKAGE_VERSION } from "./version-1sp6DKT-.mjs";
1
+ import { t as PACKAGE_VERSION } from "./version-C22JHwcK.mjs";
2
2
  import { APIError, createAuthEndpoint, createAuthMiddleware, getSessionFromCtx, sessionMiddleware } from "better-auth/api";
3
3
  import { XMLParser, XMLValidator } from "fast-xml-parser";
4
4
  import * as saml from "samlify";
@@ -8,7 +8,7 @@ import { generateRandomString } from "better-auth/crypto";
8
8
  import * as z from "zod";
9
9
  import { base64 } from "@better-auth/utils/base64";
10
10
  import { BetterFetchError, betterFetch } from "@better-fetch/fetch";
11
- import { HIDE_METADATA, createAuthorizationURL, generateGenericState, generateState, parseGenericState, parseState, validateAuthorizationCode, validateToken } from "better-auth";
11
+ import { ASSERTION_SIGNING_ALGORITHMS, HIDE_METADATA, createAuthorizationURL, generateGenericState, generateState, parseGenericState, parseState, validateAuthorizationCode, validateToken } from "better-auth";
12
12
  import { deleteSessionCookie, setSessionCookie } from "better-auth/cookies";
13
13
  import { handleOAuthUserInfo } from "better-auth/oauth2";
14
14
  import { decodeJwt } from "jose";
@@ -625,6 +625,94 @@ function validateSingleAssertion(samlResponse) {
625
625
  });
626
626
  }
627
627
  //#endregion
628
+ //#region src/saml/response-validation.ts
629
+ function errorRedirectUrl(base, error, description) {
630
+ try {
631
+ const url = new URL(base);
632
+ url.searchParams.set("error", error);
633
+ url.searchParams.set("error_description", description);
634
+ return url.toString();
635
+ } catch {
636
+ const hashIdx = base.indexOf("#");
637
+ const path = hashIdx >= 0 ? base.slice(0, hashIdx) : base;
638
+ const hash = hashIdx >= 0 ? base.slice(hashIdx + 1) : void 0;
639
+ return `${path}${path.includes("?") ? "&" : "?"}${`error=${encodeURIComponent(error)}&error_description=${encodeURIComponent(description)}`}${hash ? `#${hash}` : ""}`;
640
+ }
641
+ }
642
+ /**
643
+ * Validates the InResponseTo attribute of a SAML Response.
644
+ *
645
+ * This binds the IdP's Response to a specific SP-initiated AuthnRequest,
646
+ * preventing replay attacks, unsolicited response injection, and
647
+ * cross-provider assertion swaps.
648
+ *
649
+ * The InResponseTo value lives at `extract.response.inResponseTo` in
650
+ * samlify's parsed output (not at the top level).
651
+ */
652
+ async function validateInResponseTo(c, ctx) {
653
+ if (ctx.options.enableInResponseToValidation === false) return;
654
+ const inResponseTo = ctx.extract.response?.inResponseTo;
655
+ const allowIdpInitiated = ctx.options.allowIdpInitiated ?? false;
656
+ if (inResponseTo) {
657
+ let storedRequest = null;
658
+ const verification = await c.context.internalAdapter.findVerificationValue(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
659
+ if (verification) try {
660
+ storedRequest = JSON.parse(verification.value);
661
+ if (storedRequest && storedRequest.expiresAt < Date.now()) storedRequest = null;
662
+ } catch {
663
+ storedRequest = null;
664
+ }
665
+ if (!storedRequest) {
666
+ c.context.logger.error("SAML InResponseTo validation failed: unknown or expired request ID", {
667
+ inResponseTo,
668
+ providerId: ctx.providerId
669
+ });
670
+ throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Unknown or expired request ID"));
671
+ }
672
+ if (storedRequest.providerId !== ctx.providerId) {
673
+ c.context.logger.error("SAML InResponseTo validation failed: provider mismatch", {
674
+ inResponseTo,
675
+ expectedProvider: storedRequest.providerId,
676
+ actualProvider: ctx.providerId
677
+ });
678
+ await c.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
679
+ throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Provider mismatch"));
680
+ }
681
+ await c.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
682
+ } else if (!allowIdpInitiated) {
683
+ c.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId: ctx.providerId });
684
+ throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "unsolicited_response", "IdP-initiated SSO not allowed"));
685
+ }
686
+ }
687
+ /**
688
+ * Validates the AudienceRestriction of a SAML assertion.
689
+ *
690
+ * Per SAML 2.0 Core §2.5.1, an assertion's Audience element specifies
691
+ * the intended recipient SP. Without this check, an assertion issued
692
+ * for a different SP (e.g., another application sharing the same IdP)
693
+ * could be accepted.
694
+ */
695
+ function validateAudience(c, ctx) {
696
+ if (!ctx.expectedAudience) {
697
+ c.context.logger.warn("Could not determine SP entity ID for audience validation; skipping", { providerId: ctx.providerId });
698
+ return;
699
+ }
700
+ const audience = ctx.extract.audience;
701
+ if (!audience) {
702
+ c.context.logger.error("SAML assertion missing AudienceRestriction but audience is configured — rejecting", { providerId: ctx.providerId });
703
+ throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Audience restriction missing"));
704
+ }
705
+ const audiences = Array.isArray(audience) ? audience : [audience];
706
+ if (!audiences.includes(ctx.expectedAudience)) {
707
+ c.context.logger.error("SAML audience mismatch: assertion was issued for a different service provider", {
708
+ expected: ctx.expectedAudience,
709
+ received: audiences,
710
+ providerId: ctx.providerId
711
+ });
712
+ throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Audience mismatch"));
713
+ }
714
+ }
715
+ //#endregion
628
716
  //#region src/routes/schemas.ts
629
717
  const oidcMappingSchema = z.object({
630
718
  id: z.string().optional(),
@@ -649,7 +737,13 @@ const oidcConfigSchema = z.object({
649
737
  authorizationEndpoint: z.string().url().optional(),
650
738
  tokenEndpoint: z.string().url().optional(),
651
739
  userInfoEndpoint: z.string().url().optional(),
652
- tokenEndpointAuthentication: z.enum(["client_secret_post", "client_secret_basic"]).optional(),
740
+ tokenEndpointAuthentication: z.enum([
741
+ "client_secret_post",
742
+ "client_secret_basic",
743
+ "private_key_jwt"
744
+ ]).optional(),
745
+ privateKeyId: z.string().optional(),
746
+ privateKeyAlgorithm: z.string().optional(),
653
747
  jwksEndpoint: z.string().url().optional(),
654
748
  discoveryEndpoint: z.string().url().optional(),
655
749
  scopes: z.array(z.string()).optional(),
@@ -660,7 +754,6 @@ const oidcConfigSchema = z.object({
660
754
  const samlConfigSchema = z.object({
661
755
  entryPoint: z.string().url().optional(),
662
756
  cert: z.string().optional(),
663
- callbackUrl: z.string().url().optional(),
664
757
  audience: z.string().optional(),
665
758
  idpMetadata: z.object({
666
759
  metadata: z.string().optional(),
@@ -692,8 +785,6 @@ const samlConfigSchema = z.object({
692
785
  digestAlgorithm: z.string().optional(),
693
786
  identifierFormat: z.string().optional(),
694
787
  privateKey: z.string().optional(),
695
- decryptionPvk: z.string().optional(),
696
- additionalParams: z.record(z.string(), z.any()).optional(),
697
788
  mapping: samlMappingSchema
698
789
  });
699
790
  const updateSSOProviderBodySchema = z.object({
@@ -770,7 +861,6 @@ function sanitizeProvider(provider, baseURL) {
770
861
  } : void 0,
771
862
  samlConfig: samlConfig ? {
772
863
  entryPoint: samlConfig.entryPoint,
773
- callbackUrl: samlConfig.callbackUrl,
774
864
  audience: samlConfig.audience,
775
865
  wantAssertionsSigned: samlConfig.wantAssertionsSigned,
776
866
  authnRequestsSigned: samlConfig.authnRequestsSigned,
@@ -873,7 +963,6 @@ function mergeSAMLConfig(current, updates, issuer) {
873
963
  issuer,
874
964
  entryPoint: updates.entryPoint ?? current.entryPoint,
875
965
  cert: updates.cert ?? current.cert,
876
- callbackUrl: updates.callbackUrl ?? current.callbackUrl,
877
966
  spMetadata: updates.spMetadata ?? current.spMetadata,
878
967
  idpMetadata: updates.idpMetadata ?? current.idpMetadata,
879
968
  mapping: updates.mapping ?? current.mapping,
@@ -900,7 +989,9 @@ function mergeOIDCConfig(current, updates, issuer) {
900
989
  tokenEndpoint: updates.tokenEndpoint ?? current.tokenEndpoint,
901
990
  userInfoEndpoint: updates.userInfoEndpoint ?? current.userInfoEndpoint,
902
991
  jwksEndpoint: updates.jwksEndpoint ?? current.jwksEndpoint,
903
- tokenEndpointAuthentication: updates.tokenEndpointAuthentication ?? current.tokenEndpointAuthentication
992
+ tokenEndpointAuthentication: updates.tokenEndpointAuthentication ?? current.tokenEndpointAuthentication,
993
+ privateKeyId: updates.privateKeyId ?? current.privateKeyId,
994
+ privateKeyAlgorithm: updates.privateKeyAlgorithm ?? current.privateKeyAlgorithm
904
995
  };
905
996
  }
906
997
  const updateSSOProvider = (options) => {
@@ -945,6 +1036,8 @@ const updateSSOProvider = (options) => {
945
1036
  if (body.oidcConfig) {
946
1037
  const currentOidcConfig = parseAndValidateConfig(existingProvider.oidcConfig, "OIDC");
947
1038
  const updatedOidcConfig = mergeOIDCConfig(currentOidcConfig, body.oidcConfig, updateData.issuer || currentOidcConfig.issuer || existingProvider.issuer);
1039
+ if (updatedOidcConfig.tokenEndpointAuthentication !== "private_key_jwt" && !updatedOidcConfig.clientSecret) throw new APIError("BAD_REQUEST", { message: "clientSecret is required when using client_secret_basic or client_secret_post authentication" });
1040
+ if (updatedOidcConfig.tokenEndpointAuthentication === "private_key_jwt" && !options?.resolvePrivateKey && !options?.defaultSSO?.some((p) => p.providerId === providerId && "privateKey" in p && p.privateKey)) throw new APIError("BAD_REQUEST", { message: "private_key_jwt authentication requires either a resolvePrivateKey callback or a privateKey in defaultSSO" });
948
1041
  updateData.oidcConfig = JSON.stringify(updatedOidcConfig);
949
1042
  }
950
1043
  await ctx.context.adapter.update({
@@ -1242,11 +1335,13 @@ function parseURL(name, endpoint, base) {
1242
1335
  * @returns The selected authentication method
1243
1336
  */
1244
1337
  function selectTokenEndpointAuthMethod(doc, existing) {
1338
+ if (existing === "private_key_jwt") return existing;
1245
1339
  if (existing) return existing;
1246
1340
  const supported = doc.token_endpoint_auth_methods_supported;
1247
1341
  if (!supported || supported.length === 0) return "client_secret_basic";
1248
1342
  if (supported.includes("client_secret_basic")) return "client_secret_basic";
1249
1343
  if (supported.includes("client_secret_post")) return "client_secret_post";
1344
+ if (supported.includes("private_key_jwt")) return "private_key_jwt";
1250
1345
  return "client_secret_basic";
1251
1346
  }
1252
1347
  /**
@@ -1413,6 +1508,16 @@ async function parseRelayState(c) {
1413
1508
  }
1414
1509
  //#endregion
1415
1510
  //#region src/routes/helpers.ts
1511
+ /**
1512
+ * Normalizes a PEM string by trimming leading/trailing whitespace from each
1513
+ * line. Native `crypto.createPrivateKey` (used by samlify 2.12+) rejects PEM
1514
+ * blocks with leading whitespace, which is common when keys are stored in
1515
+ * indented config files, environment variables, or JSON.
1516
+ */
1517
+ function normalizePem(pem) {
1518
+ if (!pem) return pem;
1519
+ return pem.split("\n").map((line) => line.trim()).join("\n");
1520
+ }
1416
1521
  async function findSAMLProvider(providerId, options, adapter) {
1417
1522
  if (options?.defaultSSO?.length) {
1418
1523
  const match = options.defaultSSO.find((p) => p.providerId === providerId);
@@ -1439,30 +1544,35 @@ async function findSAMLProvider(providerId, options, adapter) {
1439
1544
  function createSP(config, baseURL, providerId, opts) {
1440
1545
  const spData = config.spMetadata;
1441
1546
  const sloLocation = `${baseURL}/sso/saml2/sp/slo/${providerId}`;
1442
- const acsUrl = config.callbackUrl || `${baseURL}/sso/saml2/sp/acs/${providerId}`;
1443
- return saml.ServiceProvider({
1547
+ const acsUrl = `${baseURL}/sso/saml2/sp/acs/${providerId}`;
1548
+ let metadata = spData?.metadata;
1549
+ if (!metadata) metadata = saml.SPMetadata({
1444
1550
  entityID: spData?.entityID || config.issuer,
1445
- assertionConsumerService: spData?.metadata ? void 0 : [{
1551
+ assertionConsumerService: [{
1446
1552
  Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
1447
1553
  Location: acsUrl
1448
1554
  }],
1449
- singleLogoutService: [{
1555
+ singleLogoutService: opts?.sloOptions ? [{
1450
1556
  Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
1451
1557
  Location: sloLocation
1452
1558
  }, {
1453
1559
  Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
1454
1560
  Location: sloLocation
1455
- }],
1561
+ }] : void 0,
1456
1562
  wantMessageSigned: config.wantAssertionsSigned || false,
1563
+ authnRequestsSigned: config.authnRequestsSigned || false,
1564
+ nameIDFormat: config.identifierFormat ? [config.identifierFormat] : void 0
1565
+ }).getMetadata() || "";
1566
+ return saml.ServiceProvider({
1567
+ metadata,
1568
+ allowCreate: true,
1457
1569
  wantLogoutRequestSigned: opts?.sloOptions?.wantLogoutRequestSigned ?? false,
1458
1570
  wantLogoutResponseSigned: opts?.sloOptions?.wantLogoutResponseSigned ?? false,
1459
- metadata: spData?.metadata,
1460
- privateKey: spData?.privateKey || config.privateKey,
1571
+ privateKey: normalizePem(spData?.privateKey || config.privateKey),
1461
1572
  privateKeyPass: spData?.privateKeyPass,
1462
1573
  isAssertionEncrypted: spData?.isAssertionEncrypted || false,
1463
- encPrivateKey: spData?.encPrivateKey,
1574
+ encPrivateKey: normalizePem(spData?.encPrivateKey),
1464
1575
  encPrivateKeyPass: spData?.encPrivateKeyPass,
1465
- nameIDFormat: config.identifierFormat ? [config.identifierFormat] : void 0,
1466
1576
  relayState: opts?.relayState
1467
1577
  });
1468
1578
  }
@@ -1470,10 +1580,10 @@ function createIdP(config) {
1470
1580
  const idpData = config.idpMetadata;
1471
1581
  if (idpData?.metadata) return saml.IdentityProvider({
1472
1582
  metadata: idpData.metadata,
1473
- privateKey: idpData.privateKey,
1583
+ privateKey: normalizePem(idpData.privateKey),
1474
1584
  privateKeyPass: idpData.privateKeyPass,
1475
1585
  isAssertionEncrypted: idpData.isAssertionEncrypted,
1476
- encPrivateKey: idpData.encPrivateKey,
1586
+ encPrivateKey: normalizePem(idpData.encPrivateKey),
1477
1587
  encPrivateKeyPass: idpData.encPrivateKeyPass
1478
1588
  });
1479
1589
  return saml.IdentityProvider({
@@ -1483,10 +1593,10 @@ function createIdP(config) {
1483
1593
  Location: config.entryPoint
1484
1594
  }],
1485
1595
  singleLogoutService: idpData?.singleLogoutService,
1486
- signingCert: idpData?.cert || config.cert,
1596
+ signingCert: normalizePem(idpData?.cert || config.cert),
1487
1597
  wantAuthnRequestsSigned: config.authnRequestsSigned || false,
1488
1598
  isAssertionEncrypted: idpData?.isAssertionEncrypted || false,
1489
- encPrivateKey: idpData?.encPrivateKey,
1599
+ encPrivateKey: normalizePem(idpData?.encPrivateKey),
1490
1600
  encPrivateKeyPass: idpData?.encPrivateKeyPass
1491
1601
  });
1492
1602
  }
@@ -1597,8 +1707,8 @@ function extractAssertionId(samlContent) {
1597
1707
  /**
1598
1708
  * Unified SAML response processing pipeline.
1599
1709
  *
1600
- * Both `/sso/saml2/callback/:providerId` (POST) and `/sso/saml2/sp/acs/:providerId`
1601
- * delegate to this function. It handles the full lifecycle: provider lookup,
1710
+ * The `/sso/saml2/sp/acs/:providerId` endpoint delegates to this function.
1711
+ * It handles the full lifecycle: provider lookup,
1602
1712
  * SP/IdP construction, response validation, session creation, and redirect
1603
1713
  * URL computation.
1604
1714
  */
@@ -1621,7 +1731,7 @@ async function processSAMLResponse(ctx, params, options) {
1621
1731
  if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
1622
1732
  const sp = createSP(parsedSamlConfig, ctx.context.baseURL, providerId);
1623
1733
  const idp = createIdP(parsedSamlConfig);
1624
- const samlRedirectUrl = getSafeRedirectUrl(relayState?.callbackURL || parsedSamlConfig.callbackUrl, params.currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
1734
+ const samlRedirectUrl = getSafeRedirectUrl(relayState?.callbackURL, params.currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
1625
1735
  validateSingleAssertion(SAMLResponse);
1626
1736
  let parsedResponse;
1627
1737
  try {
@@ -1647,40 +1757,21 @@ async function processSAMLResponse(ctx, params, options) {
1647
1757
  requireTimestamps: options?.saml?.requireTimestamps,
1648
1758
  logger: ctx.context.logger
1649
1759
  });
1650
- const inResponseTo = extract.inResponseTo;
1651
- if (options?.saml?.enableInResponseToValidation !== false) {
1652
- const allowIdpInitiated = options?.saml?.allowIdpInitiated !== false;
1653
- if (inResponseTo) {
1654
- let storedRequest = null;
1655
- const verification = await ctx.context.internalAdapter.findVerificationValue(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
1656
- if (verification) try {
1657
- storedRequest = JSON.parse(verification.value);
1658
- if (storedRequest && storedRequest.expiresAt < Date.now()) storedRequest = null;
1659
- } catch {
1660
- storedRequest = null;
1661
- }
1662
- if (!storedRequest) {
1663
- ctx.context.logger.error("SAML InResponseTo validation failed: unknown or expired request ID", {
1664
- inResponseTo,
1665
- providerId
1666
- });
1667
- throw ctx.redirect(`${samlRedirectUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`);
1668
- }
1669
- if (storedRequest.providerId !== providerId) {
1670
- ctx.context.logger.error("SAML InResponseTo validation failed: provider mismatch", {
1671
- inResponseTo,
1672
- expectedProvider: storedRequest.providerId,
1673
- actualProvider: providerId
1674
- });
1675
- await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
1676
- throw ctx.redirect(`${samlRedirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`);
1677
- }
1678
- await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
1679
- } else if (!allowIdpInitiated) {
1680
- ctx.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId });
1681
- throw ctx.redirect(`${samlRedirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`);
1682
- }
1683
- }
1760
+ await validateInResponseTo(ctx, {
1761
+ extract,
1762
+ providerId,
1763
+ options: {
1764
+ enableInResponseToValidation: options?.saml?.enableInResponseToValidation,
1765
+ allowIdpInitiated: options?.saml?.allowIdpInitiated
1766
+ },
1767
+ redirectUrl: samlRedirectUrl
1768
+ });
1769
+ validateAudience(ctx, {
1770
+ extract,
1771
+ expectedAudience: parsedSamlConfig.audience || sp.entityMeta.getEntityID(),
1772
+ providerId,
1773
+ redirectUrl: samlRedirectUrl
1774
+ });
1684
1775
  const samlContent = parsedResponse.samlContent;
1685
1776
  const assertionId = samlContent ? extractAssertionId(samlContent) : null;
1686
1777
  if (assertionId) {
@@ -1723,7 +1814,7 @@ async function processSAMLResponse(ctx, params, options) {
1723
1814
  const userInfo = {
1724
1815
  ...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, attributes[value]])),
1725
1816
  id: attributes[mapping.id || "nameID"] || extract.nameID,
1726
- email: (attributes[mapping.email || "email"] || extract.nameID).toLowerCase(),
1817
+ email: (attributes[mapping.email || "email"] || extract.nameID || "").toLowerCase(),
1727
1818
  name: [attributes[mapping.firstName || "givenName"], attributes[mapping.lastName || "surname"]].filter(Boolean).join(" ") || attributes[mapping.name || "displayName"] || extract.nameID,
1728
1819
  emailVerified: options?.trustEmailVerified && mapping.emailVerified ? attributes[mapping.emailVerified] || false : false
1729
1820
  };
@@ -1737,7 +1828,7 @@ async function processSAMLResponse(ctx, params, options) {
1737
1828
  throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
1738
1829
  }
1739
1830
  const isTrustedProvider = ctx.context.trustedProviders.includes(providerId) || "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
1740
- const callbackUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1831
+ const postAuthRedirect = relayState?.callbackURL || ctx.context.baseURL;
1741
1832
  const result = await handleOAuthUserInfo(ctx, {
1742
1833
  userInfo: {
1743
1834
  email: userInfo.email,
@@ -1751,11 +1842,11 @@ async function processSAMLResponse(ctx, params, options) {
1751
1842
  accessToken: "",
1752
1843
  refreshToken: ""
1753
1844
  },
1754
- callbackURL: callbackUrl,
1845
+ callbackURL: postAuthRedirect,
1755
1846
  disableSignUp: options?.disableImplicitSignUp,
1756
1847
  isTrustedProvider
1757
1848
  });
1758
- if (result.error) throw ctx.redirect(`${callbackUrl}?error=${result.error.split(" ").join("_")}`);
1849
+ if (result.error) throw ctx.redirect(`${samlRedirectUrl}?error=${result.error.split(" ").join("_")}`);
1759
1850
  const { session, user } = result.data;
1760
1851
  if (options?.provisionUser && (result.isRegister || options.provisionUserOnEveryLogin)) await options.provisionUser({
1761
1852
  user,
@@ -1785,7 +1876,7 @@ async function processSAMLResponse(ctx, params, options) {
1785
1876
  sessionId: session.id,
1786
1877
  providerId,
1787
1878
  nameID: extract.nameID,
1788
- sessionIndex: extract.sessionIndex
1879
+ sessionIndex: extract.sessionIndex?.sessionIndex
1789
1880
  };
1790
1881
  await ctx.context.internalAdapter.createVerificationValue({
1791
1882
  identifier: samlSessionKey,
@@ -1798,7 +1889,7 @@ async function processSAMLResponse(ctx, params, options) {
1798
1889
  expiresAt: session.expiresAt
1799
1890
  }).catch((e) => ctx.context.logger.warn("Failed to create SAML session lookup record", e));
1800
1891
  }
1801
- return getSafeRedirectUrl(relayState?.callbackURL || parsedSamlConfig.callbackUrl, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
1892
+ return getSafeRedirectUrl(relayState?.callbackURL, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
1802
1893
  }
1803
1894
  //#endregion
1804
1895
  //#region src/routes/sso.ts
@@ -1815,10 +1906,7 @@ function getOIDCRedirectURI(baseURL, providerId, options) {
1815
1906
  }
1816
1907
  return `${baseURL}/sso/callback/${providerId}`;
1817
1908
  }
1818
- const spMetadataQuerySchema = z.object({
1819
- providerId: z.string(),
1820
- format: z.enum(["xml", "json"]).default("xml")
1821
- });
1909
+ const spMetadataQuerySchema = z.object({ providerId: z.string() });
1822
1910
  const spMetadata = (options) => {
1823
1911
  return createAuthEndpoint("/sso/saml2/sp/metadata", {
1824
1912
  method: "GET",
@@ -1840,25 +1928,10 @@ const spMetadata = (options) => {
1840
1928
  if (!provider) throw new APIError("NOT_FOUND", { message: "No provider found for the given providerId" });
1841
1929
  const parsedSamlConfig = safeJsonParse(provider.samlConfig);
1842
1930
  if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
1843
- const sloLocation = `${ctx.context.baseURL}/sso/saml2/sp/slo/${ctx.query.providerId}`;
1844
- const singleLogoutService = options?.saml?.enableSingleLogout ? [{
1845
- Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
1846
- Location: sloLocation
1847
- }, {
1848
- Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
1849
- Location: sloLocation
1850
- }] : void 0;
1851
- const sp = parsedSamlConfig.spMetadata.metadata ? saml.ServiceProvider({ metadata: parsedSamlConfig.spMetadata.metadata }) : saml.SPMetadata({
1852
- entityID: parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
1853
- assertionConsumerService: [{
1854
- Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
1855
- Location: parsedSamlConfig.callbackUrl || `${ctx.context.baseURL}/sso/saml2/sp/acs/${ctx.query.providerId}`
1856
- }],
1857
- singleLogoutService,
1858
- wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
1859
- authnRequestsSigned: parsedSamlConfig.authnRequestsSigned || false,
1860
- nameIDFormat: parsedSamlConfig.identifierFormat ? [parsedSamlConfig.identifierFormat] : void 0
1861
- });
1931
+ const sp = createSP(parsedSamlConfig, ctx.context.baseURL, ctx.query.providerId, options?.saml?.enableSingleLogout ? { sloOptions: {
1932
+ wantLogoutRequestSigned: options?.saml?.wantLogoutRequestSigned,
1933
+ wantLogoutResponseSigned: options?.saml?.wantLogoutResponseSigned
1934
+ } } : void 0);
1862
1935
  return new Response(sp.getMetadata(), { headers: { "Content-Type": "application/xml" } });
1863
1936
  });
1864
1937
  };
@@ -1868,11 +1941,17 @@ const ssoProviderBodySchema = z.object({
1868
1941
  domain: z.string({}).meta({ description: "The domain(s) of the provider. For enterprise multi-domain SSO where a single IdP serves multiple email domains, use comma-separated values (e.g., 'company.com,subsidiary.com,acquired-company.com')" }),
1869
1942
  oidcConfig: z.object({
1870
1943
  clientId: z.string({}).meta({ description: "The client ID" }),
1871
- clientSecret: z.string({}).meta({ description: "The client secret" }),
1944
+ clientSecret: z.string({}).optional().meta({ description: "The client secret. Required for client_secret_basic/client_secret_post. Optional for private_key_jwt." }),
1872
1945
  authorizationEndpoint: z.string({}).meta({ description: "The authorization endpoint" }).optional(),
1873
1946
  tokenEndpoint: z.string({}).meta({ description: "The token endpoint" }).optional(),
1874
1947
  userInfoEndpoint: z.string({}).meta({ description: "The user info endpoint" }).optional(),
1875
- tokenEndpointAuthentication: z.enum(["client_secret_post", "client_secret_basic"]).optional(),
1948
+ tokenEndpointAuthentication: z.enum([
1949
+ "client_secret_post",
1950
+ "client_secret_basic",
1951
+ "private_key_jwt"
1952
+ ]).optional(),
1953
+ privateKeyId: z.string().optional(),
1954
+ privateKeyAlgorithm: z.string().optional(),
1876
1955
  jwksEndpoint: z.string({}).meta({ description: "The JWKS endpoint" }).optional(),
1877
1956
  discoveryEndpoint: z.string().optional(),
1878
1957
  skipDiscovery: z.boolean().meta({ description: "Skip OIDC discovery during registration. When true, you must provide authorizationEndpoint, tokenEndpoint, and jwksEndpoint manually." }).optional(),
@@ -1890,7 +1969,6 @@ const ssoProviderBodySchema = z.object({
1890
1969
  samlConfig: z.object({
1891
1970
  entryPoint: z.string({}).meta({ description: "The entry point of the provider" }),
1892
1971
  cert: z.string({}).meta({ description: "The certificate of the provider" }),
1893
- callbackUrl: z.string({}).meta({ description: "The callback URL of the provider" }),
1894
1972
  audience: z.string().optional(),
1895
1973
  idpMetadata: z.object({
1896
1974
  metadata: z.string().optional(),
@@ -1915,15 +1993,13 @@ const ssoProviderBodySchema = z.object({
1915
1993
  isAssertionEncrypted: z.boolean().optional(),
1916
1994
  encPrivateKey: z.string().optional(),
1917
1995
  encPrivateKeyPass: z.string().optional()
1918
- }),
1996
+ }).optional(),
1919
1997
  wantAssertionsSigned: z.boolean().optional(),
1920
1998
  authnRequestsSigned: z.boolean().optional(),
1921
1999
  signatureAlgorithm: z.string().optional(),
1922
2000
  digestAlgorithm: z.string().optional(),
1923
2001
  identifierFormat: z.string().optional(),
1924
2002
  privateKey: z.string().optional(),
1925
- decryptionPvk: z.string().optional(),
1926
- additionalParams: z.record(z.string(), z.any()).optional(),
1927
2003
  mapping: z.object({
1928
2004
  id: z.string({}).meta({ description: "Field mapping for user ID (defaults to 'nameID')" }),
1929
2005
  email: z.string({}).meta({ description: "Field mapping for email (defaults to 'email')" }),
@@ -2175,6 +2251,8 @@ const registerSSOProvider = (options) => {
2175
2251
  authorizationEndpoint: body.oidcConfig.authorizationEndpoint,
2176
2252
  tokenEndpoint: body.oidcConfig.tokenEndpoint,
2177
2253
  tokenEndpointAuthentication: body.oidcConfig.tokenEndpointAuthentication || "client_secret_basic",
2254
+ privateKeyId: body.oidcConfig.privateKeyId,
2255
+ privateKeyAlgorithm: body.oidcConfig.privateKeyAlgorithm,
2178
2256
  jwksEndpoint: body.oidcConfig.jwksEndpoint,
2179
2257
  pkce: body.oidcConfig.pkce,
2180
2258
  discoveryEndpoint: body.oidcConfig.discoveryEndpoint || `${body.issuer}/.well-known/openid-configuration`,
@@ -2191,6 +2269,8 @@ const registerSSOProvider = (options) => {
2191
2269
  authorizationEndpoint: hydratedOIDCConfig.authorizationEndpoint,
2192
2270
  tokenEndpoint: hydratedOIDCConfig.tokenEndpoint,
2193
2271
  tokenEndpointAuthentication: hydratedOIDCConfig.tokenEndpointAuthentication,
2272
+ privateKeyId: body.oidcConfig.privateKeyId,
2273
+ privateKeyAlgorithm: body.oidcConfig.privateKeyAlgorithm,
2194
2274
  jwksEndpoint: hydratedOIDCConfig.jwksEndpoint,
2195
2275
  pkce: body.oidcConfig.pkce,
2196
2276
  discoveryEndpoint: hydratedOIDCConfig.discoveryEndpoint,
@@ -2220,12 +2300,19 @@ const registerSSOProvider = (options) => {
2220
2300
  issuer: body.issuer,
2221
2301
  domain: body.domain,
2222
2302
  domainVerified: false,
2223
- oidcConfig: buildOIDCConfig(),
2303
+ oidcConfig: (() => {
2304
+ const config = buildOIDCConfig();
2305
+ if (config) {
2306
+ const parsed = JSON.parse(config);
2307
+ if (parsed.tokenEndpointAuthentication !== "private_key_jwt" && !parsed.clientSecret) throw new APIError("BAD_REQUEST", { message: "clientSecret is required when using client_secret_basic or client_secret_post authentication" });
2308
+ if (parsed.tokenEndpointAuthentication === "private_key_jwt" && !options?.resolvePrivateKey && !options?.defaultSSO?.some((p) => p.providerId === body.providerId && "privateKey" in p && p.privateKey)) throw new APIError("BAD_REQUEST", { message: "private_key_jwt authentication requires either a resolvePrivateKey callback or a privateKey in defaultSSO" });
2309
+ }
2310
+ return config;
2311
+ })(),
2224
2312
  samlConfig: body.samlConfig ? JSON.stringify({
2225
2313
  issuer: body.issuer,
2226
2314
  entryPoint: body.samlConfig.entryPoint,
2227
2315
  cert: body.samlConfig.cert,
2228
- callbackUrl: body.samlConfig.callbackUrl,
2229
2316
  audience: body.samlConfig.audience,
2230
2317
  idpMetadata: body.samlConfig.idpMetadata,
2231
2318
  spMetadata: body.samlConfig.spMetadata,
@@ -2235,8 +2322,6 @@ const registerSSOProvider = (options) => {
2235
2322
  digestAlgorithm: body.samlConfig.digestAlgorithm,
2236
2323
  identifierFormat: body.samlConfig.identifierFormat,
2237
2324
  privateKey: body.samlConfig.privateKey,
2238
- decryptionPvk: body.samlConfig.decryptionPvk,
2239
- additionalParams: body.samlConfig.additionalParams,
2240
2325
  mapping: body.samlConfig.mapping
2241
2326
  }) : null,
2242
2327
  organizationId: body.organizationId,
@@ -2443,46 +2528,8 @@ const signInSSO = (options) => {
2443
2528
  if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
2444
2529
  if (parsedSamlConfig.authnRequestsSigned && !parsedSamlConfig.spMetadata?.privateKey && !parsedSamlConfig.privateKey) throw new APIError("BAD_REQUEST", { message: "authnRequestsSigned is enabled but no privateKey provided in spMetadata or samlConfig" });
2445
2530
  const { state: relayState } = await generateRelayState(ctx, void 0, false);
2446
- let metadata = parsedSamlConfig.spMetadata.metadata;
2447
- if (!metadata) metadata = saml.SPMetadata({
2448
- entityID: parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
2449
- assertionConsumerService: [{
2450
- Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
2451
- Location: parsedSamlConfig.callbackUrl || `${ctx.context.baseURL}/sso/saml2/sp/acs/${provider.providerId}`
2452
- }],
2453
- wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
2454
- authnRequestsSigned: parsedSamlConfig.authnRequestsSigned || false,
2455
- nameIDFormat: parsedSamlConfig.identifierFormat ? [parsedSamlConfig.identifierFormat] : void 0
2456
- }).getMetadata() || "";
2457
- const sp = saml.ServiceProvider({
2458
- metadata,
2459
- allowCreate: true,
2460
- privateKey: parsedSamlConfig.spMetadata?.privateKey || parsedSamlConfig.privateKey,
2461
- privateKeyPass: parsedSamlConfig.spMetadata?.privateKeyPass,
2462
- relayState
2463
- });
2464
- const idpData = parsedSamlConfig.idpMetadata;
2465
- let idp;
2466
- if (!idpData?.metadata) idp = saml.IdentityProvider({
2467
- entityID: idpData?.entityID || parsedSamlConfig.issuer,
2468
- singleSignOnService: idpData?.singleSignOnService || [{
2469
- Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
2470
- Location: parsedSamlConfig.entryPoint
2471
- }],
2472
- signingCert: idpData?.cert || parsedSamlConfig.cert,
2473
- wantAuthnRequestsSigned: parsedSamlConfig.authnRequestsSigned || false,
2474
- isAssertionEncrypted: idpData?.isAssertionEncrypted || false,
2475
- encPrivateKey: idpData?.encPrivateKey,
2476
- encPrivateKeyPass: idpData?.encPrivateKeyPass
2477
- });
2478
- else idp = saml.IdentityProvider({
2479
- metadata: idpData.metadata,
2480
- privateKey: idpData.privateKey,
2481
- privateKeyPass: idpData.privateKeyPass,
2482
- isAssertionEncrypted: idpData.isAssertionEncrypted,
2483
- encPrivateKey: idpData.encPrivateKey,
2484
- encPrivateKeyPass: idpData.encPrivateKeyPass
2485
- });
2531
+ const sp = createSP(parsedSamlConfig, ctx.context.baseURL, provider.providerId, { relayState });
2532
+ const idp = createIdP(parsedSamlConfig);
2486
2533
  const loginRequest = sp.createLoginRequest(idp, "redirect");
2487
2534
  if (!loginRequest) throw new APIError("BAD_REQUEST", { message: "Invalid SAML request" });
2488
2535
  if (loginRequest.id && options?.saml?.enableInResponseToValidation !== false) {
@@ -2573,6 +2620,30 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
2573
2620
  ]
2574
2621
  };
2575
2622
  if (!config.tokenEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_endpoint_not_found`);
2623
+ let authMethod = "basic";
2624
+ if (config.tokenEndpointAuthentication === "client_secret_post") authMethod = "post";
2625
+ else if (config.tokenEndpointAuthentication === "private_key_jwt") authMethod = "private_key_jwt";
2626
+ let clientAssertionConfig;
2627
+ if (authMethod === "private_key_jwt") {
2628
+ let resolved;
2629
+ const matchingDefault = options?.defaultSSO?.find((p) => p.providerId === provider.providerId && "privateKey" in p && p.privateKey);
2630
+ if (matchingDefault && "privateKey" in matchingDefault) resolved = matchingDefault.privateKey;
2631
+ if (!resolved && options?.resolvePrivateKey) resolved = await options.resolvePrivateKey({
2632
+ providerId: provider.providerId,
2633
+ keyId: config.privateKeyId,
2634
+ issuer: config.issuer
2635
+ });
2636
+ if (!resolved) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=no_private_key_available`);
2637
+ const rawAlg = config.privateKeyAlgorithm ?? resolved.algorithm;
2638
+ const algorithm = rawAlg && ASSERTION_SIGNING_ALGORITHMS.includes(rawAlg) ? rawAlg : void 0;
2639
+ clientAssertionConfig = {
2640
+ privateKeyJwk: resolved.privateKeyJwk,
2641
+ privateKeyPem: resolved.privateKeyPem,
2642
+ kid: config.privateKeyId ?? resolved.kid,
2643
+ algorithm,
2644
+ tokenEndpoint: config.tokenEndpoint
2645
+ };
2646
+ }
2576
2647
  const tokenResponse = await validateAuthorizationCode({
2577
2648
  code,
2578
2649
  codeVerifier: config.pkce ? stateData.codeVerifier : void 0,
@@ -2582,7 +2653,8 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
2582
2653
  clientSecret: config.clientSecret
2583
2654
  },
2584
2655
  tokenEndpoint: config.tokenEndpoint,
2585
- authentication: config.tokenEndpointAuthentication === "client_secret_post" ? "post" : "basic"
2656
+ authentication: authMethod,
2657
+ clientAssertion: clientAssertionConfig
2586
2658
  }).catch((e) => {
2587
2659
  ctx.context.logger.error("Error validating authorization code", e);
2588
2660
  if (e instanceof BetterFetchError) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=${e.message}`);
@@ -2732,72 +2804,42 @@ const callbackSSOShared = (options) => {
2732
2804
  return handleOIDCCallback(ctx, options, providerId, stateData);
2733
2805
  });
2734
2806
  };
2735
- const callbackSSOSAMLBodySchema = z.object({
2807
+ const acsEndpointBodySchema = z.object({
2736
2808
  SAMLResponse: z.string(),
2737
2809
  RelayState: z.string().optional()
2738
2810
  });
2739
- const callbackSSOSAML = (options) => {
2740
- return createAuthEndpoint("/sso/saml2/callback/:providerId", {
2811
+ const acsEndpoint = (options) => {
2812
+ return createAuthEndpoint("/sso/saml2/sp/acs/:providerId", {
2741
2813
  method: ["GET", "POST"],
2742
- body: callbackSSOSAMLBodySchema.optional(),
2814
+ body: acsEndpointBodySchema.optional(),
2743
2815
  query: z.object({ RelayState: z.string().optional() }).optional(),
2744
2816
  metadata: {
2745
2817
  ...HIDE_METADATA,
2746
2818
  allowedMediaTypes: ["application/x-www-form-urlencoded", "application/json"],
2747
2819
  openapi: {
2748
- operationId: "handleSAMLCallback",
2749
- summary: "Callback URL for SAML provider",
2750
- description: "This endpoint is used as the callback URL for SAML providers. Supports both GET and POST methods for IdP-initiated and SP-initiated flows.",
2820
+ operationId: "handleSAMLAssertionConsumerService",
2821
+ summary: "SAML Assertion Consumer Service",
2822
+ description: "Handles SAML responses from IdP after successful authentication. Supports GET for post-auth redirects and POST for SAML response processing.",
2751
2823
  responses: {
2752
- "302": { description: "Redirects to the callback URL" },
2753
- "400": { description: "Invalid SAML response" },
2754
- "401": { description: "Unauthorized - SAML authentication failed" }
2824
+ "302": { description: "Redirects after authentication (success or error with query params)" },
2825
+ "400": { description: "Missing SAMLResponse in POST body" },
2826
+ "404": { description: "SAML provider not found" }
2755
2827
  }
2756
2828
  }
2757
2829
  }
2758
2830
  }, async (ctx) => {
2759
2831
  const { providerId } = ctx.params;
2832
+ const currentCallbackPath = `${ctx.context.baseURL}/sso/saml2/sp/acs/${providerId}`;
2760
2833
  const appOrigin = new URL(ctx.context.baseURL).origin;
2761
- const errorURL = ctx.context.options.onAPIError?.errorURL || `${appOrigin}/error`;
2762
- const currentCallbackPath = `${ctx.context.baseURL}/sso/saml2/callback/${providerId}`;
2763
2834
  if (ctx.method === "GET" && !ctx.body?.SAMLResponse) {
2764
- if (!(await getSessionFromCtx(ctx))?.session) throw ctx.redirect(`${errorURL}?error=invalid_request`);
2835
+ if (!(await getSessionFromCtx(ctx))?.session) {
2836
+ const errorURL = ctx.context.options.onAPIError?.errorURL || `${appOrigin}/error`;
2837
+ throw ctx.redirect(`${errorURL}?error=invalid_request`);
2838
+ }
2765
2839
  const relayState = ctx.query?.RelayState;
2766
- const safeRedirectUrl = getSafeRedirectUrl(relayState, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
2767
- throw ctx.redirect(safeRedirectUrl);
2840
+ throw ctx.redirect(getSafeRedirectUrl(relayState, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings)));
2768
2841
  }
2769
2842
  if (!ctx.body?.SAMLResponse) throw new APIError("BAD_REQUEST", { message: "SAMLResponse is required for POST requests" });
2770
- const safeRedirectUrl = await processSAMLResponse(ctx, {
2771
- SAMLResponse: ctx.body.SAMLResponse,
2772
- RelayState: ctx.body.RelayState,
2773
- providerId,
2774
- currentCallbackPath
2775
- }, options);
2776
- throw ctx.redirect(safeRedirectUrl);
2777
- });
2778
- };
2779
- const acsEndpointBodySchema = z.object({
2780
- SAMLResponse: z.string(),
2781
- RelayState: z.string().optional()
2782
- });
2783
- const acsEndpoint = (options) => {
2784
- return createAuthEndpoint("/sso/saml2/sp/acs/:providerId", {
2785
- method: "POST",
2786
- body: acsEndpointBodySchema,
2787
- metadata: {
2788
- ...HIDE_METADATA,
2789
- allowedMediaTypes: ["application/x-www-form-urlencoded", "application/json"],
2790
- openapi: {
2791
- operationId: "handleSAMLAssertionConsumerService",
2792
- summary: "SAML Assertion Consumer Service",
2793
- description: "Handles SAML responses from IdP after successful authentication",
2794
- responses: { "302": { description: "Redirects to the callback URL after successful authentication" } }
2795
- }
2796
- }
2797
- }, async (ctx) => {
2798
- const { providerId } = ctx.params;
2799
- const currentCallbackPath = `${ctx.context.baseURL}/sso/saml2/sp/acs/${providerId}`;
2800
- const appOrigin = new URL(ctx.context.baseURL).origin;
2801
2843
  try {
2802
2844
  const safeRedirectUrl = await processSAMLResponse(ctx, {
2803
2845
  SAMLResponse: ctx.body.SAMLResponse,
@@ -2809,9 +2851,8 @@ const acsEndpoint = (options) => {
2809
2851
  } catch (error) {
2810
2852
  if (error instanceof Response || error && typeof error === "object" && "status" in error && error.status === 302) throw error;
2811
2853
  if (error instanceof APIError && error.statusCode === 400) {
2812
- const internalCode = error.body?.code || "";
2813
- const errorCode = internalCode === "SAML_MULTIPLE_ASSERTIONS" ? "multiple_assertions" : internalCode === "SAML_NO_ASSERTION" ? "no_assertion" : internalCode.toLowerCase() || "saml_error";
2814
- const redirectUrl = getSafeRedirectUrl(ctx.body.RelayState || void 0, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
2854
+ const errorCode = (error.body?.code || "saml_error").toLowerCase();
2855
+ const redirectUrl = getSafeRedirectUrl(ctx.body?.RelayState || void 0, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
2815
2856
  throw ctx.redirect(`${redirectUrl}${redirectUrl.includes("?") ? "&" : "?"}error=${encodeURIComponent(errorCode)}&error_description=${encodeURIComponent(error.message)}`);
2816
2857
  }
2817
2858
  throw error;
@@ -2986,11 +3027,7 @@ saml.setSchemaValidator({ async validate(xml) {
2986
3027
  * These endpoints receive POST requests from external Identity Providers,
2987
3028
  * which won't have a matching Origin header.
2988
3029
  */
2989
- const SAML_SKIP_ORIGIN_CHECK_PATHS = [
2990
- "/sso/saml2/callback",
2991
- "/sso/saml2/sp/acs",
2992
- "/sso/saml2/sp/slo"
2993
- ];
3030
+ const SAML_SKIP_ORIGIN_CHECK_PATHS = ["/sso/saml2/sp/acs", "/sso/saml2/sp/slo"];
2994
3031
  function sso(options) {
2995
3032
  const optionsWithStore = options;
2996
3033
  let endpoints = {
@@ -2999,7 +3036,6 @@ function sso(options) {
2999
3036
  signInSSO: signInSSO(optionsWithStore),
3000
3037
  callbackSSO: callbackSSO(optionsWithStore),
3001
3038
  callbackSSOShared: callbackSSOShared(optionsWithStore),
3002
- callbackSSOSAML: callbackSSOSAML(optionsWithStore),
3003
3039
  acsEndpoint: acsEndpoint(optionsWithStore),
3004
3040
  sloEndpoint: sloEndpoint(optionsWithStore),
3005
3041
  initiateSLO: initiateSLO(optionsWithStore),
@@ -1,5 +1,5 @@
1
1
  //#endregion
2
2
  //#region src/version.ts
3
- const PACKAGE_VERSION = "1.6.3";
3
+ const PACKAGE_VERSION = "1.7.0-beta.1";
4
4
  //#endregion
5
5
  export { PACKAGE_VERSION as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@better-auth/sso",
3
- "version": "1.6.3",
3
+ "version": "1.7.0-beta.1",
4
4
  "description": "SSO plugin for Better Auth",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -58,7 +58,7 @@
58
58
  "dependencies": {
59
59
  "fast-xml-parser": "^5.5.7",
60
60
  "jose": "^6.1.3",
61
- "samlify": "~2.10.2",
61
+ "samlify": "~2.12.0",
62
62
  "tldts": "^6.1.0",
63
63
  "zod": "^4.3.6"
64
64
  },
@@ -70,15 +70,15 @@
70
70
  "express": "^5.2.1",
71
71
  "oauth2-mock-server": "^8.2.2",
72
72
  "tsdown": "0.21.1",
73
- "@better-auth/core": "1.6.3",
74
- "better-auth": "1.6.3"
73
+ "@better-auth/core": "1.7.0-beta.1",
74
+ "better-auth": "1.7.0-beta.1"
75
75
  },
76
76
  "peerDependencies": {
77
77
  "@better-auth/utils": "0.4.0",
78
78
  "@better-fetch/fetch": "1.1.21",
79
79
  "better-call": "1.3.5",
80
- "@better-auth/core": "^1.6.3",
81
- "better-auth": "^1.6.3"
80
+ "@better-auth/core": "^1.7.0-beta.1",
81
+ "better-auth": "^1.7.0-beta.1"
82
82
  },
83
83
  "scripts": {
84
84
  "build": "tsdown",