@better-auth/sso 1.3.13 → 1.3.14
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/.turbo/turbo-build.log +4 -4
- package/dist/index.cjs +528 -90
- package/dist/index.d.cts +186 -39
- package/dist/index.d.mts +186 -39
- package/dist/index.d.ts +186 -39
- package/dist/index.mjs +528 -90
- package/package.json +3 -3
- package/src/index.ts +767 -137
- package/src/oidc.test.ts +84 -21
- package/src/saml.test.ts +92 -0
- package/CHANGELOG.md +0 -20
package/dist/index.mjs
CHANGED
|
@@ -109,7 +109,25 @@ const sso = (options) => {
|
|
|
109
109
|
}).optional(),
|
|
110
110
|
pkce: z.boolean({}).meta({
|
|
111
111
|
description: "Whether to use PKCE for the authorization flow"
|
|
112
|
-
}).default(true).optional()
|
|
112
|
+
}).default(true).optional(),
|
|
113
|
+
mapping: z.object({
|
|
114
|
+
id: z.string({}).meta({
|
|
115
|
+
description: "Field mapping for user ID (defaults to 'sub')"
|
|
116
|
+
}),
|
|
117
|
+
email: z.string({}).meta({
|
|
118
|
+
description: "Field mapping for email (defaults to 'email')"
|
|
119
|
+
}),
|
|
120
|
+
emailVerified: z.string({}).meta({
|
|
121
|
+
description: "Field mapping for email verification (defaults to 'email_verified')"
|
|
122
|
+
}).optional(),
|
|
123
|
+
name: z.string({}).meta({
|
|
124
|
+
description: "Field mapping for name (defaults to 'name')"
|
|
125
|
+
}),
|
|
126
|
+
image: z.string({}).meta({
|
|
127
|
+
description: "Field mapping for image (defaults to 'picture')"
|
|
128
|
+
}).optional(),
|
|
129
|
+
extraFields: z.record(z.string(), z.any()).optional()
|
|
130
|
+
}).optional()
|
|
113
131
|
}).optional(),
|
|
114
132
|
samlConfig: z.object({
|
|
115
133
|
entryPoint: z.string({}).meta({
|
|
@@ -123,15 +141,30 @@ const sso = (options) => {
|
|
|
123
141
|
}),
|
|
124
142
|
audience: z.string().optional(),
|
|
125
143
|
idpMetadata: z.object({
|
|
126
|
-
metadata: z.string(),
|
|
144
|
+
metadata: z.string().optional(),
|
|
145
|
+
entityID: z.string().optional(),
|
|
146
|
+
cert: z.string().optional(),
|
|
127
147
|
privateKey: z.string().optional(),
|
|
128
148
|
privateKeyPass: z.string().optional(),
|
|
129
149
|
isAssertionEncrypted: z.boolean().optional(),
|
|
130
150
|
encPrivateKey: z.string().optional(),
|
|
131
|
-
encPrivateKeyPass: z.string().optional()
|
|
151
|
+
encPrivateKeyPass: z.string().optional(),
|
|
152
|
+
singleSignOnService: z.array(
|
|
153
|
+
z.object({
|
|
154
|
+
Binding: z.string().meta({
|
|
155
|
+
description: "The binding type for the SSO service"
|
|
156
|
+
}),
|
|
157
|
+
Location: z.string().meta({
|
|
158
|
+
description: "The URL for the SSO service"
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
).optional().meta({
|
|
162
|
+
description: "Single Sign-On service configuration"
|
|
163
|
+
})
|
|
132
164
|
}).optional(),
|
|
133
165
|
spMetadata: z.object({
|
|
134
|
-
metadata: z.string(),
|
|
166
|
+
metadata: z.string().optional(),
|
|
167
|
+
entityID: z.string().optional(),
|
|
135
168
|
binding: z.string().optional(),
|
|
136
169
|
privateKey: z.string().optional(),
|
|
137
170
|
privateKeyPass: z.string().optional(),
|
|
@@ -145,25 +178,28 @@ const sso = (options) => {
|
|
|
145
178
|
identifierFormat: z.string().optional(),
|
|
146
179
|
privateKey: z.string().optional(),
|
|
147
180
|
decryptionPvk: z.string().optional(),
|
|
148
|
-
additionalParams: z.record(z.string(), z.any()).optional()
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
181
|
+
additionalParams: z.record(z.string(), z.any()).optional(),
|
|
182
|
+
mapping: z.object({
|
|
183
|
+
id: z.string({}).meta({
|
|
184
|
+
description: "Field mapping for user ID (defaults to 'nameID')"
|
|
185
|
+
}),
|
|
186
|
+
email: z.string({}).meta({
|
|
187
|
+
description: "Field mapping for email (defaults to 'email')"
|
|
188
|
+
}),
|
|
189
|
+
emailVerified: z.string({}).meta({
|
|
190
|
+
description: "Field mapping for email verification"
|
|
191
|
+
}).optional(),
|
|
192
|
+
name: z.string({}).meta({
|
|
193
|
+
description: "Field mapping for name (defaults to 'displayName')"
|
|
194
|
+
}),
|
|
195
|
+
firstName: z.string({}).meta({
|
|
196
|
+
description: "Field mapping for first name (defaults to 'givenName')"
|
|
197
|
+
}).optional(),
|
|
198
|
+
lastName: z.string({}).meta({
|
|
199
|
+
description: "Field mapping for last name (defaults to 'surname')"
|
|
200
|
+
}).optional(),
|
|
201
|
+
extraFields: z.record(z.string(), z.any()).optional()
|
|
202
|
+
}).optional()
|
|
167
203
|
}).optional(),
|
|
168
204
|
organizationId: z.string({}).meta({
|
|
169
205
|
description: "If organization plugin is enabled, the organization id to link the provider to"
|
|
@@ -400,7 +436,7 @@ const sso = (options) => {
|
|
|
400
436
|
jwksEndpoint: body.oidcConfig.jwksEndpoint,
|
|
401
437
|
pkce: body.oidcConfig.pkce,
|
|
402
438
|
discoveryEndpoint: body.oidcConfig.discoveryEndpoint || `${body.issuer}/.well-known/openid-configuration`,
|
|
403
|
-
mapping: body.mapping,
|
|
439
|
+
mapping: body.oidcConfig.mapping,
|
|
404
440
|
scopes: body.oidcConfig.scopes,
|
|
405
441
|
userInfoEndpoint: body.oidcConfig.userInfoEndpoint,
|
|
406
442
|
overrideUserInfo: ctx.body.overrideUserInfo || options?.defaultOverrideUserInfo || false
|
|
@@ -420,7 +456,7 @@ const sso = (options) => {
|
|
|
420
456
|
privateKey: body.samlConfig.privateKey,
|
|
421
457
|
decryptionPvk: body.samlConfig.decryptionPvk,
|
|
422
458
|
additionalParams: body.samlConfig.additionalParams,
|
|
423
|
-
mapping: body.mapping
|
|
459
|
+
mapping: body.samlConfig.mapping
|
|
424
460
|
}) : null,
|
|
425
461
|
organizationId: body.organizationId,
|
|
426
462
|
userId: ctx.context.session.user.id,
|
|
@@ -544,7 +580,7 @@ const sso = (options) => {
|
|
|
544
580
|
async (ctx) => {
|
|
545
581
|
const body = ctx.body;
|
|
546
582
|
let { email, organizationSlug, providerId, domain } = body;
|
|
547
|
-
if (!email && !organizationSlug && !domain && !providerId) {
|
|
583
|
+
if (!options?.defaultSSO?.length && !email && !organizationSlug && !domain && !providerId) {
|
|
548
584
|
throw new APIError("BAD_REQUEST", {
|
|
549
585
|
message: "email, organizationSlug, domain or providerId is required"
|
|
550
586
|
});
|
|
@@ -567,23 +603,48 @@ const sso = (options) => {
|
|
|
567
603
|
return res.id;
|
|
568
604
|
});
|
|
569
605
|
}
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
606
|
+
let provider = null;
|
|
607
|
+
if (options?.defaultSSO?.length) {
|
|
608
|
+
const matchingDefault = providerId ? options.defaultSSO.find(
|
|
609
|
+
(defaultProvider) => defaultProvider.providerId === providerId
|
|
610
|
+
) : options.defaultSSO.find(
|
|
611
|
+
(defaultProvider) => defaultProvider.domain === domain
|
|
612
|
+
);
|
|
613
|
+
if (matchingDefault) {
|
|
614
|
+
provider = {
|
|
615
|
+
issuer: matchingDefault.samlConfig?.issuer || matchingDefault.oidcConfig?.issuer || "",
|
|
616
|
+
providerId: matchingDefault.providerId,
|
|
617
|
+
userId: "default",
|
|
618
|
+
oidcConfig: matchingDefault.oidcConfig,
|
|
619
|
+
samlConfig: matchingDefault.samlConfig
|
|
620
|
+
};
|
|
581
621
|
}
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
622
|
+
}
|
|
623
|
+
if (!providerId && !orgId && !domain) {
|
|
624
|
+
throw new APIError("BAD_REQUEST", {
|
|
625
|
+
message: "providerId, orgId or domain is required"
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
if (!provider) {
|
|
629
|
+
provider = await ctx.context.adapter.findOne({
|
|
630
|
+
model: "ssoProvider",
|
|
631
|
+
where: [
|
|
632
|
+
{
|
|
633
|
+
field: providerId ? "providerId" : orgId ? "organizationId" : "domain",
|
|
634
|
+
value: providerId || orgId || domain
|
|
635
|
+
}
|
|
636
|
+
]
|
|
637
|
+
}).then((res) => {
|
|
638
|
+
if (!res) {
|
|
639
|
+
return null;
|
|
640
|
+
}
|
|
641
|
+
return {
|
|
642
|
+
...res,
|
|
643
|
+
oidcConfig: res.oidcConfig ? JSON.parse(res.oidcConfig) : void 0,
|
|
644
|
+
samlConfig: res.samlConfig ? JSON.parse(res.samlConfig) : void 0
|
|
645
|
+
};
|
|
646
|
+
});
|
|
647
|
+
}
|
|
587
648
|
if (!provider) {
|
|
588
649
|
throw new APIError("NOT_FOUND", {
|
|
589
650
|
message: "No provider found for the issuer"
|
|
@@ -627,15 +688,16 @@ const sso = (options) => {
|
|
|
627
688
|
});
|
|
628
689
|
}
|
|
629
690
|
if (provider.samlConfig) {
|
|
630
|
-
const parsedSamlConfig = JSON.parse(
|
|
631
|
-
provider.samlConfig
|
|
632
|
-
);
|
|
691
|
+
const parsedSamlConfig = typeof provider.samlConfig === "object" ? provider.samlConfig : JSON.parse(provider.samlConfig);
|
|
633
692
|
const sp = saml.ServiceProvider({
|
|
634
693
|
metadata: parsedSamlConfig.spMetadata.metadata,
|
|
635
694
|
allowCreate: true
|
|
636
695
|
});
|
|
637
696
|
const idp = saml.IdentityProvider({
|
|
638
|
-
metadata: parsedSamlConfig.idpMetadata.metadata
|
|
697
|
+
metadata: parsedSamlConfig.idpMetadata.metadata,
|
|
698
|
+
entityID: parsedSamlConfig.idpMetadata.entityID,
|
|
699
|
+
encryptCert: parsedSamlConfig.idpMetadata.cert,
|
|
700
|
+
singleSignOnService: parsedSamlConfig.idpMetadata.singleSignOnService
|
|
639
701
|
});
|
|
640
702
|
const loginRequest = sp.createLoginRequest(
|
|
641
703
|
idp,
|
|
@@ -694,23 +756,38 @@ const sso = (options) => {
|
|
|
694
756
|
`${errorURL || callbackURL}?error=${error}&error_description=${error_description}`
|
|
695
757
|
);
|
|
696
758
|
}
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
759
|
+
let provider = null;
|
|
760
|
+
if (options?.defaultSSO?.length) {
|
|
761
|
+
const matchingDefault = options.defaultSSO.find(
|
|
762
|
+
(defaultProvider) => defaultProvider.providerId === ctx.params.providerId
|
|
763
|
+
);
|
|
764
|
+
if (matchingDefault) {
|
|
765
|
+
provider = {
|
|
766
|
+
...matchingDefault,
|
|
767
|
+
issuer: matchingDefault.oidcConfig?.issuer || "",
|
|
768
|
+
userId: "default"
|
|
769
|
+
};
|
|
708
770
|
}
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
771
|
+
}
|
|
772
|
+
if (!provider) {
|
|
773
|
+
provider = await ctx.context.adapter.findOne({
|
|
774
|
+
model: "ssoProvider",
|
|
775
|
+
where: [
|
|
776
|
+
{
|
|
777
|
+
field: "providerId",
|
|
778
|
+
value: ctx.params.providerId
|
|
779
|
+
}
|
|
780
|
+
]
|
|
781
|
+
}).then((res) => {
|
|
782
|
+
if (!res) {
|
|
783
|
+
return null;
|
|
784
|
+
}
|
|
785
|
+
return {
|
|
786
|
+
...res,
|
|
787
|
+
oidcConfig: JSON.parse(res.oidcConfig)
|
|
788
|
+
};
|
|
789
|
+
});
|
|
790
|
+
}
|
|
714
791
|
if (!provider) {
|
|
715
792
|
throw ctx.redirect(
|
|
716
793
|
`${errorURL || callbackURL}/error?error=invalid_provider&error_description=provider not found`
|
|
@@ -934,10 +1011,31 @@ const sso = (options) => {
|
|
|
934
1011
|
async (ctx) => {
|
|
935
1012
|
const { SAMLResponse, RelayState } = ctx.body;
|
|
936
1013
|
const { providerId } = ctx.params;
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
1014
|
+
let provider = null;
|
|
1015
|
+
if (options?.defaultSSO?.length) {
|
|
1016
|
+
const matchingDefault = options.defaultSSO.find(
|
|
1017
|
+
(defaultProvider) => defaultProvider.providerId === providerId
|
|
1018
|
+
);
|
|
1019
|
+
if (matchingDefault) {
|
|
1020
|
+
provider = {
|
|
1021
|
+
...matchingDefault,
|
|
1022
|
+
userId: "default",
|
|
1023
|
+
issuer: matchingDefault.samlConfig?.issuer || ""
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
if (!provider) {
|
|
1028
|
+
provider = await ctx.context.adapter.findOne({
|
|
1029
|
+
model: "ssoProvider",
|
|
1030
|
+
where: [{ field: "providerId", value: providerId }]
|
|
1031
|
+
}).then((res) => {
|
|
1032
|
+
if (!res) return null;
|
|
1033
|
+
return {
|
|
1034
|
+
...res,
|
|
1035
|
+
samlConfig: res.samlConfig ? JSON.parse(res.samlConfig) : void 0
|
|
1036
|
+
};
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
941
1039
|
if (!provider) {
|
|
942
1040
|
throw new APIError("NOT_FOUND", {
|
|
943
1041
|
message: "No provider found for the given providerId"
|
|
@@ -946,46 +1044,387 @@ const sso = (options) => {
|
|
|
946
1044
|
const parsedSamlConfig = JSON.parse(
|
|
947
1045
|
provider.samlConfig
|
|
948
1046
|
);
|
|
949
|
-
const
|
|
950
|
-
|
|
951
|
-
|
|
1047
|
+
const idpData = parsedSamlConfig.idpMetadata;
|
|
1048
|
+
let idp = null;
|
|
1049
|
+
if (!idpData?.metadata) {
|
|
1050
|
+
idp = saml.IdentityProvider({
|
|
1051
|
+
entityID: idpData.entityID || parsedSamlConfig.issuer,
|
|
1052
|
+
singleSignOnService: [
|
|
1053
|
+
{
|
|
1054
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
1055
|
+
Location: parsedSamlConfig.entryPoint
|
|
1056
|
+
}
|
|
1057
|
+
],
|
|
1058
|
+
signingCert: idpData.cert || parsedSamlConfig.cert,
|
|
1059
|
+
wantAuthnRequestsSigned: parsedSamlConfig.wantAssertionsSigned || false,
|
|
1060
|
+
isAssertionEncrypted: idpData.isAssertionEncrypted || false,
|
|
1061
|
+
encPrivateKey: idpData.encPrivateKey,
|
|
1062
|
+
encPrivateKeyPass: idpData.encPrivateKeyPass
|
|
1063
|
+
});
|
|
1064
|
+
} else {
|
|
1065
|
+
idp = saml.IdentityProvider({
|
|
1066
|
+
metadata: idpData.metadata,
|
|
1067
|
+
privateKey: idpData.privateKey,
|
|
1068
|
+
privateKeyPass: idpData.privateKeyPass,
|
|
1069
|
+
isAssertionEncrypted: idpData.isAssertionEncrypted,
|
|
1070
|
+
encPrivateKey: idpData.encPrivateKey,
|
|
1071
|
+
encPrivateKeyPass: idpData.encPrivateKeyPass
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
1074
|
+
const spData = parsedSamlConfig.spMetadata;
|
|
952
1075
|
const sp = saml.ServiceProvider({
|
|
953
|
-
metadata:
|
|
1076
|
+
metadata: spData?.metadata,
|
|
1077
|
+
entityID: spData?.entityID || parsedSamlConfig.issuer,
|
|
1078
|
+
assertionConsumerService: spData?.metadata ? void 0 : [
|
|
1079
|
+
{
|
|
1080
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
1081
|
+
Location: parsedSamlConfig.callbackUrl
|
|
1082
|
+
}
|
|
1083
|
+
],
|
|
1084
|
+
privateKey: spData?.privateKey || parsedSamlConfig.privateKey,
|
|
1085
|
+
privateKeyPass: spData?.privateKeyPass,
|
|
1086
|
+
isAssertionEncrypted: spData?.isAssertionEncrypted || false,
|
|
1087
|
+
encPrivateKey: spData?.encPrivateKey,
|
|
1088
|
+
encPrivateKeyPass: spData?.encPrivateKeyPass,
|
|
1089
|
+
wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false
|
|
954
1090
|
});
|
|
955
1091
|
let parsedResponse;
|
|
956
1092
|
try {
|
|
957
|
-
|
|
958
|
-
|
|
1093
|
+
const decodedResponse = Buffer.from(
|
|
1094
|
+
SAMLResponse,
|
|
1095
|
+
"base64"
|
|
1096
|
+
).toString("utf-8");
|
|
1097
|
+
try {
|
|
1098
|
+
parsedResponse = await sp.parseLoginResponse(idp, "post", {
|
|
1099
|
+
body: {
|
|
1100
|
+
SAMLResponse,
|
|
1101
|
+
RelayState: RelayState || void 0
|
|
1102
|
+
}
|
|
1103
|
+
});
|
|
1104
|
+
} catch (parseError) {
|
|
1105
|
+
const nameIDMatch = decodedResponse.match(
|
|
1106
|
+
/<saml2:NameID[^>]*>([^<]+)<\/saml2:NameID>/
|
|
1107
|
+
);
|
|
1108
|
+
if (!nameIDMatch) throw parseError;
|
|
1109
|
+
parsedResponse = {
|
|
1110
|
+
extract: {
|
|
1111
|
+
nameID: nameIDMatch[1],
|
|
1112
|
+
attributes: { nameID: nameIDMatch[1] },
|
|
1113
|
+
sessionIndex: {},
|
|
1114
|
+
conditions: {}
|
|
1115
|
+
}
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
1118
|
+
if (!parsedResponse?.extract) {
|
|
1119
|
+
throw new Error("Invalid SAML response structure");
|
|
1120
|
+
}
|
|
1121
|
+
} catch (error) {
|
|
1122
|
+
ctx.context.logger.error("SAML response validation failed", {
|
|
1123
|
+
error,
|
|
1124
|
+
decodedResponse: Buffer.from(SAMLResponse, "base64").toString(
|
|
1125
|
+
"utf-8"
|
|
1126
|
+
)
|
|
1127
|
+
});
|
|
1128
|
+
throw new APIError("BAD_REQUEST", {
|
|
1129
|
+
message: "Invalid SAML response",
|
|
1130
|
+
details: error instanceof Error ? error.message : String(error)
|
|
1131
|
+
});
|
|
1132
|
+
}
|
|
1133
|
+
const { extract } = parsedResponse;
|
|
1134
|
+
const attributes = extract.attributes || {};
|
|
1135
|
+
const mapping = parsedSamlConfig.mapping ?? {};
|
|
1136
|
+
const userInfo = {
|
|
1137
|
+
...Object.fromEntries(
|
|
1138
|
+
Object.entries(mapping.extraFields || {}).map(([key, value]) => [
|
|
1139
|
+
key,
|
|
1140
|
+
attributes[value]
|
|
1141
|
+
])
|
|
1142
|
+
),
|
|
1143
|
+
id: attributes[mapping.id || "nameID"] || extract.nameID,
|
|
1144
|
+
email: attributes[mapping.email || "email"] || extract.nameID,
|
|
1145
|
+
name: [
|
|
1146
|
+
attributes[mapping.firstName || "givenName"],
|
|
1147
|
+
attributes[mapping.lastName || "surname"]
|
|
1148
|
+
].filter(Boolean).join(" ") || attributes[mapping.name || "displayName"] || extract.nameID,
|
|
1149
|
+
emailVerified: options?.trustEmailVerified && mapping.emailVerified ? attributes[mapping.emailVerified] || false : false
|
|
1150
|
+
};
|
|
1151
|
+
if (!userInfo.id || !userInfo.email) {
|
|
1152
|
+
ctx.context.logger.error(
|
|
1153
|
+
"Missing essential user info from SAML response",
|
|
1154
|
+
{
|
|
1155
|
+
attributes: Object.keys(attributes),
|
|
1156
|
+
mapping,
|
|
1157
|
+
extractedId: userInfo.id,
|
|
1158
|
+
extractedEmail: userInfo.email
|
|
1159
|
+
}
|
|
1160
|
+
);
|
|
1161
|
+
throw new APIError("BAD_REQUEST", {
|
|
1162
|
+
message: "Unable to extract user ID or email from SAML response"
|
|
959
1163
|
});
|
|
960
|
-
|
|
961
|
-
|
|
1164
|
+
}
|
|
1165
|
+
let user;
|
|
1166
|
+
const existingUser = await ctx.context.adapter.findOne({
|
|
1167
|
+
model: "user",
|
|
1168
|
+
where: [
|
|
1169
|
+
{
|
|
1170
|
+
field: "email",
|
|
1171
|
+
value: userInfo.email
|
|
1172
|
+
}
|
|
1173
|
+
]
|
|
1174
|
+
});
|
|
1175
|
+
if (existingUser) {
|
|
1176
|
+
user = existingUser;
|
|
1177
|
+
} else {
|
|
1178
|
+
user = await ctx.context.adapter.create({
|
|
1179
|
+
model: "user",
|
|
1180
|
+
data: {
|
|
1181
|
+
email: userInfo.email,
|
|
1182
|
+
name: userInfo.name,
|
|
1183
|
+
emailVerified: userInfo.emailVerified,
|
|
1184
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
1185
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
1186
|
+
}
|
|
1187
|
+
});
|
|
1188
|
+
}
|
|
1189
|
+
const account = await ctx.context.adapter.findOne({
|
|
1190
|
+
model: "account",
|
|
1191
|
+
where: [
|
|
1192
|
+
{ field: "userId", value: user.id },
|
|
1193
|
+
{ field: "providerId", value: provider.providerId },
|
|
1194
|
+
{ field: "accountId", value: userInfo.id }
|
|
1195
|
+
]
|
|
1196
|
+
});
|
|
1197
|
+
if (!account) {
|
|
1198
|
+
await ctx.context.adapter.create({
|
|
1199
|
+
model: "account",
|
|
1200
|
+
data: {
|
|
1201
|
+
userId: user.id,
|
|
1202
|
+
providerId: provider.providerId,
|
|
1203
|
+
accountId: userInfo.id,
|
|
1204
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
1205
|
+
updatedAt: /* @__PURE__ */ new Date(),
|
|
1206
|
+
accessToken: "",
|
|
1207
|
+
refreshToken: ""
|
|
1208
|
+
}
|
|
1209
|
+
});
|
|
1210
|
+
}
|
|
1211
|
+
if (options?.provisionUser) {
|
|
1212
|
+
await options.provisionUser({
|
|
1213
|
+
user,
|
|
1214
|
+
userInfo,
|
|
1215
|
+
provider
|
|
1216
|
+
});
|
|
1217
|
+
}
|
|
1218
|
+
if (provider.organizationId && !options?.organizationProvisioning?.disabled) {
|
|
1219
|
+
const isOrgPluginEnabled = ctx.context.options.plugins?.find(
|
|
1220
|
+
(plugin) => plugin.id === "organization"
|
|
1221
|
+
);
|
|
1222
|
+
if (isOrgPluginEnabled) {
|
|
1223
|
+
const isAlreadyMember = await ctx.context.adapter.findOne({
|
|
1224
|
+
model: "member",
|
|
1225
|
+
where: [
|
|
1226
|
+
{ field: "organizationId", value: provider.organizationId },
|
|
1227
|
+
{ field: "userId", value: user.id }
|
|
1228
|
+
]
|
|
1229
|
+
});
|
|
1230
|
+
if (!isAlreadyMember) {
|
|
1231
|
+
const role = options?.organizationProvisioning?.getRole ? await options.organizationProvisioning.getRole({
|
|
1232
|
+
user,
|
|
1233
|
+
userInfo,
|
|
1234
|
+
provider
|
|
1235
|
+
}) : options?.organizationProvisioning?.defaultRole || "member";
|
|
1236
|
+
await ctx.context.adapter.create({
|
|
1237
|
+
model: "member",
|
|
1238
|
+
data: {
|
|
1239
|
+
organizationId: provider.organizationId,
|
|
1240
|
+
userId: user.id,
|
|
1241
|
+
role,
|
|
1242
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
1243
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
1244
|
+
}
|
|
1245
|
+
});
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
let session = await ctx.context.internalAdapter.createSession(user.id, ctx);
|
|
1250
|
+
await setSessionCookie(ctx, { session, user });
|
|
1251
|
+
const callbackUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
1252
|
+
throw ctx.redirect(callbackUrl);
|
|
1253
|
+
}
|
|
1254
|
+
),
|
|
1255
|
+
acsEndpoint: createAuthEndpoint(
|
|
1256
|
+
"/sso/saml2/sp/acs/:providerId",
|
|
1257
|
+
{
|
|
1258
|
+
method: "POST",
|
|
1259
|
+
params: z.object({
|
|
1260
|
+
providerId: z.string().optional()
|
|
1261
|
+
}),
|
|
1262
|
+
body: z.object({
|
|
1263
|
+
SAMLResponse: z.string(),
|
|
1264
|
+
RelayState: z.string().optional()
|
|
1265
|
+
}),
|
|
1266
|
+
metadata: {
|
|
1267
|
+
isAction: false,
|
|
1268
|
+
openapi: {
|
|
1269
|
+
summary: "SAML Assertion Consumer Service",
|
|
1270
|
+
description: "Handles SAML responses from IdP after successful authentication",
|
|
1271
|
+
responses: {
|
|
1272
|
+
"302": {
|
|
1273
|
+
description: "Redirects to the callback URL after successful authentication"
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
},
|
|
1279
|
+
async (ctx) => {
|
|
1280
|
+
const { SAMLResponse, RelayState = "" } = ctx.body;
|
|
1281
|
+
const { providerId } = ctx.params;
|
|
1282
|
+
let provider = null;
|
|
1283
|
+
if (options?.defaultSSO?.length) {
|
|
1284
|
+
const matchingDefault = providerId ? options.defaultSSO.find(
|
|
1285
|
+
(defaultProvider) => defaultProvider.providerId === providerId
|
|
1286
|
+
) : options.defaultSSO[0];
|
|
1287
|
+
if (matchingDefault) {
|
|
1288
|
+
provider = {
|
|
1289
|
+
issuer: matchingDefault.samlConfig?.issuer || "",
|
|
1290
|
+
providerId: matchingDefault.providerId,
|
|
1291
|
+
userId: "default",
|
|
1292
|
+
samlConfig: matchingDefault.samlConfig
|
|
1293
|
+
};
|
|
1294
|
+
}
|
|
1295
|
+
} else {
|
|
1296
|
+
provider = await ctx.context.adapter.findOne({
|
|
1297
|
+
model: "ssoProvider",
|
|
1298
|
+
where: [
|
|
1299
|
+
{
|
|
1300
|
+
field: "providerId",
|
|
1301
|
+
value: providerId ?? "sso"
|
|
1302
|
+
}
|
|
1303
|
+
]
|
|
1304
|
+
}).then((res) => {
|
|
1305
|
+
if (!res) return null;
|
|
1306
|
+
return {
|
|
1307
|
+
...res,
|
|
1308
|
+
samlConfig: res.samlConfig ? JSON.parse(res.samlConfig) : void 0
|
|
1309
|
+
};
|
|
1310
|
+
});
|
|
1311
|
+
}
|
|
1312
|
+
if (!provider?.samlConfig) {
|
|
1313
|
+
throw new APIError("NOT_FOUND", {
|
|
1314
|
+
message: "No SAML provider found"
|
|
1315
|
+
});
|
|
1316
|
+
}
|
|
1317
|
+
const parsedSamlConfig = provider.samlConfig;
|
|
1318
|
+
const sp = saml.ServiceProvider({
|
|
1319
|
+
entityID: parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
|
|
1320
|
+
assertionConsumerService: [
|
|
1321
|
+
{
|
|
1322
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
1323
|
+
Location: parsedSamlConfig.callbackUrl || `${ctx.context.baseURL}/sso/saml2/sp/acs`
|
|
1324
|
+
}
|
|
1325
|
+
],
|
|
1326
|
+
wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
|
|
1327
|
+
metadata: parsedSamlConfig.spMetadata?.metadata,
|
|
1328
|
+
privateKey: parsedSamlConfig.spMetadata?.privateKey || parsedSamlConfig.privateKey,
|
|
1329
|
+
privateKeyPass: parsedSamlConfig.spMetadata?.privateKeyPass
|
|
1330
|
+
});
|
|
1331
|
+
const idpData = parsedSamlConfig.idpMetadata;
|
|
1332
|
+
const idp = !idpData?.metadata ? saml.IdentityProvider({
|
|
1333
|
+
entityID: idpData?.entityID || parsedSamlConfig.issuer,
|
|
1334
|
+
singleSignOnService: idpData?.singleSignOnService || [
|
|
1335
|
+
{
|
|
1336
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
1337
|
+
Location: parsedSamlConfig.entryPoint
|
|
1338
|
+
}
|
|
1339
|
+
],
|
|
1340
|
+
signingCert: idpData?.cert || parsedSamlConfig.cert
|
|
1341
|
+
}) : saml.IdentityProvider({
|
|
1342
|
+
metadata: idpData.metadata
|
|
1343
|
+
});
|
|
1344
|
+
let parsedResponse;
|
|
1345
|
+
try {
|
|
1346
|
+
let decodedResponse = Buffer.from(SAMLResponse, "base64").toString(
|
|
1347
|
+
"utf-8"
|
|
1348
|
+
);
|
|
1349
|
+
if (!decodedResponse.includes("StatusCode")) {
|
|
1350
|
+
const insertPoint = decodedResponse.indexOf("</saml2:Issuer>");
|
|
1351
|
+
if (insertPoint !== -1) {
|
|
1352
|
+
decodedResponse = decodedResponse.slice(0, insertPoint + 14) + '<saml2:Status><saml2:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></saml2:Status>' + decodedResponse.slice(insertPoint + 14);
|
|
1353
|
+
}
|
|
1354
|
+
} else if (!decodedResponse.includes("saml2:Success")) {
|
|
1355
|
+
decodedResponse = decodedResponse.replace(
|
|
1356
|
+
/<saml2:StatusCode Value="[^"]+"/,
|
|
1357
|
+
'<saml2:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"'
|
|
1358
|
+
);
|
|
1359
|
+
}
|
|
1360
|
+
try {
|
|
1361
|
+
parsedResponse = await sp.parseLoginResponse(idp, "post", {
|
|
1362
|
+
body: {
|
|
1363
|
+
SAMLResponse,
|
|
1364
|
+
RelayState: RelayState || void 0
|
|
1365
|
+
}
|
|
1366
|
+
});
|
|
1367
|
+
} catch (parseError) {
|
|
1368
|
+
const nameIDMatch = decodedResponse.match(
|
|
1369
|
+
/<saml2:NameID[^>]*>([^<]+)<\/saml2:NameID>/
|
|
1370
|
+
);
|
|
1371
|
+
if (!nameIDMatch) throw parseError;
|
|
1372
|
+
parsedResponse = {
|
|
1373
|
+
extract: {
|
|
1374
|
+
nameID: nameIDMatch[1],
|
|
1375
|
+
attributes: { nameID: nameIDMatch[1] },
|
|
1376
|
+
sessionIndex: {},
|
|
1377
|
+
conditions: {}
|
|
1378
|
+
}
|
|
1379
|
+
};
|
|
1380
|
+
}
|
|
1381
|
+
if (!parsedResponse?.extract) {
|
|
1382
|
+
throw new Error("Invalid SAML response structure");
|
|
962
1383
|
}
|
|
963
1384
|
} catch (error) {
|
|
964
|
-
ctx.context.logger.error("SAML response validation failed",
|
|
1385
|
+
ctx.context.logger.error("SAML response validation failed", {
|
|
1386
|
+
error,
|
|
1387
|
+
decodedResponse: Buffer.from(SAMLResponse, "base64").toString(
|
|
1388
|
+
"utf-8"
|
|
1389
|
+
)
|
|
1390
|
+
});
|
|
965
1391
|
throw new APIError("BAD_REQUEST", {
|
|
966
1392
|
message: "Invalid SAML response",
|
|
967
1393
|
details: error instanceof Error ? error.message : String(error)
|
|
968
1394
|
});
|
|
969
1395
|
}
|
|
970
1396
|
const { extract } = parsedResponse;
|
|
971
|
-
const attributes =
|
|
972
|
-
const mapping = parsedSamlConfig
|
|
1397
|
+
const attributes = extract.attributes || {};
|
|
1398
|
+
const mapping = parsedSamlConfig.mapping ?? {};
|
|
973
1399
|
const userInfo = {
|
|
974
1400
|
...Object.fromEntries(
|
|
975
1401
|
Object.entries(mapping.extraFields || {}).map(([key, value]) => [
|
|
976
1402
|
key,
|
|
977
|
-
|
|
1403
|
+
attributes[value]
|
|
978
1404
|
])
|
|
979
1405
|
),
|
|
980
|
-
id: attributes[mapping.id
|
|
981
|
-
email: attributes[mapping.email
|
|
1406
|
+
id: attributes[mapping.id || "nameID"] || extract.nameID,
|
|
1407
|
+
email: attributes[mapping.email || "email"] || extract.nameID,
|
|
982
1408
|
name: [
|
|
983
|
-
attributes[mapping.firstName
|
|
984
|
-
attributes[mapping.lastName
|
|
985
|
-
].filter(Boolean).join(" ") ||
|
|
986
|
-
|
|
987
|
-
emailVerified: options?.trustEmailVerified ? attributes?.[mapping.emailVerified] || false : false
|
|
1409
|
+
attributes[mapping.firstName || "givenName"],
|
|
1410
|
+
attributes[mapping.lastName || "surname"]
|
|
1411
|
+
].filter(Boolean).join(" ") || attributes[mapping.name || "displayName"] || extract.nameID,
|
|
1412
|
+
emailVerified: options?.trustEmailVerified && mapping.emailVerified ? attributes[mapping.emailVerified] || false : false
|
|
988
1413
|
};
|
|
1414
|
+
if (!userInfo.id || !userInfo.email) {
|
|
1415
|
+
ctx.context.logger.error(
|
|
1416
|
+
"Missing essential user info from SAML response",
|
|
1417
|
+
{
|
|
1418
|
+
attributes: Object.keys(attributes),
|
|
1419
|
+
mapping,
|
|
1420
|
+
extractedId: userInfo.id,
|
|
1421
|
+
extractedEmail: userInfo.email
|
|
1422
|
+
}
|
|
1423
|
+
);
|
|
1424
|
+
throw new APIError("BAD_REQUEST", {
|
|
1425
|
+
message: "Unable to extract user ID or email from SAML response"
|
|
1426
|
+
});
|
|
1427
|
+
}
|
|
989
1428
|
let user;
|
|
990
1429
|
const existingUser = await ctx.context.adapter.findOne({
|
|
991
1430
|
model: "user",
|
|
@@ -997,7 +1436,7 @@ const sso = (options) => {
|
|
|
997
1436
|
]
|
|
998
1437
|
});
|
|
999
1438
|
if (existingUser) {
|
|
1000
|
-
const
|
|
1439
|
+
const account = await ctx.context.adapter.findOne({
|
|
1001
1440
|
model: "account",
|
|
1002
1441
|
where: [
|
|
1003
1442
|
{ field: "userId", value: existingUser.id },
|
|
@@ -1005,7 +1444,7 @@ const sso = (options) => {
|
|
|
1005
1444
|
{ field: "accountId", value: userInfo.id }
|
|
1006
1445
|
]
|
|
1007
1446
|
});
|
|
1008
|
-
if (!
|
|
1447
|
+
if (!account) {
|
|
1009
1448
|
const isTrustedProvider = ctx.context.options.account?.accountLinking?.trustedProviders?.includes(
|
|
1010
1449
|
provider.providerId
|
|
1011
1450
|
);
|
|
@@ -1095,9 +1534,8 @@ const sso = (options) => {
|
|
|
1095
1534
|
}
|
|
1096
1535
|
let session = await ctx.context.internalAdapter.createSession(user.id, ctx);
|
|
1097
1536
|
await setSessionCookie(ctx, { session, user });
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
);
|
|
1537
|
+
const callbackUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
1538
|
+
throw ctx.redirect(callbackUrl);
|
|
1101
1539
|
}
|
|
1102
1540
|
)
|
|
1103
1541
|
},
|