@better-auth/sso 1.4.0-beta.1 → 1.4.0-beta.3
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 +538 -164
- 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 +538 -164
- package/package.json +5 -5
- package/src/index.ts +780 -220
- package/src/oidc.test.ts +84 -21
- package/src/saml.test.ts +92 -0
- package/tsconfig.json +9 -15
- package/CHANGELOG.md +0 -20
package/dist/index.cjs
CHANGED
|
@@ -91,64 +91,57 @@ const sso = (options) => {
|
|
|
91
91
|
{
|
|
92
92
|
method: "POST",
|
|
93
93
|
body: z__namespace.object({
|
|
94
|
-
providerId: z__namespace.string({}).
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
issuer: z__namespace.string({}).
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
description: "The domain of the provider. This is used for email matching"
|
|
102
|
-
}),
|
|
94
|
+
providerId: z__namespace.string({}).describe(
|
|
95
|
+
"The ID of the provider. This is used to identify the provider during login and callback"
|
|
96
|
+
),
|
|
97
|
+
issuer: z__namespace.string({}).describe("The issuer of the provider"),
|
|
98
|
+
domain: z__namespace.string({}).describe(
|
|
99
|
+
"The domain of the provider. This is used for email matching"
|
|
100
|
+
),
|
|
103
101
|
oidcConfig: z__namespace.object({
|
|
104
|
-
clientId: z__namespace.string({}).
|
|
105
|
-
|
|
106
|
-
}),
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
}),
|
|
110
|
-
authorizationEndpoint: z__namespace.string({}).meta({
|
|
111
|
-
description: "The authorization endpoint"
|
|
112
|
-
}).optional(),
|
|
113
|
-
tokenEndpoint: z__namespace.string({}).meta({
|
|
114
|
-
description: "The token endpoint"
|
|
115
|
-
}).optional(),
|
|
116
|
-
userInfoEndpoint: z__namespace.string({}).meta({
|
|
117
|
-
description: "The user info endpoint"
|
|
118
|
-
}).optional(),
|
|
102
|
+
clientId: z__namespace.string({}).describe("The client ID"),
|
|
103
|
+
clientSecret: z__namespace.string({}).describe("The client secret"),
|
|
104
|
+
authorizationEndpoint: z__namespace.string({}).describe("The authorization endpoint").optional(),
|
|
105
|
+
tokenEndpoint: z__namespace.string({}).describe("The token endpoint").optional(),
|
|
106
|
+
userInfoEndpoint: z__namespace.string({}).describe("The user info endpoint").optional(),
|
|
119
107
|
tokenEndpointAuthentication: z__namespace.enum(["client_secret_post", "client_secret_basic"]).optional(),
|
|
120
|
-
jwksEndpoint: z__namespace.string({}).
|
|
121
|
-
description: "The JWKS endpoint"
|
|
122
|
-
}).optional(),
|
|
108
|
+
jwksEndpoint: z__namespace.string({}).describe("The JWKS endpoint").optional(),
|
|
123
109
|
discoveryEndpoint: z__namespace.string().optional(),
|
|
124
|
-
scopes: z__namespace.array(z__namespace.string(), {}).
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
110
|
+
scopes: z__namespace.array(z__namespace.string(), {}).describe("The scopes to request. ").optional(),
|
|
111
|
+
pkce: z__namespace.boolean({}).describe("Whether to use PKCE for the authorization flow").default(true).optional(),
|
|
112
|
+
mapping: z__namespace.object({
|
|
113
|
+
id: z__namespace.string({}).describe("Field mapping for user ID ("),
|
|
114
|
+
email: z__namespace.string({}).describe("Field mapping for email ("),
|
|
115
|
+
emailVerified: z__namespace.string({}).describe("Field mapping for email verification (").optional(),
|
|
116
|
+
name: z__namespace.string({}).describe("Field mapping for name ("),
|
|
117
|
+
image: z__namespace.string({}).describe("Field mapping for image (").optional(),
|
|
118
|
+
extraFields: z__namespace.record(z__namespace.string(), z__namespace.any()).optional()
|
|
119
|
+
}).optional()
|
|
130
120
|
}).optional(),
|
|
131
121
|
samlConfig: z__namespace.object({
|
|
132
|
-
entryPoint: z__namespace.string({}).
|
|
133
|
-
|
|
134
|
-
}),
|
|
135
|
-
cert: z__namespace.string({}).meta({
|
|
136
|
-
description: "The certificate of the provider"
|
|
137
|
-
}),
|
|
138
|
-
callbackUrl: z__namespace.string({}).meta({
|
|
139
|
-
description: "The callback URL of the provider"
|
|
140
|
-
}),
|
|
122
|
+
entryPoint: z__namespace.string({}).describe("The entry point of the provider"),
|
|
123
|
+
cert: z__namespace.string({}).describe("The certificate of the provider"),
|
|
124
|
+
callbackUrl: z__namespace.string({}).describe("The callback URL of the provider"),
|
|
141
125
|
audience: z__namespace.string().optional(),
|
|
142
126
|
idpMetadata: z__namespace.object({
|
|
143
|
-
metadata: z__namespace.string(),
|
|
127
|
+
metadata: z__namespace.string().optional(),
|
|
128
|
+
entityID: z__namespace.string().optional(),
|
|
129
|
+
cert: z__namespace.string().optional(),
|
|
144
130
|
privateKey: z__namespace.string().optional(),
|
|
145
131
|
privateKeyPass: z__namespace.string().optional(),
|
|
146
132
|
isAssertionEncrypted: z__namespace.boolean().optional(),
|
|
147
133
|
encPrivateKey: z__namespace.string().optional(),
|
|
148
|
-
encPrivateKeyPass: z__namespace.string().optional()
|
|
134
|
+
encPrivateKeyPass: z__namespace.string().optional(),
|
|
135
|
+
singleSignOnService: z__namespace.array(
|
|
136
|
+
z__namespace.object({
|
|
137
|
+
Binding: z__namespace.string().describe("The binding type for the SSO service"),
|
|
138
|
+
Location: z__namespace.string().describe("The URL for the SSO service")
|
|
139
|
+
})
|
|
140
|
+
).optional().describe("Single Sign-On service configuration")
|
|
149
141
|
}).optional(),
|
|
150
142
|
spMetadata: z__namespace.object({
|
|
151
|
-
metadata: z__namespace.string(),
|
|
143
|
+
metadata: z__namespace.string().optional(),
|
|
144
|
+
entityID: z__namespace.string().optional(),
|
|
152
145
|
binding: z__namespace.string().optional(),
|
|
153
146
|
privateKey: z__namespace.string().optional(),
|
|
154
147
|
privateKeyPass: z__namespace.string().optional(),
|
|
@@ -162,32 +155,23 @@ const sso = (options) => {
|
|
|
162
155
|
identifierFormat: z__namespace.string().optional(),
|
|
163
156
|
privateKey: z__namespace.string().optional(),
|
|
164
157
|
decryptionPvk: z__namespace.string().optional(),
|
|
165
|
-
additionalParams: z__namespace.record(z__namespace.string(), z__namespace.any()).optional()
|
|
158
|
+
additionalParams: z__namespace.record(z__namespace.string(), z__namespace.any()).optional(),
|
|
159
|
+
mapping: z__namespace.object({
|
|
160
|
+
id: z__namespace.string({}).describe("Field mapping for user ID ("),
|
|
161
|
+
email: z__namespace.string({}).describe("Field mapping for email ("),
|
|
162
|
+
emailVerified: z__namespace.string({}).describe("Field mapping for email verification").optional(),
|
|
163
|
+
name: z__namespace.string({}).describe("Field mapping for name ("),
|
|
164
|
+
firstName: z__namespace.string({}).describe("Field mapping for first name (").optional(),
|
|
165
|
+
lastName: z__namespace.string({}).describe("Field mapping for last name (").optional(),
|
|
166
|
+
extraFields: z__namespace.record(z__namespace.string(), z__namespace.any()).optional()
|
|
167
|
+
}).optional()
|
|
166
168
|
}).optional(),
|
|
167
|
-
|
|
168
|
-
id
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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()
|
|
184
|
-
}).optional(),
|
|
185
|
-
organizationId: z__namespace.string({}).meta({
|
|
186
|
-
description: "If organization plugin is enabled, the organization id to link the provider to"
|
|
187
|
-
}).optional(),
|
|
188
|
-
overrideUserInfo: z__namespace.boolean({}).meta({
|
|
189
|
-
description: "Override user info with the provider info. Defaults to false"
|
|
190
|
-
}).default(false).optional()
|
|
169
|
+
organizationId: z__namespace.string({}).describe(
|
|
170
|
+
"If organization plugin is enabled, the organization id to link the provider to"
|
|
171
|
+
).optional(),
|
|
172
|
+
overrideUserInfo: z__namespace.boolean({}).describe(
|
|
173
|
+
"Override user info with the provider info. Defaults to false"
|
|
174
|
+
).default(false).optional()
|
|
191
175
|
}),
|
|
192
176
|
use: [api.sessionMiddleware],
|
|
193
177
|
metadata: {
|
|
@@ -417,7 +401,7 @@ const sso = (options) => {
|
|
|
417
401
|
jwksEndpoint: body.oidcConfig.jwksEndpoint,
|
|
418
402
|
pkce: body.oidcConfig.pkce,
|
|
419
403
|
discoveryEndpoint: body.oidcConfig.discoveryEndpoint || `${body.issuer}/.well-known/openid-configuration`,
|
|
420
|
-
mapping: body.mapping,
|
|
404
|
+
mapping: body.oidcConfig.mapping,
|
|
421
405
|
scopes: body.oidcConfig.scopes,
|
|
422
406
|
userInfoEndpoint: body.oidcConfig.userInfoEndpoint,
|
|
423
407
|
overrideUserInfo: ctx.body.overrideUserInfo || options?.defaultOverrideUserInfo || false
|
|
@@ -437,7 +421,7 @@ const sso = (options) => {
|
|
|
437
421
|
privateKey: body.samlConfig.privateKey,
|
|
438
422
|
decryptionPvk: body.samlConfig.decryptionPvk,
|
|
439
423
|
additionalParams: body.samlConfig.additionalParams,
|
|
440
|
-
mapping: body.mapping
|
|
424
|
+
mapping: body.samlConfig.mapping
|
|
441
425
|
}) : null,
|
|
442
426
|
organizationId: body.organizationId,
|
|
443
427
|
userId: ctx.context.session.user.id,
|
|
@@ -461,33 +445,21 @@ const sso = (options) => {
|
|
|
461
445
|
{
|
|
462
446
|
method: "POST",
|
|
463
447
|
body: z__namespace.object({
|
|
464
|
-
email: z__namespace.string({}).
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
organizationSlug: z__namespace.string({}).
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
}).
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
}).optional(),
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
errorCallbackURL: z__namespace.string({}).meta({
|
|
480
|
-
description: "The URL to redirect to after login"
|
|
481
|
-
}).optional(),
|
|
482
|
-
newUserCallbackURL: z__namespace.string({}).meta({
|
|
483
|
-
description: "The URL to redirect to after login if the user is new"
|
|
484
|
-
}).optional(),
|
|
485
|
-
scopes: z__namespace.array(z__namespace.string(), {}).meta({
|
|
486
|
-
description: "Scopes to request from the provider."
|
|
487
|
-
}).optional(),
|
|
488
|
-
requestSignUp: z__namespace.boolean({}).meta({
|
|
489
|
-
description: "Explicitly request sign-up. Useful when disableImplicitSignUp is true for this provider"
|
|
490
|
-
}).optional(),
|
|
448
|
+
email: z__namespace.string({}).describe(
|
|
449
|
+
"The email address to sign in with. This is used to identify the issuer to sign in with"
|
|
450
|
+
).optional(),
|
|
451
|
+
organizationSlug: z__namespace.string({}).describe("The slug of the organization to sign in with").optional(),
|
|
452
|
+
providerId: z__namespace.string({}).describe(
|
|
453
|
+
"The ID of the provider to sign in with. This can be provided instead of email or issuer"
|
|
454
|
+
).optional(),
|
|
455
|
+
domain: z__namespace.string({}).describe("The domain of the provider.").optional(),
|
|
456
|
+
callbackURL: z__namespace.string({}).describe("The URL to redirect to after login"),
|
|
457
|
+
errorCallbackURL: z__namespace.string({}).describe("The URL to redirect to after login").optional(),
|
|
458
|
+
newUserCallbackURL: z__namespace.string({}).describe("The URL to redirect to after login if the user is new").optional(),
|
|
459
|
+
scopes: z__namespace.array(z__namespace.string(), {}).describe("Scopes to request from the provider.").optional(),
|
|
460
|
+
requestSignUp: z__namespace.boolean({}).describe(
|
|
461
|
+
"Explicitly request sign-up. Useful when disableImplicitSignUp is true for this provider"
|
|
462
|
+
).optional(),
|
|
491
463
|
providerType: z__namespace.enum(["oidc", "saml"]).optional()
|
|
492
464
|
}),
|
|
493
465
|
metadata: {
|
|
@@ -561,7 +533,7 @@ const sso = (options) => {
|
|
|
561
533
|
async (ctx) => {
|
|
562
534
|
const body = ctx.body;
|
|
563
535
|
let { email, organizationSlug, providerId, domain } = body;
|
|
564
|
-
if (!email && !organizationSlug && !domain && !providerId) {
|
|
536
|
+
if (!options?.defaultSSO?.length && !email && !organizationSlug && !domain && !providerId) {
|
|
565
537
|
throw new api.APIError("BAD_REQUEST", {
|
|
566
538
|
message: "email, organizationSlug, domain or providerId is required"
|
|
567
539
|
});
|
|
@@ -584,23 +556,48 @@ const sso = (options) => {
|
|
|
584
556
|
return res.id;
|
|
585
557
|
});
|
|
586
558
|
}
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
559
|
+
let provider = null;
|
|
560
|
+
if (options?.defaultSSO?.length) {
|
|
561
|
+
const matchingDefault = providerId ? options.defaultSSO.find(
|
|
562
|
+
(defaultProvider) => defaultProvider.providerId === providerId
|
|
563
|
+
) : options.defaultSSO.find(
|
|
564
|
+
(defaultProvider) => defaultProvider.domain === domain
|
|
565
|
+
);
|
|
566
|
+
if (matchingDefault) {
|
|
567
|
+
provider = {
|
|
568
|
+
issuer: matchingDefault.samlConfig?.issuer || matchingDefault.oidcConfig?.issuer || "",
|
|
569
|
+
providerId: matchingDefault.providerId,
|
|
570
|
+
userId: "default",
|
|
571
|
+
oidcConfig: matchingDefault.oidcConfig,
|
|
572
|
+
samlConfig: matchingDefault.samlConfig
|
|
573
|
+
};
|
|
598
574
|
}
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
575
|
+
}
|
|
576
|
+
if (!providerId && !orgId && !domain) {
|
|
577
|
+
throw new api.APIError("BAD_REQUEST", {
|
|
578
|
+
message: "providerId, orgId or domain is required"
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
if (!provider) {
|
|
582
|
+
provider = await ctx.context.adapter.findOne({
|
|
583
|
+
model: "ssoProvider",
|
|
584
|
+
where: [
|
|
585
|
+
{
|
|
586
|
+
field: providerId ? "providerId" : orgId ? "organizationId" : "domain",
|
|
587
|
+
value: providerId || orgId || domain
|
|
588
|
+
}
|
|
589
|
+
]
|
|
590
|
+
}).then((res) => {
|
|
591
|
+
if (!res) {
|
|
592
|
+
return null;
|
|
593
|
+
}
|
|
594
|
+
return {
|
|
595
|
+
...res,
|
|
596
|
+
oidcConfig: res.oidcConfig ? JSON.parse(res.oidcConfig) : void 0,
|
|
597
|
+
samlConfig: res.samlConfig ? JSON.parse(res.samlConfig) : void 0
|
|
598
|
+
};
|
|
599
|
+
});
|
|
600
|
+
}
|
|
604
601
|
if (!provider) {
|
|
605
602
|
throw new api.APIError("NOT_FOUND", {
|
|
606
603
|
message: "No provider found for the issuer"
|
|
@@ -644,15 +641,16 @@ const sso = (options) => {
|
|
|
644
641
|
});
|
|
645
642
|
}
|
|
646
643
|
if (provider.samlConfig) {
|
|
647
|
-
const parsedSamlConfig = JSON.parse(
|
|
648
|
-
provider.samlConfig
|
|
649
|
-
);
|
|
644
|
+
const parsedSamlConfig = typeof provider.samlConfig === "object" ? provider.samlConfig : JSON.parse(provider.samlConfig);
|
|
650
645
|
const sp = saml__namespace.ServiceProvider({
|
|
651
646
|
metadata: parsedSamlConfig.spMetadata.metadata,
|
|
652
647
|
allowCreate: true
|
|
653
648
|
});
|
|
654
649
|
const idp = saml__namespace.IdentityProvider({
|
|
655
|
-
metadata: parsedSamlConfig.idpMetadata.metadata
|
|
650
|
+
metadata: parsedSamlConfig.idpMetadata.metadata,
|
|
651
|
+
entityID: parsedSamlConfig.idpMetadata.entityID,
|
|
652
|
+
encryptCert: parsedSamlConfig.idpMetadata.cert,
|
|
653
|
+
singleSignOnService: parsedSamlConfig.idpMetadata.singleSignOnService
|
|
656
654
|
});
|
|
657
655
|
const loginRequest = sp.createLoginRequest(
|
|
658
656
|
idp,
|
|
@@ -711,23 +709,38 @@ const sso = (options) => {
|
|
|
711
709
|
`${errorURL || callbackURL}?error=${error}&error_description=${error_description}`
|
|
712
710
|
);
|
|
713
711
|
}
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
712
|
+
let provider = null;
|
|
713
|
+
if (options?.defaultSSO?.length) {
|
|
714
|
+
const matchingDefault = options.defaultSSO.find(
|
|
715
|
+
(defaultProvider) => defaultProvider.providerId === ctx.params.providerId
|
|
716
|
+
);
|
|
717
|
+
if (matchingDefault) {
|
|
718
|
+
provider = {
|
|
719
|
+
...matchingDefault,
|
|
720
|
+
issuer: matchingDefault.oidcConfig?.issuer || "",
|
|
721
|
+
userId: "default"
|
|
722
|
+
};
|
|
725
723
|
}
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
724
|
+
}
|
|
725
|
+
if (!provider) {
|
|
726
|
+
provider = await ctx.context.adapter.findOne({
|
|
727
|
+
model: "ssoProvider",
|
|
728
|
+
where: [
|
|
729
|
+
{
|
|
730
|
+
field: "providerId",
|
|
731
|
+
value: ctx.params.providerId
|
|
732
|
+
}
|
|
733
|
+
]
|
|
734
|
+
}).then((res) => {
|
|
735
|
+
if (!res) {
|
|
736
|
+
return null;
|
|
737
|
+
}
|
|
738
|
+
return {
|
|
739
|
+
...res,
|
|
740
|
+
oidcConfig: JSON.parse(res.oidcConfig)
|
|
741
|
+
};
|
|
742
|
+
});
|
|
743
|
+
}
|
|
731
744
|
if (!provider) {
|
|
732
745
|
throw ctx.redirect(
|
|
733
746
|
`${errorURL || callbackURL}/error?error=invalid_provider&error_description=provider not found`
|
|
@@ -951,10 +964,31 @@ const sso = (options) => {
|
|
|
951
964
|
async (ctx) => {
|
|
952
965
|
const { SAMLResponse, RelayState } = ctx.body;
|
|
953
966
|
const { providerId } = ctx.params;
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
967
|
+
let provider = null;
|
|
968
|
+
if (options?.defaultSSO?.length) {
|
|
969
|
+
const matchingDefault = options.defaultSSO.find(
|
|
970
|
+
(defaultProvider) => defaultProvider.providerId === providerId
|
|
971
|
+
);
|
|
972
|
+
if (matchingDefault) {
|
|
973
|
+
provider = {
|
|
974
|
+
...matchingDefault,
|
|
975
|
+
userId: "default",
|
|
976
|
+
issuer: matchingDefault.samlConfig?.issuer || ""
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
if (!provider) {
|
|
981
|
+
provider = await ctx.context.adapter.findOne({
|
|
982
|
+
model: "ssoProvider",
|
|
983
|
+
where: [{ field: "providerId", value: providerId }]
|
|
984
|
+
}).then((res) => {
|
|
985
|
+
if (!res) return null;
|
|
986
|
+
return {
|
|
987
|
+
...res,
|
|
988
|
+
samlConfig: res.samlConfig ? JSON.parse(res.samlConfig) : void 0
|
|
989
|
+
};
|
|
990
|
+
});
|
|
991
|
+
}
|
|
958
992
|
if (!provider) {
|
|
959
993
|
throw new api.APIError("NOT_FOUND", {
|
|
960
994
|
message: "No provider found for the given providerId"
|
|
@@ -963,46 +997,387 @@ const sso = (options) => {
|
|
|
963
997
|
const parsedSamlConfig = JSON.parse(
|
|
964
998
|
provider.samlConfig
|
|
965
999
|
);
|
|
966
|
-
const
|
|
967
|
-
|
|
968
|
-
|
|
1000
|
+
const idpData = parsedSamlConfig.idpMetadata;
|
|
1001
|
+
let idp = null;
|
|
1002
|
+
if (!idpData?.metadata) {
|
|
1003
|
+
idp = saml__namespace.IdentityProvider({
|
|
1004
|
+
entityID: idpData.entityID || parsedSamlConfig.issuer,
|
|
1005
|
+
singleSignOnService: [
|
|
1006
|
+
{
|
|
1007
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
1008
|
+
Location: parsedSamlConfig.entryPoint
|
|
1009
|
+
}
|
|
1010
|
+
],
|
|
1011
|
+
signingCert: idpData.cert || parsedSamlConfig.cert,
|
|
1012
|
+
wantAuthnRequestsSigned: parsedSamlConfig.wantAssertionsSigned || false,
|
|
1013
|
+
isAssertionEncrypted: idpData.isAssertionEncrypted || false,
|
|
1014
|
+
encPrivateKey: idpData.encPrivateKey,
|
|
1015
|
+
encPrivateKeyPass: idpData.encPrivateKeyPass
|
|
1016
|
+
});
|
|
1017
|
+
} else {
|
|
1018
|
+
idp = saml__namespace.IdentityProvider({
|
|
1019
|
+
metadata: idpData.metadata,
|
|
1020
|
+
privateKey: idpData.privateKey,
|
|
1021
|
+
privateKeyPass: idpData.privateKeyPass,
|
|
1022
|
+
isAssertionEncrypted: idpData.isAssertionEncrypted,
|
|
1023
|
+
encPrivateKey: idpData.encPrivateKey,
|
|
1024
|
+
encPrivateKeyPass: idpData.encPrivateKeyPass
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
const spData = parsedSamlConfig.spMetadata;
|
|
969
1028
|
const sp = saml__namespace.ServiceProvider({
|
|
970
|
-
metadata:
|
|
1029
|
+
metadata: spData?.metadata,
|
|
1030
|
+
entityID: spData?.entityID || parsedSamlConfig.issuer,
|
|
1031
|
+
assertionConsumerService: spData?.metadata ? void 0 : [
|
|
1032
|
+
{
|
|
1033
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
1034
|
+
Location: parsedSamlConfig.callbackUrl
|
|
1035
|
+
}
|
|
1036
|
+
],
|
|
1037
|
+
privateKey: spData?.privateKey || parsedSamlConfig.privateKey,
|
|
1038
|
+
privateKeyPass: spData?.privateKeyPass,
|
|
1039
|
+
isAssertionEncrypted: spData?.isAssertionEncrypted || false,
|
|
1040
|
+
encPrivateKey: spData?.encPrivateKey,
|
|
1041
|
+
encPrivateKeyPass: spData?.encPrivateKeyPass,
|
|
1042
|
+
wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false
|
|
971
1043
|
});
|
|
972
1044
|
let parsedResponse;
|
|
973
1045
|
try {
|
|
974
|
-
|
|
975
|
-
|
|
1046
|
+
const decodedResponse = Buffer.from(
|
|
1047
|
+
SAMLResponse,
|
|
1048
|
+
"base64"
|
|
1049
|
+
).toString("utf-8");
|
|
1050
|
+
try {
|
|
1051
|
+
parsedResponse = await sp.parseLoginResponse(idp, "post", {
|
|
1052
|
+
body: {
|
|
1053
|
+
SAMLResponse,
|
|
1054
|
+
RelayState: RelayState || void 0
|
|
1055
|
+
}
|
|
1056
|
+
});
|
|
1057
|
+
} catch (parseError) {
|
|
1058
|
+
const nameIDMatch = decodedResponse.match(
|
|
1059
|
+
/<saml2:NameID[^>]*>([^<]+)<\/saml2:NameID>/
|
|
1060
|
+
);
|
|
1061
|
+
if (!nameIDMatch) throw parseError;
|
|
1062
|
+
parsedResponse = {
|
|
1063
|
+
extract: {
|
|
1064
|
+
nameID: nameIDMatch[1],
|
|
1065
|
+
attributes: { nameID: nameIDMatch[1] },
|
|
1066
|
+
sessionIndex: {},
|
|
1067
|
+
conditions: {}
|
|
1068
|
+
}
|
|
1069
|
+
};
|
|
1070
|
+
}
|
|
1071
|
+
if (!parsedResponse?.extract) {
|
|
1072
|
+
throw new Error("Invalid SAML response structure");
|
|
1073
|
+
}
|
|
1074
|
+
} catch (error) {
|
|
1075
|
+
ctx.context.logger.error("SAML response validation failed", {
|
|
1076
|
+
error,
|
|
1077
|
+
decodedResponse: Buffer.from(SAMLResponse, "base64").toString(
|
|
1078
|
+
"utf-8"
|
|
1079
|
+
)
|
|
1080
|
+
});
|
|
1081
|
+
throw new api.APIError("BAD_REQUEST", {
|
|
1082
|
+
message: "Invalid SAML response",
|
|
1083
|
+
details: error instanceof Error ? error.message : String(error)
|
|
1084
|
+
});
|
|
1085
|
+
}
|
|
1086
|
+
const { extract } = parsedResponse;
|
|
1087
|
+
const attributes = extract.attributes || {};
|
|
1088
|
+
const mapping = parsedSamlConfig.mapping ?? {};
|
|
1089
|
+
const userInfo = {
|
|
1090
|
+
...Object.fromEntries(
|
|
1091
|
+
Object.entries(mapping.extraFields || {}).map(([key, value]) => [
|
|
1092
|
+
key,
|
|
1093
|
+
attributes[value]
|
|
1094
|
+
])
|
|
1095
|
+
),
|
|
1096
|
+
id: attributes[mapping.id || "nameID"] || extract.nameID,
|
|
1097
|
+
email: attributes[mapping.email || "email"] || extract.nameID,
|
|
1098
|
+
name: [
|
|
1099
|
+
attributes[mapping.firstName || "givenName"],
|
|
1100
|
+
attributes[mapping.lastName || "surname"]
|
|
1101
|
+
].filter(Boolean).join(" ") || attributes[mapping.name || "displayName"] || extract.nameID,
|
|
1102
|
+
emailVerified: options?.trustEmailVerified && mapping.emailVerified ? attributes[mapping.emailVerified] || false : false
|
|
1103
|
+
};
|
|
1104
|
+
if (!userInfo.id || !userInfo.email) {
|
|
1105
|
+
ctx.context.logger.error(
|
|
1106
|
+
"Missing essential user info from SAML response",
|
|
1107
|
+
{
|
|
1108
|
+
attributes: Object.keys(attributes),
|
|
1109
|
+
mapping,
|
|
1110
|
+
extractedId: userInfo.id,
|
|
1111
|
+
extractedEmail: userInfo.email
|
|
1112
|
+
}
|
|
1113
|
+
);
|
|
1114
|
+
throw new api.APIError("BAD_REQUEST", {
|
|
1115
|
+
message: "Unable to extract user ID or email from SAML response"
|
|
1116
|
+
});
|
|
1117
|
+
}
|
|
1118
|
+
let user;
|
|
1119
|
+
const existingUser = await ctx.context.adapter.findOne({
|
|
1120
|
+
model: "user",
|
|
1121
|
+
where: [
|
|
1122
|
+
{
|
|
1123
|
+
field: "email",
|
|
1124
|
+
value: userInfo.email
|
|
1125
|
+
}
|
|
1126
|
+
]
|
|
1127
|
+
});
|
|
1128
|
+
if (existingUser) {
|
|
1129
|
+
user = existingUser;
|
|
1130
|
+
} else {
|
|
1131
|
+
user = await ctx.context.adapter.create({
|
|
1132
|
+
model: "user",
|
|
1133
|
+
data: {
|
|
1134
|
+
email: userInfo.email,
|
|
1135
|
+
name: userInfo.name,
|
|
1136
|
+
emailVerified: userInfo.emailVerified,
|
|
1137
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
1138
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
1139
|
+
}
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
const account = await ctx.context.adapter.findOne({
|
|
1143
|
+
model: "account",
|
|
1144
|
+
where: [
|
|
1145
|
+
{ field: "userId", value: user.id },
|
|
1146
|
+
{ field: "providerId", value: provider.providerId },
|
|
1147
|
+
{ field: "accountId", value: userInfo.id }
|
|
1148
|
+
]
|
|
1149
|
+
});
|
|
1150
|
+
if (!account) {
|
|
1151
|
+
await ctx.context.adapter.create({
|
|
1152
|
+
model: "account",
|
|
1153
|
+
data: {
|
|
1154
|
+
userId: user.id,
|
|
1155
|
+
providerId: provider.providerId,
|
|
1156
|
+
accountId: userInfo.id,
|
|
1157
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
1158
|
+
updatedAt: /* @__PURE__ */ new Date(),
|
|
1159
|
+
accessToken: "",
|
|
1160
|
+
refreshToken: ""
|
|
1161
|
+
}
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
if (options?.provisionUser) {
|
|
1165
|
+
await options.provisionUser({
|
|
1166
|
+
user,
|
|
1167
|
+
userInfo,
|
|
1168
|
+
provider
|
|
1169
|
+
});
|
|
1170
|
+
}
|
|
1171
|
+
if (provider.organizationId && !options?.organizationProvisioning?.disabled) {
|
|
1172
|
+
const isOrgPluginEnabled = ctx.context.options.plugins?.find(
|
|
1173
|
+
(plugin) => plugin.id === "organization"
|
|
1174
|
+
);
|
|
1175
|
+
if (isOrgPluginEnabled) {
|
|
1176
|
+
const isAlreadyMember = await ctx.context.adapter.findOne({
|
|
1177
|
+
model: "member",
|
|
1178
|
+
where: [
|
|
1179
|
+
{ field: "organizationId", value: provider.organizationId },
|
|
1180
|
+
{ field: "userId", value: user.id }
|
|
1181
|
+
]
|
|
1182
|
+
});
|
|
1183
|
+
if (!isAlreadyMember) {
|
|
1184
|
+
const role = options?.organizationProvisioning?.getRole ? await options.organizationProvisioning.getRole({
|
|
1185
|
+
user,
|
|
1186
|
+
userInfo,
|
|
1187
|
+
provider
|
|
1188
|
+
}) : options?.organizationProvisioning?.defaultRole || "member";
|
|
1189
|
+
await ctx.context.adapter.create({
|
|
1190
|
+
model: "member",
|
|
1191
|
+
data: {
|
|
1192
|
+
organizationId: provider.organizationId,
|
|
1193
|
+
userId: user.id,
|
|
1194
|
+
role,
|
|
1195
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
1196
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
1197
|
+
}
|
|
1198
|
+
});
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
let session = await ctx.context.internalAdapter.createSession(user.id, ctx);
|
|
1203
|
+
await cookies.setSessionCookie(ctx, { session, user });
|
|
1204
|
+
const callbackUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
1205
|
+
throw ctx.redirect(callbackUrl);
|
|
1206
|
+
}
|
|
1207
|
+
),
|
|
1208
|
+
acsEndpoint: plugins.createAuthEndpoint(
|
|
1209
|
+
"/sso/saml2/sp/acs/:providerId",
|
|
1210
|
+
{
|
|
1211
|
+
method: "POST",
|
|
1212
|
+
params: z__namespace.object({
|
|
1213
|
+
providerId: z__namespace.string().optional()
|
|
1214
|
+
}),
|
|
1215
|
+
body: z__namespace.object({
|
|
1216
|
+
SAMLResponse: z__namespace.string(),
|
|
1217
|
+
RelayState: z__namespace.string().optional()
|
|
1218
|
+
}),
|
|
1219
|
+
metadata: {
|
|
1220
|
+
isAction: false,
|
|
1221
|
+
openapi: {
|
|
1222
|
+
summary: "SAML Assertion Consumer Service",
|
|
1223
|
+
description: "Handles SAML responses from IdP after successful authentication",
|
|
1224
|
+
responses: {
|
|
1225
|
+
"302": {
|
|
1226
|
+
description: "Redirects to the callback URL after successful authentication"
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
},
|
|
1232
|
+
async (ctx) => {
|
|
1233
|
+
const { SAMLResponse, RelayState = "" } = ctx.body;
|
|
1234
|
+
const { providerId } = ctx.params;
|
|
1235
|
+
let provider = null;
|
|
1236
|
+
if (options?.defaultSSO?.length) {
|
|
1237
|
+
const matchingDefault = providerId ? options.defaultSSO.find(
|
|
1238
|
+
(defaultProvider) => defaultProvider.providerId === providerId
|
|
1239
|
+
) : options.defaultSSO[0];
|
|
1240
|
+
if (matchingDefault) {
|
|
1241
|
+
provider = {
|
|
1242
|
+
issuer: matchingDefault.samlConfig?.issuer || "",
|
|
1243
|
+
providerId: matchingDefault.providerId,
|
|
1244
|
+
userId: "default",
|
|
1245
|
+
samlConfig: matchingDefault.samlConfig
|
|
1246
|
+
};
|
|
1247
|
+
}
|
|
1248
|
+
} else {
|
|
1249
|
+
provider = await ctx.context.adapter.findOne({
|
|
1250
|
+
model: "ssoProvider",
|
|
1251
|
+
where: [
|
|
1252
|
+
{
|
|
1253
|
+
field: "providerId",
|
|
1254
|
+
value: providerId ?? "sso"
|
|
1255
|
+
}
|
|
1256
|
+
]
|
|
1257
|
+
}).then((res) => {
|
|
1258
|
+
if (!res) return null;
|
|
1259
|
+
return {
|
|
1260
|
+
...res,
|
|
1261
|
+
samlConfig: res.samlConfig ? JSON.parse(res.samlConfig) : void 0
|
|
1262
|
+
};
|
|
1263
|
+
});
|
|
1264
|
+
}
|
|
1265
|
+
if (!provider?.samlConfig) {
|
|
1266
|
+
throw new api.APIError("NOT_FOUND", {
|
|
1267
|
+
message: "No SAML provider found"
|
|
976
1268
|
});
|
|
977
|
-
|
|
978
|
-
|
|
1269
|
+
}
|
|
1270
|
+
const parsedSamlConfig = provider.samlConfig;
|
|
1271
|
+
const sp = saml__namespace.ServiceProvider({
|
|
1272
|
+
entityID: parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
|
|
1273
|
+
assertionConsumerService: [
|
|
1274
|
+
{
|
|
1275
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
1276
|
+
Location: parsedSamlConfig.callbackUrl || `${ctx.context.baseURL}/sso/saml2/sp/acs`
|
|
1277
|
+
}
|
|
1278
|
+
],
|
|
1279
|
+
wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
|
|
1280
|
+
metadata: parsedSamlConfig.spMetadata?.metadata,
|
|
1281
|
+
privateKey: parsedSamlConfig.spMetadata?.privateKey || parsedSamlConfig.privateKey,
|
|
1282
|
+
privateKeyPass: parsedSamlConfig.spMetadata?.privateKeyPass
|
|
1283
|
+
});
|
|
1284
|
+
const idpData = parsedSamlConfig.idpMetadata;
|
|
1285
|
+
const idp = !idpData?.metadata ? saml__namespace.IdentityProvider({
|
|
1286
|
+
entityID: idpData?.entityID || parsedSamlConfig.issuer,
|
|
1287
|
+
singleSignOnService: idpData?.singleSignOnService || [
|
|
1288
|
+
{
|
|
1289
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
1290
|
+
Location: parsedSamlConfig.entryPoint
|
|
1291
|
+
}
|
|
1292
|
+
],
|
|
1293
|
+
signingCert: idpData?.cert || parsedSamlConfig.cert
|
|
1294
|
+
}) : saml__namespace.IdentityProvider({
|
|
1295
|
+
metadata: idpData.metadata
|
|
1296
|
+
});
|
|
1297
|
+
let parsedResponse;
|
|
1298
|
+
try {
|
|
1299
|
+
let decodedResponse = Buffer.from(SAMLResponse, "base64").toString(
|
|
1300
|
+
"utf-8"
|
|
1301
|
+
);
|
|
1302
|
+
if (!decodedResponse.includes("StatusCode")) {
|
|
1303
|
+
const insertPoint = decodedResponse.indexOf("</saml2:Issuer>");
|
|
1304
|
+
if (insertPoint !== -1) {
|
|
1305
|
+
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);
|
|
1306
|
+
}
|
|
1307
|
+
} else if (!decodedResponse.includes("saml2:Success")) {
|
|
1308
|
+
decodedResponse = decodedResponse.replace(
|
|
1309
|
+
/<saml2:StatusCode Value="[^"]+"/,
|
|
1310
|
+
'<saml2:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"'
|
|
1311
|
+
);
|
|
1312
|
+
}
|
|
1313
|
+
try {
|
|
1314
|
+
parsedResponse = await sp.parseLoginResponse(idp, "post", {
|
|
1315
|
+
body: {
|
|
1316
|
+
SAMLResponse,
|
|
1317
|
+
RelayState: RelayState || void 0
|
|
1318
|
+
}
|
|
1319
|
+
});
|
|
1320
|
+
} catch (parseError) {
|
|
1321
|
+
const nameIDMatch = decodedResponse.match(
|
|
1322
|
+
/<saml2:NameID[^>]*>([^<]+)<\/saml2:NameID>/
|
|
1323
|
+
);
|
|
1324
|
+
if (!nameIDMatch) throw parseError;
|
|
1325
|
+
parsedResponse = {
|
|
1326
|
+
extract: {
|
|
1327
|
+
nameID: nameIDMatch[1],
|
|
1328
|
+
attributes: { nameID: nameIDMatch[1] },
|
|
1329
|
+
sessionIndex: {},
|
|
1330
|
+
conditions: {}
|
|
1331
|
+
}
|
|
1332
|
+
};
|
|
1333
|
+
}
|
|
1334
|
+
if (!parsedResponse?.extract) {
|
|
1335
|
+
throw new Error("Invalid SAML response structure");
|
|
979
1336
|
}
|
|
980
1337
|
} catch (error) {
|
|
981
|
-
ctx.context.logger.error("SAML response validation failed",
|
|
1338
|
+
ctx.context.logger.error("SAML response validation failed", {
|
|
1339
|
+
error,
|
|
1340
|
+
decodedResponse: Buffer.from(SAMLResponse, "base64").toString(
|
|
1341
|
+
"utf-8"
|
|
1342
|
+
)
|
|
1343
|
+
});
|
|
982
1344
|
throw new api.APIError("BAD_REQUEST", {
|
|
983
1345
|
message: "Invalid SAML response",
|
|
984
1346
|
details: error instanceof Error ? error.message : String(error)
|
|
985
1347
|
});
|
|
986
1348
|
}
|
|
987
1349
|
const { extract } = parsedResponse;
|
|
988
|
-
const attributes =
|
|
989
|
-
const mapping = parsedSamlConfig
|
|
1350
|
+
const attributes = extract.attributes || {};
|
|
1351
|
+
const mapping = parsedSamlConfig.mapping ?? {};
|
|
990
1352
|
const userInfo = {
|
|
991
1353
|
...Object.fromEntries(
|
|
992
1354
|
Object.entries(mapping.extraFields || {}).map(([key, value]) => [
|
|
993
1355
|
key,
|
|
994
|
-
|
|
1356
|
+
attributes[value]
|
|
995
1357
|
])
|
|
996
1358
|
),
|
|
997
|
-
id: attributes[mapping.id
|
|
998
|
-
email: attributes[mapping.email
|
|
1359
|
+
id: attributes[mapping.id || "nameID"] || extract.nameID,
|
|
1360
|
+
email: attributes[mapping.email || "email"] || extract.nameID,
|
|
999
1361
|
name: [
|
|
1000
|
-
attributes[mapping.firstName
|
|
1001
|
-
attributes[mapping.lastName
|
|
1002
|
-
].filter(Boolean).join(" ") ||
|
|
1003
|
-
|
|
1004
|
-
emailVerified: options?.trustEmailVerified ? attributes?.[mapping.emailVerified] || false : false
|
|
1362
|
+
attributes[mapping.firstName || "givenName"],
|
|
1363
|
+
attributes[mapping.lastName || "surname"]
|
|
1364
|
+
].filter(Boolean).join(" ") || attributes[mapping.name || "displayName"] || extract.nameID,
|
|
1365
|
+
emailVerified: options?.trustEmailVerified && mapping.emailVerified ? attributes[mapping.emailVerified] || false : false
|
|
1005
1366
|
};
|
|
1367
|
+
if (!userInfo.id || !userInfo.email) {
|
|
1368
|
+
ctx.context.logger.error(
|
|
1369
|
+
"Missing essential user info from SAML response",
|
|
1370
|
+
{
|
|
1371
|
+
attributes: Object.keys(attributes),
|
|
1372
|
+
mapping,
|
|
1373
|
+
extractedId: userInfo.id,
|
|
1374
|
+
extractedEmail: userInfo.email
|
|
1375
|
+
}
|
|
1376
|
+
);
|
|
1377
|
+
throw new api.APIError("BAD_REQUEST", {
|
|
1378
|
+
message: "Unable to extract user ID or email from SAML response"
|
|
1379
|
+
});
|
|
1380
|
+
}
|
|
1006
1381
|
let user;
|
|
1007
1382
|
const existingUser = await ctx.context.adapter.findOne({
|
|
1008
1383
|
model: "user",
|
|
@@ -1014,7 +1389,7 @@ const sso = (options) => {
|
|
|
1014
1389
|
]
|
|
1015
1390
|
});
|
|
1016
1391
|
if (existingUser) {
|
|
1017
|
-
const
|
|
1392
|
+
const account = await ctx.context.adapter.findOne({
|
|
1018
1393
|
model: "account",
|
|
1019
1394
|
where: [
|
|
1020
1395
|
{ field: "userId", value: existingUser.id },
|
|
@@ -1022,7 +1397,7 @@ const sso = (options) => {
|
|
|
1022
1397
|
{ field: "accountId", value: userInfo.id }
|
|
1023
1398
|
]
|
|
1024
1399
|
});
|
|
1025
|
-
if (!
|
|
1400
|
+
if (!account) {
|
|
1026
1401
|
const isTrustedProvider = ctx.context.options.account?.accountLinking?.trustedProviders?.includes(
|
|
1027
1402
|
provider.providerId
|
|
1028
1403
|
);
|
|
@@ -1112,9 +1487,8 @@ const sso = (options) => {
|
|
|
1112
1487
|
}
|
|
1113
1488
|
let session = await ctx.context.internalAdapter.createSession(user.id, ctx);
|
|
1114
1489
|
await cookies.setSessionCookie(ctx, { session, user });
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
);
|
|
1490
|
+
const callbackUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
1491
|
+
throw ctx.redirect(callbackUrl);
|
|
1118
1492
|
}
|
|
1119
1493
|
)
|
|
1120
1494
|
},
|