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