@better-auth/sso 1.3.18 → 1.4.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/.turbo/turbo-build.log +4 -4
- package/CHANGELOG.md +20 -0
- package/dist/index.cjs +91 -541
- package/dist/index.d.cts +39 -186
- package/dist/index.d.mts +39 -186
- package/dist/index.d.ts +39 -186
- package/dist/index.mjs +91 -541
- package/package.json +5 -5
- package/src/index.ts +142 -798
- package/src/oidc.test.ts +21 -84
- package/src/saml.test.ts +8 -163
- package/tsconfig.json +15 -9
package/dist/index.cjs
CHANGED
|
@@ -76,18 +76,8 @@ const sso = (options) => {
|
|
|
76
76
|
});
|
|
77
77
|
}
|
|
78
78
|
const parsedSamlConfig = JSON.parse(provider.samlConfig);
|
|
79
|
-
const sp =
|
|
79
|
+
const sp = saml__namespace.ServiceProvider({
|
|
80
80
|
metadata: parsedSamlConfig.spMetadata.metadata
|
|
81
|
-
}) : saml__namespace.SPMetadata({
|
|
82
|
-
entityID: parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
|
|
83
|
-
assertionConsumerService: [
|
|
84
|
-
{
|
|
85
|
-
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
86
|
-
Location: parsedSamlConfig.callbackUrl || `${ctx.context.baseURL}/sso/saml2/sp/acs/${provider.id}`
|
|
87
|
-
}
|
|
88
|
-
],
|
|
89
|
-
wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
|
|
90
|
-
nameIDFormat: parsedSamlConfig.identifierFormat ? [parsedSamlConfig.identifierFormat] : void 0
|
|
91
81
|
});
|
|
92
82
|
return new Response(sp.getMetadata(), {
|
|
93
83
|
headers: {
|
|
@@ -136,25 +126,7 @@ const sso = (options) => {
|
|
|
136
126
|
}).optional(),
|
|
137
127
|
pkce: z__namespace.boolean({}).meta({
|
|
138
128
|
description: "Whether to use PKCE for the authorization flow"
|
|
139
|
-
}).default(true).optional()
|
|
140
|
-
mapping: z__namespace.object({
|
|
141
|
-
id: z__namespace.string({}).meta({
|
|
142
|
-
description: "Field mapping for user ID (defaults to 'sub')"
|
|
143
|
-
}),
|
|
144
|
-
email: z__namespace.string({}).meta({
|
|
145
|
-
description: "Field mapping for email (defaults to 'email')"
|
|
146
|
-
}),
|
|
147
|
-
emailVerified: z__namespace.string({}).meta({
|
|
148
|
-
description: "Field mapping for email verification (defaults to 'email_verified')"
|
|
149
|
-
}).optional(),
|
|
150
|
-
name: z__namespace.string({}).meta({
|
|
151
|
-
description: "Field mapping for name (defaults to 'name')"
|
|
152
|
-
}),
|
|
153
|
-
image: z__namespace.string({}).meta({
|
|
154
|
-
description: "Field mapping for image (defaults to 'picture')"
|
|
155
|
-
}).optional(),
|
|
156
|
-
extraFields: z__namespace.record(z__namespace.string(), z__namespace.any()).optional()
|
|
157
|
-
}).optional()
|
|
129
|
+
}).default(true).optional()
|
|
158
130
|
}).optional(),
|
|
159
131
|
samlConfig: z__namespace.object({
|
|
160
132
|
entryPoint: z__namespace.string({}).meta({
|
|
@@ -168,30 +140,15 @@ const sso = (options) => {
|
|
|
168
140
|
}),
|
|
169
141
|
audience: z__namespace.string().optional(),
|
|
170
142
|
idpMetadata: z__namespace.object({
|
|
171
|
-
metadata: z__namespace.string()
|
|
172
|
-
entityID: z__namespace.string().optional(),
|
|
173
|
-
cert: z__namespace.string().optional(),
|
|
143
|
+
metadata: z__namespace.string(),
|
|
174
144
|
privateKey: z__namespace.string().optional(),
|
|
175
145
|
privateKeyPass: z__namespace.string().optional(),
|
|
176
146
|
isAssertionEncrypted: z__namespace.boolean().optional(),
|
|
177
147
|
encPrivateKey: z__namespace.string().optional(),
|
|
178
|
-
encPrivateKeyPass: z__namespace.string().optional()
|
|
179
|
-
singleSignOnService: z__namespace.array(
|
|
180
|
-
z__namespace.object({
|
|
181
|
-
Binding: z__namespace.string().meta({
|
|
182
|
-
description: "The binding type for the SSO service"
|
|
183
|
-
}),
|
|
184
|
-
Location: z__namespace.string().meta({
|
|
185
|
-
description: "The URL for the SSO service"
|
|
186
|
-
})
|
|
187
|
-
})
|
|
188
|
-
).optional().meta({
|
|
189
|
-
description: "Single Sign-On service configuration"
|
|
190
|
-
})
|
|
148
|
+
encPrivateKeyPass: z__namespace.string().optional()
|
|
191
149
|
}).optional(),
|
|
192
150
|
spMetadata: z__namespace.object({
|
|
193
|
-
metadata: z__namespace.string()
|
|
194
|
-
entityID: z__namespace.string().optional(),
|
|
151
|
+
metadata: z__namespace.string(),
|
|
195
152
|
binding: z__namespace.string().optional(),
|
|
196
153
|
privateKey: z__namespace.string().optional(),
|
|
197
154
|
privateKeyPass: z__namespace.string().optional(),
|
|
@@ -205,28 +162,25 @@ const sso = (options) => {
|
|
|
205
162
|
identifierFormat: z__namespace.string().optional(),
|
|
206
163
|
privateKey: z__namespace.string().optional(),
|
|
207
164
|
decryptionPvk: z__namespace.string().optional(),
|
|
208
|
-
additionalParams: z__namespace.record(z__namespace.string(), z__namespace.any()).optional()
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
}).optional(),
|
|
228
|
-
extraFields: z__namespace.record(z__namespace.string(), z__namespace.any()).optional()
|
|
229
|
-
}).optional()
|
|
165
|
+
additionalParams: z__namespace.record(z__namespace.string(), z__namespace.any()).optional()
|
|
166
|
+
}).optional(),
|
|
167
|
+
mapping: z__namespace.object({
|
|
168
|
+
id: z__namespace.string({}).meta({
|
|
169
|
+
description: "The field in the user info response that contains the id. Defaults to 'sub'"
|
|
170
|
+
}),
|
|
171
|
+
email: z__namespace.string({}).meta({
|
|
172
|
+
description: "The field in the user info response that contains the email. Defaults to 'email'"
|
|
173
|
+
}),
|
|
174
|
+
emailVerified: z__namespace.string({}).meta({
|
|
175
|
+
description: "The field in the user info response that contains whether the email is verified. defaults to 'email_verified'"
|
|
176
|
+
}).optional(),
|
|
177
|
+
name: z__namespace.string({}).meta({
|
|
178
|
+
description: "The field in the user info response that contains the name. Defaults to 'name'"
|
|
179
|
+
}),
|
|
180
|
+
image: z__namespace.string({}).meta({
|
|
181
|
+
description: "The field in the user info response that contains the image. Defaults to 'picture'"
|
|
182
|
+
}).optional(),
|
|
183
|
+
extraFields: z__namespace.record(z__namespace.string(), z__namespace.any()).optional()
|
|
230
184
|
}).optional(),
|
|
231
185
|
organizationId: z__namespace.string({}).meta({
|
|
232
186
|
description: "If organization plugin is enabled, the organization id to link the provider to"
|
|
@@ -463,7 +417,7 @@ const sso = (options) => {
|
|
|
463
417
|
jwksEndpoint: body.oidcConfig.jwksEndpoint,
|
|
464
418
|
pkce: body.oidcConfig.pkce,
|
|
465
419
|
discoveryEndpoint: body.oidcConfig.discoveryEndpoint || `${body.issuer}/.well-known/openid-configuration`,
|
|
466
|
-
mapping: body.
|
|
420
|
+
mapping: body.mapping,
|
|
467
421
|
scopes: body.oidcConfig.scopes,
|
|
468
422
|
userInfoEndpoint: body.oidcConfig.userInfoEndpoint,
|
|
469
423
|
overrideUserInfo: ctx.body.overrideUserInfo || options?.defaultOverrideUserInfo || false
|
|
@@ -483,7 +437,7 @@ const sso = (options) => {
|
|
|
483
437
|
privateKey: body.samlConfig.privateKey,
|
|
484
438
|
decryptionPvk: body.samlConfig.decryptionPvk,
|
|
485
439
|
additionalParams: body.samlConfig.additionalParams,
|
|
486
|
-
mapping: body.
|
|
440
|
+
mapping: body.mapping
|
|
487
441
|
}) : null,
|
|
488
442
|
organizationId: body.organizationId,
|
|
489
443
|
userId: ctx.context.session.user.id,
|
|
@@ -607,7 +561,7 @@ const sso = (options) => {
|
|
|
607
561
|
async (ctx) => {
|
|
608
562
|
const body = ctx.body;
|
|
609
563
|
let { email, organizationSlug, providerId, domain } = body;
|
|
610
|
-
if (!
|
|
564
|
+
if (!email && !organizationSlug && !domain && !providerId) {
|
|
611
565
|
throw new api.APIError("BAD_REQUEST", {
|
|
612
566
|
message: "email, organizationSlug, domain or providerId is required"
|
|
613
567
|
});
|
|
@@ -630,48 +584,23 @@ const sso = (options) => {
|
|
|
630
584
|
return res.id;
|
|
631
585
|
});
|
|
632
586
|
}
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
);
|
|
640
|
-
if (matchingDefault) {
|
|
641
|
-
provider = {
|
|
642
|
-
issuer: matchingDefault.samlConfig?.issuer || matchingDefault.oidcConfig?.issuer || "",
|
|
643
|
-
providerId: matchingDefault.providerId,
|
|
644
|
-
userId: "default",
|
|
645
|
-
oidcConfig: matchingDefault.oidcConfig,
|
|
646
|
-
samlConfig: matchingDefault.samlConfig
|
|
647
|
-
};
|
|
648
|
-
}
|
|
649
|
-
}
|
|
650
|
-
if (!providerId && !orgId && !domain) {
|
|
651
|
-
throw new api.APIError("BAD_REQUEST", {
|
|
652
|
-
message: "providerId, orgId or domain is required"
|
|
653
|
-
});
|
|
654
|
-
}
|
|
655
|
-
if (!provider) {
|
|
656
|
-
provider = await ctx.context.adapter.findOne({
|
|
657
|
-
model: "ssoProvider",
|
|
658
|
-
where: [
|
|
659
|
-
{
|
|
660
|
-
field: providerId ? "providerId" : orgId ? "organizationId" : "domain",
|
|
661
|
-
value: providerId || orgId || domain
|
|
662
|
-
}
|
|
663
|
-
]
|
|
664
|
-
}).then((res) => {
|
|
665
|
-
if (!res) {
|
|
666
|
-
return null;
|
|
587
|
+
const provider = await ctx.context.adapter.findOne({
|
|
588
|
+
model: "ssoProvider",
|
|
589
|
+
where: [
|
|
590
|
+
{
|
|
591
|
+
field: providerId ? "providerId" : orgId ? "organizationId" : "domain",
|
|
592
|
+
value: providerId || orgId || domain
|
|
667
593
|
}
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
594
|
+
]
|
|
595
|
+
}).then((res) => {
|
|
596
|
+
if (!res) {
|
|
597
|
+
return null;
|
|
598
|
+
}
|
|
599
|
+
return {
|
|
600
|
+
...res,
|
|
601
|
+
oidcConfig: JSON.parse(res.oidcConfig)
|
|
602
|
+
};
|
|
603
|
+
});
|
|
675
604
|
if (!provider) {
|
|
676
605
|
throw new api.APIError("NOT_FOUND", {
|
|
677
606
|
message: "No provider found for the issuer"
|
|
@@ -715,16 +644,15 @@ const sso = (options) => {
|
|
|
715
644
|
});
|
|
716
645
|
}
|
|
717
646
|
if (provider.samlConfig) {
|
|
718
|
-
const parsedSamlConfig =
|
|
647
|
+
const parsedSamlConfig = JSON.parse(
|
|
648
|
+
provider.samlConfig
|
|
649
|
+
);
|
|
719
650
|
const sp = saml__namespace.ServiceProvider({
|
|
720
651
|
metadata: parsedSamlConfig.spMetadata.metadata,
|
|
721
652
|
allowCreate: true
|
|
722
653
|
});
|
|
723
654
|
const idp = saml__namespace.IdentityProvider({
|
|
724
|
-
metadata: parsedSamlConfig.idpMetadata
|
|
725
|
-
entityID: parsedSamlConfig.idpMetadata?.entityID,
|
|
726
|
-
encryptCert: parsedSamlConfig.idpMetadata?.cert,
|
|
727
|
-
singleSignOnService: parsedSamlConfig.idpMetadata?.singleSignOnService
|
|
655
|
+
metadata: parsedSamlConfig.idpMetadata.metadata
|
|
728
656
|
});
|
|
729
657
|
const loginRequest = sp.createLoginRequest(
|
|
730
658
|
idp,
|
|
@@ -783,38 +711,23 @@ const sso = (options) => {
|
|
|
783
711
|
`${errorURL || callbackURL}?error=${error}&error_description=${error_description}`
|
|
784
712
|
);
|
|
785
713
|
}
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
provider = {
|
|
793
|
-
...matchingDefault,
|
|
794
|
-
issuer: matchingDefault.oidcConfig?.issuer || "",
|
|
795
|
-
userId: "default"
|
|
796
|
-
};
|
|
797
|
-
}
|
|
798
|
-
}
|
|
799
|
-
if (!provider) {
|
|
800
|
-
provider = await ctx.context.adapter.findOne({
|
|
801
|
-
model: "ssoProvider",
|
|
802
|
-
where: [
|
|
803
|
-
{
|
|
804
|
-
field: "providerId",
|
|
805
|
-
value: ctx.params.providerId
|
|
806
|
-
}
|
|
807
|
-
]
|
|
808
|
-
}).then((res) => {
|
|
809
|
-
if (!res) {
|
|
810
|
-
return null;
|
|
714
|
+
const provider = await ctx.context.adapter.findOne({
|
|
715
|
+
model: "ssoProvider",
|
|
716
|
+
where: [
|
|
717
|
+
{
|
|
718
|
+
field: "providerId",
|
|
719
|
+
value: ctx.params.providerId
|
|
811
720
|
}
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
}
|
|
817
|
-
|
|
721
|
+
]
|
|
722
|
+
}).then((res) => {
|
|
723
|
+
if (!res) {
|
|
724
|
+
return null;
|
|
725
|
+
}
|
|
726
|
+
return {
|
|
727
|
+
...res,
|
|
728
|
+
oidcConfig: JSON.parse(res.oidcConfig)
|
|
729
|
+
};
|
|
730
|
+
});
|
|
818
731
|
if (!provider) {
|
|
819
732
|
throw ctx.redirect(
|
|
820
733
|
`${errorURL || callbackURL}/error?error=invalid_provider&error_description=provider not found`
|
|
@@ -1038,31 +951,10 @@ const sso = (options) => {
|
|
|
1038
951
|
async (ctx) => {
|
|
1039
952
|
const { SAMLResponse, RelayState } = ctx.body;
|
|
1040
953
|
const { providerId } = ctx.params;
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
);
|
|
1046
|
-
if (matchingDefault) {
|
|
1047
|
-
provider = {
|
|
1048
|
-
...matchingDefault,
|
|
1049
|
-
userId: "default",
|
|
1050
|
-
issuer: matchingDefault.samlConfig?.issuer || ""
|
|
1051
|
-
};
|
|
1052
|
-
}
|
|
1053
|
-
}
|
|
1054
|
-
if (!provider) {
|
|
1055
|
-
provider = await ctx.context.adapter.findOne({
|
|
1056
|
-
model: "ssoProvider",
|
|
1057
|
-
where: [{ field: "providerId", value: providerId }]
|
|
1058
|
-
}).then((res) => {
|
|
1059
|
-
if (!res) return null;
|
|
1060
|
-
return {
|
|
1061
|
-
...res,
|
|
1062
|
-
samlConfig: res.samlConfig ? JSON.parse(res.samlConfig) : void 0
|
|
1063
|
-
};
|
|
1064
|
-
});
|
|
1065
|
-
}
|
|
954
|
+
const provider = await ctx.context.adapter.findOne({
|
|
955
|
+
model: "ssoProvider",
|
|
956
|
+
where: [{ field: "providerId", value: providerId }]
|
|
957
|
+
});
|
|
1066
958
|
if (!provider) {
|
|
1067
959
|
throw new api.APIError("NOT_FOUND", {
|
|
1068
960
|
message: "No provider found for the given providerId"
|
|
@@ -1071,389 +963,46 @@ const sso = (options) => {
|
|
|
1071
963
|
const parsedSamlConfig = JSON.parse(
|
|
1072
964
|
provider.samlConfig
|
|
1073
965
|
);
|
|
1074
|
-
const
|
|
1075
|
-
|
|
1076
|
-
if (!idpData?.metadata) {
|
|
1077
|
-
idp = saml__namespace.IdentityProvider({
|
|
1078
|
-
entityID: idpData.entityID || parsedSamlConfig.issuer,
|
|
1079
|
-
singleSignOnService: [
|
|
1080
|
-
{
|
|
1081
|
-
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
1082
|
-
Location: parsedSamlConfig.entryPoint
|
|
1083
|
-
}
|
|
1084
|
-
],
|
|
1085
|
-
signingCert: idpData.cert || parsedSamlConfig.cert,
|
|
1086
|
-
wantAuthnRequestsSigned: parsedSamlConfig.wantAssertionsSigned || false,
|
|
1087
|
-
isAssertionEncrypted: idpData.isAssertionEncrypted || false,
|
|
1088
|
-
encPrivateKey: idpData.encPrivateKey,
|
|
1089
|
-
encPrivateKeyPass: idpData.encPrivateKeyPass
|
|
1090
|
-
});
|
|
1091
|
-
} else {
|
|
1092
|
-
idp = saml__namespace.IdentityProvider({
|
|
1093
|
-
metadata: idpData.metadata,
|
|
1094
|
-
privateKey: idpData.privateKey,
|
|
1095
|
-
privateKeyPass: idpData.privateKeyPass,
|
|
1096
|
-
isAssertionEncrypted: idpData.isAssertionEncrypted,
|
|
1097
|
-
encPrivateKey: idpData.encPrivateKey,
|
|
1098
|
-
encPrivateKeyPass: idpData.encPrivateKeyPass
|
|
1099
|
-
});
|
|
1100
|
-
}
|
|
1101
|
-
const spData = parsedSamlConfig.spMetadata;
|
|
1102
|
-
const sp = saml__namespace.ServiceProvider({
|
|
1103
|
-
metadata: spData?.metadata,
|
|
1104
|
-
entityID: spData?.entityID || parsedSamlConfig.issuer,
|
|
1105
|
-
assertionConsumerService: spData?.metadata ? void 0 : [
|
|
1106
|
-
{
|
|
1107
|
-
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
1108
|
-
Location: parsedSamlConfig.callbackUrl
|
|
1109
|
-
}
|
|
1110
|
-
],
|
|
1111
|
-
privateKey: spData?.privateKey || parsedSamlConfig.privateKey,
|
|
1112
|
-
privateKeyPass: spData?.privateKeyPass,
|
|
1113
|
-
isAssertionEncrypted: spData?.isAssertionEncrypted || false,
|
|
1114
|
-
encPrivateKey: spData?.encPrivateKey,
|
|
1115
|
-
encPrivateKeyPass: spData?.encPrivateKeyPass,
|
|
1116
|
-
wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
|
|
1117
|
-
nameIDFormat: parsedSamlConfig.identifierFormat ? [parsedSamlConfig.identifierFormat] : void 0
|
|
1118
|
-
});
|
|
1119
|
-
let parsedResponse;
|
|
1120
|
-
try {
|
|
1121
|
-
const decodedResponse = Buffer.from(
|
|
1122
|
-
SAMLResponse,
|
|
1123
|
-
"base64"
|
|
1124
|
-
).toString("utf-8");
|
|
1125
|
-
try {
|
|
1126
|
-
parsedResponse = await sp.parseLoginResponse(idp, "post", {
|
|
1127
|
-
body: {
|
|
1128
|
-
SAMLResponse,
|
|
1129
|
-
RelayState: RelayState || void 0
|
|
1130
|
-
}
|
|
1131
|
-
});
|
|
1132
|
-
} catch (parseError) {
|
|
1133
|
-
const nameIDMatch = decodedResponse.match(
|
|
1134
|
-
/<saml2:NameID[^>]*>([^<]+)<\/saml2:NameID>/
|
|
1135
|
-
);
|
|
1136
|
-
if (!nameIDMatch) throw parseError;
|
|
1137
|
-
parsedResponse = {
|
|
1138
|
-
extract: {
|
|
1139
|
-
nameID: nameIDMatch[1],
|
|
1140
|
-
attributes: { nameID: nameIDMatch[1] },
|
|
1141
|
-
sessionIndex: {},
|
|
1142
|
-
conditions: {}
|
|
1143
|
-
}
|
|
1144
|
-
};
|
|
1145
|
-
}
|
|
1146
|
-
if (!parsedResponse?.extract) {
|
|
1147
|
-
throw new Error("Invalid SAML response structure");
|
|
1148
|
-
}
|
|
1149
|
-
} catch (error) {
|
|
1150
|
-
ctx.context.logger.error("SAML response validation failed", {
|
|
1151
|
-
error,
|
|
1152
|
-
decodedResponse: Buffer.from(SAMLResponse, "base64").toString(
|
|
1153
|
-
"utf-8"
|
|
1154
|
-
)
|
|
1155
|
-
});
|
|
1156
|
-
throw new api.APIError("BAD_REQUEST", {
|
|
1157
|
-
message: "Invalid SAML response",
|
|
1158
|
-
details: error instanceof Error ? error.message : String(error)
|
|
1159
|
-
});
|
|
1160
|
-
}
|
|
1161
|
-
const { extract } = parsedResponse;
|
|
1162
|
-
const attributes = extract.attributes || {};
|
|
1163
|
-
const mapping = parsedSamlConfig.mapping ?? {};
|
|
1164
|
-
const userInfo = {
|
|
1165
|
-
...Object.fromEntries(
|
|
1166
|
-
Object.entries(mapping.extraFields || {}).map(([key, value]) => [
|
|
1167
|
-
key,
|
|
1168
|
-
attributes[value]
|
|
1169
|
-
])
|
|
1170
|
-
),
|
|
1171
|
-
id: attributes[mapping.id || "nameID"] || extract.nameID,
|
|
1172
|
-
email: attributes[mapping.email || "email"] || extract.nameID,
|
|
1173
|
-
name: [
|
|
1174
|
-
attributes[mapping.firstName || "givenName"],
|
|
1175
|
-
attributes[mapping.lastName || "surname"]
|
|
1176
|
-
].filter(Boolean).join(" ") || attributes[mapping.name || "displayName"] || extract.nameID,
|
|
1177
|
-
emailVerified: options?.trustEmailVerified && mapping.emailVerified ? attributes[mapping.emailVerified] || false : false
|
|
1178
|
-
};
|
|
1179
|
-
if (!userInfo.id || !userInfo.email) {
|
|
1180
|
-
ctx.context.logger.error(
|
|
1181
|
-
"Missing essential user info from SAML response",
|
|
1182
|
-
{
|
|
1183
|
-
attributes: Object.keys(attributes),
|
|
1184
|
-
mapping,
|
|
1185
|
-
extractedId: userInfo.id,
|
|
1186
|
-
extractedEmail: userInfo.email
|
|
1187
|
-
}
|
|
1188
|
-
);
|
|
1189
|
-
throw new api.APIError("BAD_REQUEST", {
|
|
1190
|
-
message: "Unable to extract user ID or email from SAML response"
|
|
1191
|
-
});
|
|
1192
|
-
}
|
|
1193
|
-
let user;
|
|
1194
|
-
const existingUser = await ctx.context.adapter.findOne({
|
|
1195
|
-
model: "user",
|
|
1196
|
-
where: [
|
|
1197
|
-
{
|
|
1198
|
-
field: "email",
|
|
1199
|
-
value: userInfo.email
|
|
1200
|
-
}
|
|
1201
|
-
]
|
|
1202
|
-
});
|
|
1203
|
-
if (existingUser) {
|
|
1204
|
-
user = existingUser;
|
|
1205
|
-
} else {
|
|
1206
|
-
user = await ctx.context.adapter.create({
|
|
1207
|
-
model: "user",
|
|
1208
|
-
data: {
|
|
1209
|
-
email: userInfo.email,
|
|
1210
|
-
name: userInfo.name,
|
|
1211
|
-
emailVerified: userInfo.emailVerified,
|
|
1212
|
-
createdAt: /* @__PURE__ */ new Date(),
|
|
1213
|
-
updatedAt: /* @__PURE__ */ new Date()
|
|
1214
|
-
}
|
|
1215
|
-
});
|
|
1216
|
-
}
|
|
1217
|
-
const account = await ctx.context.adapter.findOne({
|
|
1218
|
-
model: "account",
|
|
1219
|
-
where: [
|
|
1220
|
-
{ field: "userId", value: user.id },
|
|
1221
|
-
{ field: "providerId", value: provider.providerId },
|
|
1222
|
-
{ field: "accountId", value: userInfo.id }
|
|
1223
|
-
]
|
|
966
|
+
const idp = saml__namespace.IdentityProvider({
|
|
967
|
+
metadata: parsedSamlConfig.idpMetadata.metadata
|
|
1224
968
|
});
|
|
1225
|
-
if (!account) {
|
|
1226
|
-
await ctx.context.adapter.create({
|
|
1227
|
-
model: "account",
|
|
1228
|
-
data: {
|
|
1229
|
-
userId: user.id,
|
|
1230
|
-
providerId: provider.providerId,
|
|
1231
|
-
accountId: userInfo.id,
|
|
1232
|
-
createdAt: /* @__PURE__ */ new Date(),
|
|
1233
|
-
updatedAt: /* @__PURE__ */ new Date(),
|
|
1234
|
-
accessToken: "",
|
|
1235
|
-
refreshToken: ""
|
|
1236
|
-
}
|
|
1237
|
-
});
|
|
1238
|
-
}
|
|
1239
|
-
if (options?.provisionUser) {
|
|
1240
|
-
await options.provisionUser({
|
|
1241
|
-
user,
|
|
1242
|
-
userInfo,
|
|
1243
|
-
provider
|
|
1244
|
-
});
|
|
1245
|
-
}
|
|
1246
|
-
if (provider.organizationId && !options?.organizationProvisioning?.disabled) {
|
|
1247
|
-
const isOrgPluginEnabled = ctx.context.options.plugins?.find(
|
|
1248
|
-
(plugin) => plugin.id === "organization"
|
|
1249
|
-
);
|
|
1250
|
-
if (isOrgPluginEnabled) {
|
|
1251
|
-
const isAlreadyMember = await ctx.context.adapter.findOne({
|
|
1252
|
-
model: "member",
|
|
1253
|
-
where: [
|
|
1254
|
-
{ field: "organizationId", value: provider.organizationId },
|
|
1255
|
-
{ field: "userId", value: user.id }
|
|
1256
|
-
]
|
|
1257
|
-
});
|
|
1258
|
-
if (!isAlreadyMember) {
|
|
1259
|
-
const role = options?.organizationProvisioning?.getRole ? await options.organizationProvisioning.getRole({
|
|
1260
|
-
user,
|
|
1261
|
-
userInfo,
|
|
1262
|
-
provider
|
|
1263
|
-
}) : options?.organizationProvisioning?.defaultRole || "member";
|
|
1264
|
-
await ctx.context.adapter.create({
|
|
1265
|
-
model: "member",
|
|
1266
|
-
data: {
|
|
1267
|
-
organizationId: provider.organizationId,
|
|
1268
|
-
userId: user.id,
|
|
1269
|
-
role,
|
|
1270
|
-
createdAt: /* @__PURE__ */ new Date(),
|
|
1271
|
-
updatedAt: /* @__PURE__ */ new Date()
|
|
1272
|
-
}
|
|
1273
|
-
});
|
|
1274
|
-
}
|
|
1275
|
-
}
|
|
1276
|
-
}
|
|
1277
|
-
let session = await ctx.context.internalAdapter.createSession(user.id, ctx);
|
|
1278
|
-
await cookies.setSessionCookie(ctx, { session, user });
|
|
1279
|
-
const callbackUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
1280
|
-
throw ctx.redirect(callbackUrl);
|
|
1281
|
-
}
|
|
1282
|
-
),
|
|
1283
|
-
acsEndpoint: plugins.createAuthEndpoint(
|
|
1284
|
-
"/sso/saml2/sp/acs/:providerId",
|
|
1285
|
-
{
|
|
1286
|
-
method: "POST",
|
|
1287
|
-
params: z__namespace.object({
|
|
1288
|
-
providerId: z__namespace.string().optional()
|
|
1289
|
-
}),
|
|
1290
|
-
body: z__namespace.object({
|
|
1291
|
-
SAMLResponse: z__namespace.string(),
|
|
1292
|
-
RelayState: z__namespace.string().optional()
|
|
1293
|
-
}),
|
|
1294
|
-
metadata: {
|
|
1295
|
-
isAction: false,
|
|
1296
|
-
openapi: {
|
|
1297
|
-
summary: "SAML Assertion Consumer Service",
|
|
1298
|
-
description: "Handles SAML responses from IdP after successful authentication",
|
|
1299
|
-
responses: {
|
|
1300
|
-
"302": {
|
|
1301
|
-
description: "Redirects to the callback URL after successful authentication"
|
|
1302
|
-
}
|
|
1303
|
-
}
|
|
1304
|
-
}
|
|
1305
|
-
}
|
|
1306
|
-
},
|
|
1307
|
-
async (ctx) => {
|
|
1308
|
-
const { SAMLResponse, RelayState = "" } = ctx.body;
|
|
1309
|
-
const { providerId } = ctx.params;
|
|
1310
|
-
let provider = null;
|
|
1311
|
-
if (options?.defaultSSO?.length) {
|
|
1312
|
-
const matchingDefault = providerId ? options.defaultSSO.find(
|
|
1313
|
-
(defaultProvider) => defaultProvider.providerId === providerId
|
|
1314
|
-
) : options.defaultSSO[0];
|
|
1315
|
-
if (matchingDefault) {
|
|
1316
|
-
provider = {
|
|
1317
|
-
issuer: matchingDefault.samlConfig?.issuer || "",
|
|
1318
|
-
providerId: matchingDefault.providerId,
|
|
1319
|
-
userId: "default",
|
|
1320
|
-
samlConfig: matchingDefault.samlConfig
|
|
1321
|
-
};
|
|
1322
|
-
}
|
|
1323
|
-
} else {
|
|
1324
|
-
provider = await ctx.context.adapter.findOne({
|
|
1325
|
-
model: "ssoProvider",
|
|
1326
|
-
where: [
|
|
1327
|
-
{
|
|
1328
|
-
field: "providerId",
|
|
1329
|
-
value: providerId ?? "sso"
|
|
1330
|
-
}
|
|
1331
|
-
]
|
|
1332
|
-
}).then((res) => {
|
|
1333
|
-
if (!res) return null;
|
|
1334
|
-
return {
|
|
1335
|
-
...res,
|
|
1336
|
-
samlConfig: res.samlConfig ? JSON.parse(res.samlConfig) : void 0
|
|
1337
|
-
};
|
|
1338
|
-
});
|
|
1339
|
-
}
|
|
1340
|
-
if (!provider?.samlConfig) {
|
|
1341
|
-
throw new api.APIError("NOT_FOUND", {
|
|
1342
|
-
message: "No SAML provider found"
|
|
1343
|
-
});
|
|
1344
|
-
}
|
|
1345
|
-
const parsedSamlConfig = provider.samlConfig;
|
|
1346
969
|
const sp = saml__namespace.ServiceProvider({
|
|
1347
|
-
|
|
1348
|
-
assertionConsumerService: [
|
|
1349
|
-
{
|
|
1350
|
-
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
1351
|
-
Location: parsedSamlConfig.callbackUrl || `${ctx.context.baseURL}/sso/saml2/sp/acs/${providerId}`
|
|
1352
|
-
}
|
|
1353
|
-
],
|
|
1354
|
-
wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
|
|
1355
|
-
metadata: parsedSamlConfig.spMetadata?.metadata,
|
|
1356
|
-
privateKey: parsedSamlConfig.spMetadata?.privateKey || parsedSamlConfig.privateKey,
|
|
1357
|
-
privateKeyPass: parsedSamlConfig.spMetadata?.privateKeyPass,
|
|
1358
|
-
nameIDFormat: parsedSamlConfig.identifierFormat ? [parsedSamlConfig.identifierFormat] : void 0
|
|
1359
|
-
});
|
|
1360
|
-
const idpData = parsedSamlConfig.idpMetadata;
|
|
1361
|
-
const idp = !idpData?.metadata ? saml__namespace.IdentityProvider({
|
|
1362
|
-
entityID: idpData?.entityID || parsedSamlConfig.issuer,
|
|
1363
|
-
singleSignOnService: idpData?.singleSignOnService || [
|
|
1364
|
-
{
|
|
1365
|
-
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
1366
|
-
Location: parsedSamlConfig.entryPoint
|
|
1367
|
-
}
|
|
1368
|
-
],
|
|
1369
|
-
signingCert: idpData?.cert || parsedSamlConfig.cert
|
|
1370
|
-
}) : saml__namespace.IdentityProvider({
|
|
1371
|
-
metadata: idpData.metadata
|
|
970
|
+
metadata: parsedSamlConfig.spMetadata.metadata
|
|
1372
971
|
});
|
|
1373
972
|
let parsedResponse;
|
|
1374
973
|
try {
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
);
|
|
1378
|
-
if (!
|
|
1379
|
-
|
|
1380
|
-
if (insertPoint !== -1) {
|
|
1381
|
-
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);
|
|
1382
|
-
}
|
|
1383
|
-
} else if (!decodedResponse.includes("saml2:Success")) {
|
|
1384
|
-
decodedResponse = decodedResponse.replace(
|
|
1385
|
-
/<saml2:StatusCode Value="[^"]+"/,
|
|
1386
|
-
'<saml2:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"'
|
|
1387
|
-
);
|
|
1388
|
-
}
|
|
1389
|
-
try {
|
|
1390
|
-
parsedResponse = await sp.parseLoginResponse(idp, "post", {
|
|
1391
|
-
body: {
|
|
1392
|
-
SAMLResponse,
|
|
1393
|
-
RelayState: RelayState || void 0
|
|
1394
|
-
}
|
|
1395
|
-
});
|
|
1396
|
-
} catch (parseError) {
|
|
1397
|
-
const nameIDMatch = decodedResponse.match(
|
|
1398
|
-
/<saml2:NameID[^>]*>([^<]+)<\/saml2:NameID>/
|
|
1399
|
-
);
|
|
1400
|
-
if (!nameIDMatch) throw parseError;
|
|
1401
|
-
parsedResponse = {
|
|
1402
|
-
extract: {
|
|
1403
|
-
nameID: nameIDMatch[1],
|
|
1404
|
-
attributes: { nameID: nameIDMatch[1] },
|
|
1405
|
-
sessionIndex: {},
|
|
1406
|
-
conditions: {}
|
|
1407
|
-
}
|
|
1408
|
-
};
|
|
1409
|
-
}
|
|
1410
|
-
if (!parsedResponse?.extract) {
|
|
1411
|
-
throw new Error("Invalid SAML response structure");
|
|
974
|
+
parsedResponse = await sp.parseLoginResponse(idp, "post", {
|
|
975
|
+
body: { SAMLResponse, RelayState }
|
|
976
|
+
});
|
|
977
|
+
if (!parsedResponse) {
|
|
978
|
+
throw new Error("Empty SAML response");
|
|
1412
979
|
}
|
|
1413
980
|
} catch (error) {
|
|
1414
|
-
ctx.context.logger.error("SAML response validation failed",
|
|
1415
|
-
error,
|
|
1416
|
-
decodedResponse: Buffer.from(SAMLResponse, "base64").toString(
|
|
1417
|
-
"utf-8"
|
|
1418
|
-
)
|
|
1419
|
-
});
|
|
981
|
+
ctx.context.logger.error("SAML response validation failed", error);
|
|
1420
982
|
throw new api.APIError("BAD_REQUEST", {
|
|
1421
983
|
message: "Invalid SAML response",
|
|
1422
984
|
details: error instanceof Error ? error.message : String(error)
|
|
1423
985
|
});
|
|
1424
986
|
}
|
|
1425
987
|
const { extract } = parsedResponse;
|
|
1426
|
-
const attributes = extract.attributes
|
|
1427
|
-
const mapping = parsedSamlConfig
|
|
988
|
+
const attributes = parsedResponse.extract.attributes;
|
|
989
|
+
const mapping = parsedSamlConfig?.mapping ?? {};
|
|
1428
990
|
const userInfo = {
|
|
1429
991
|
...Object.fromEntries(
|
|
1430
992
|
Object.entries(mapping.extraFields || {}).map(([key, value]) => [
|
|
1431
993
|
key,
|
|
1432
|
-
attributes[value]
|
|
994
|
+
extract.attributes[value]
|
|
1433
995
|
])
|
|
1434
996
|
),
|
|
1435
|
-
id: attributes[mapping.id || "nameID"]
|
|
1436
|
-
email: attributes[mapping.email || "
|
|
997
|
+
id: attributes[mapping.id] || attributes["nameID"],
|
|
998
|
+
email: attributes[mapping.email] || attributes["nameID"] || attributes["email"],
|
|
1437
999
|
name: [
|
|
1438
|
-
attributes[mapping.firstName || "givenName"],
|
|
1439
|
-
attributes[mapping.lastName || "surname"]
|
|
1440
|
-
].filter(Boolean).join(" ") ||
|
|
1441
|
-
|
|
1000
|
+
attributes[mapping.firstName] || attributes["givenName"],
|
|
1001
|
+
attributes[mapping.lastName] || attributes["surname"]
|
|
1002
|
+
].filter(Boolean).join(" ") || parsedResponse.extract.attributes?.displayName,
|
|
1003
|
+
attributes: parsedResponse.extract.attributes,
|
|
1004
|
+
emailVerified: options?.trustEmailVerified ? attributes?.[mapping.emailVerified] || false : false
|
|
1442
1005
|
};
|
|
1443
|
-
if (!userInfo.id || !userInfo.email) {
|
|
1444
|
-
ctx.context.logger.error(
|
|
1445
|
-
"Missing essential user info from SAML response",
|
|
1446
|
-
{
|
|
1447
|
-
attributes: Object.keys(attributes),
|
|
1448
|
-
mapping,
|
|
1449
|
-
extractedId: userInfo.id,
|
|
1450
|
-
extractedEmail: userInfo.email
|
|
1451
|
-
}
|
|
1452
|
-
);
|
|
1453
|
-
throw new api.APIError("BAD_REQUEST", {
|
|
1454
|
-
message: "Unable to extract user ID or email from SAML response"
|
|
1455
|
-
});
|
|
1456
|
-
}
|
|
1457
1006
|
let user;
|
|
1458
1007
|
const existingUser = await ctx.context.adapter.findOne({
|
|
1459
1008
|
model: "user",
|
|
@@ -1465,7 +1014,7 @@ const sso = (options) => {
|
|
|
1465
1014
|
]
|
|
1466
1015
|
});
|
|
1467
1016
|
if (existingUser) {
|
|
1468
|
-
const
|
|
1017
|
+
const accounts = await ctx.context.adapter.findOne({
|
|
1469
1018
|
model: "account",
|
|
1470
1019
|
where: [
|
|
1471
1020
|
{ field: "userId", value: existingUser.id },
|
|
@@ -1473,7 +1022,7 @@ const sso = (options) => {
|
|
|
1473
1022
|
{ field: "accountId", value: userInfo.id }
|
|
1474
1023
|
]
|
|
1475
1024
|
});
|
|
1476
|
-
if (!
|
|
1025
|
+
if (!accounts) {
|
|
1477
1026
|
const isTrustedProvider = ctx.context.options.account?.accountLinking?.trustedProviders?.includes(
|
|
1478
1027
|
provider.providerId
|
|
1479
1028
|
);
|
|
@@ -1563,8 +1112,9 @@ const sso = (options) => {
|
|
|
1563
1112
|
}
|
|
1564
1113
|
let session = await ctx.context.internalAdapter.createSession(user.id, ctx);
|
|
1565
1114
|
await cookies.setSessionCookie(ctx, { session, user });
|
|
1566
|
-
|
|
1567
|
-
|
|
1115
|
+
throw ctx.redirect(
|
|
1116
|
+
RelayState || `${parsedSamlConfig.callbackUrl}` || `${parsedSamlConfig.issuer}`
|
|
1117
|
+
);
|
|
1568
1118
|
}
|
|
1569
1119
|
)
|
|
1570
1120
|
},
|