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