@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
package/dist/client.mjs
CHANGED
|
@@ -64,28 +64,46 @@ interface OIDCConfig {
|
|
|
64
64
|
issuer: string;
|
|
65
65
|
pkce: boolean;
|
|
66
66
|
clientId: string;
|
|
67
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
"
|
|
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,
|
|
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
|
|
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-
|
|
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([
|
|
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 =
|
|
1443
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
*
|
|
1601
|
-
*
|
|
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
|
|
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
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
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
|
|
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:
|
|
1845
|
+
callbackURL: postAuthRedirect,
|
|
1755
1846
|
disableSignUp: options?.disableImplicitSignUp,
|
|
1756
1847
|
isTrustedProvider
|
|
1757
1848
|
});
|
|
1758
|
-
if (result.error) throw ctx.redirect(`${
|
|
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
|
|
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
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
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([
|
|
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:
|
|
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
|
-
|
|
2447
|
-
|
|
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:
|
|
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
|
|
2807
|
+
const acsEndpointBodySchema = z.object({
|
|
2736
2808
|
SAMLResponse: z.string(),
|
|
2737
2809
|
RelayState: z.string().optional()
|
|
2738
2810
|
});
|
|
2739
|
-
const
|
|
2740
|
-
return createAuthEndpoint("/sso/saml2/
|
|
2811
|
+
const acsEndpoint = (options) => {
|
|
2812
|
+
return createAuthEndpoint("/sso/saml2/sp/acs/:providerId", {
|
|
2741
2813
|
method: ["GET", "POST"],
|
|
2742
|
-
body:
|
|
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: "
|
|
2749
|
-
summary: "
|
|
2750
|
-
description: "
|
|
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
|
|
2753
|
-
"400": { description: "
|
|
2754
|
-
"
|
|
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)
|
|
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
|
-
|
|
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
|
|
2813
|
-
const
|
|
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),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@better-auth/sso",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
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.
|
|
74
|
-
"better-auth": "1.
|
|
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.
|
|
81
|
-
"better-auth": "^1.
|
|
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",
|