@better-auth/sso 1.3.13 → 1.3.15
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.mjs
CHANGED
|
@@ -74,64 +74,57 @@ const sso = (options) => {
|
|
|
74
74
|
{
|
|
75
75
|
method: "POST",
|
|
76
76
|
body: z.object({
|
|
77
|
-
providerId: z.string({}).
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
issuer: z.string({}).
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
description: "The domain of the provider. This is used for email matching"
|
|
85
|
-
}),
|
|
77
|
+
providerId: z.string({}).describe(
|
|
78
|
+
"The ID of the provider. This is used to identify the provider during login and callback"
|
|
79
|
+
),
|
|
80
|
+
issuer: z.string({}).describe("The issuer of the provider"),
|
|
81
|
+
domain: z.string({}).describe(
|
|
82
|
+
"The domain of the provider. This is used for email matching"
|
|
83
|
+
),
|
|
86
84
|
oidcConfig: z.object({
|
|
87
|
-
clientId: z.string({}).
|
|
88
|
-
|
|
89
|
-
}),
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}),
|
|
93
|
-
authorizationEndpoint: z.string({}).meta({
|
|
94
|
-
description: "The authorization endpoint"
|
|
95
|
-
}).optional(),
|
|
96
|
-
tokenEndpoint: z.string({}).meta({
|
|
97
|
-
description: "The token endpoint"
|
|
98
|
-
}).optional(),
|
|
99
|
-
userInfoEndpoint: z.string({}).meta({
|
|
100
|
-
description: "The user info endpoint"
|
|
101
|
-
}).optional(),
|
|
85
|
+
clientId: z.string({}).describe("The client ID"),
|
|
86
|
+
clientSecret: z.string({}).describe("The client secret"),
|
|
87
|
+
authorizationEndpoint: z.string({}).describe("The authorization endpoint").optional(),
|
|
88
|
+
tokenEndpoint: z.string({}).describe("The token endpoint").optional(),
|
|
89
|
+
userInfoEndpoint: z.string({}).describe("The user info endpoint").optional(),
|
|
102
90
|
tokenEndpointAuthentication: z.enum(["client_secret_post", "client_secret_basic"]).optional(),
|
|
103
|
-
jwksEndpoint: z.string({}).
|
|
104
|
-
description: "The JWKS endpoint"
|
|
105
|
-
}).optional(),
|
|
91
|
+
jwksEndpoint: z.string({}).describe("The JWKS endpoint").optional(),
|
|
106
92
|
discoveryEndpoint: z.string().optional(),
|
|
107
|
-
scopes: z.array(z.string(), {}).
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
93
|
+
scopes: z.array(z.string(), {}).describe("The scopes to request. ").optional(),
|
|
94
|
+
pkce: z.boolean({}).describe("Whether to use PKCE for the authorization flow").default(true).optional(),
|
|
95
|
+
mapping: z.object({
|
|
96
|
+
id: z.string({}).describe("Field mapping for user ID ("),
|
|
97
|
+
email: z.string({}).describe("Field mapping for email ("),
|
|
98
|
+
emailVerified: z.string({}).describe("Field mapping for email verification (").optional(),
|
|
99
|
+
name: z.string({}).describe("Field mapping for name ("),
|
|
100
|
+
image: z.string({}).describe("Field mapping for image (").optional(),
|
|
101
|
+
extraFields: z.record(z.string(), z.any()).optional()
|
|
102
|
+
}).optional()
|
|
113
103
|
}).optional(),
|
|
114
104
|
samlConfig: z.object({
|
|
115
|
-
entryPoint: z.string({}).
|
|
116
|
-
|
|
117
|
-
}),
|
|
118
|
-
cert: z.string({}).meta({
|
|
119
|
-
description: "The certificate of the provider"
|
|
120
|
-
}),
|
|
121
|
-
callbackUrl: z.string({}).meta({
|
|
122
|
-
description: "The callback URL of the provider"
|
|
123
|
-
}),
|
|
105
|
+
entryPoint: z.string({}).describe("The entry point of the provider"),
|
|
106
|
+
cert: z.string({}).describe("The certificate of the provider"),
|
|
107
|
+
callbackUrl: z.string({}).describe("The callback URL of the provider"),
|
|
124
108
|
audience: z.string().optional(),
|
|
125
109
|
idpMetadata: z.object({
|
|
126
|
-
metadata: z.string(),
|
|
110
|
+
metadata: z.string().optional(),
|
|
111
|
+
entityID: z.string().optional(),
|
|
112
|
+
cert: z.string().optional(),
|
|
127
113
|
privateKey: z.string().optional(),
|
|
128
114
|
privateKeyPass: z.string().optional(),
|
|
129
115
|
isAssertionEncrypted: z.boolean().optional(),
|
|
130
116
|
encPrivateKey: z.string().optional(),
|
|
131
|
-
encPrivateKeyPass: z.string().optional()
|
|
117
|
+
encPrivateKeyPass: z.string().optional(),
|
|
118
|
+
singleSignOnService: z.array(
|
|
119
|
+
z.object({
|
|
120
|
+
Binding: z.string().describe("The binding type for the SSO service"),
|
|
121
|
+
Location: z.string().describe("The URL for the SSO service")
|
|
122
|
+
})
|
|
123
|
+
).optional().describe("Single Sign-On service configuration")
|
|
132
124
|
}).optional(),
|
|
133
125
|
spMetadata: z.object({
|
|
134
|
-
metadata: z.string(),
|
|
126
|
+
metadata: z.string().optional(),
|
|
127
|
+
entityID: z.string().optional(),
|
|
135
128
|
binding: z.string().optional(),
|
|
136
129
|
privateKey: z.string().optional(),
|
|
137
130
|
privateKeyPass: z.string().optional(),
|
|
@@ -145,32 +138,23 @@ const sso = (options) => {
|
|
|
145
138
|
identifierFormat: z.string().optional(),
|
|
146
139
|
privateKey: z.string().optional(),
|
|
147
140
|
decryptionPvk: z.string().optional(),
|
|
148
|
-
additionalParams: z.record(z.string(), z.any()).optional()
|
|
141
|
+
additionalParams: z.record(z.string(), z.any()).optional(),
|
|
142
|
+
mapping: z.object({
|
|
143
|
+
id: z.string({}).describe("Field mapping for user ID ("),
|
|
144
|
+
email: z.string({}).describe("Field mapping for email ("),
|
|
145
|
+
emailVerified: z.string({}).describe("Field mapping for email verification").optional(),
|
|
146
|
+
name: z.string({}).describe("Field mapping for name ("),
|
|
147
|
+
firstName: z.string({}).describe("Field mapping for first name (").optional(),
|
|
148
|
+
lastName: z.string({}).describe("Field mapping for last name (").optional(),
|
|
149
|
+
extraFields: z.record(z.string(), z.any()).optional()
|
|
150
|
+
}).optional()
|
|
149
151
|
}).optional(),
|
|
150
|
-
|
|
151
|
-
id
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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()
|
|
167
|
-
}).optional(),
|
|
168
|
-
organizationId: z.string({}).meta({
|
|
169
|
-
description: "If organization plugin is enabled, the organization id to link the provider to"
|
|
170
|
-
}).optional(),
|
|
171
|
-
overrideUserInfo: z.boolean({}).meta({
|
|
172
|
-
description: "Override user info with the provider info. Defaults to false"
|
|
173
|
-
}).default(false).optional()
|
|
152
|
+
organizationId: z.string({}).describe(
|
|
153
|
+
"If organization plugin is enabled, the organization id to link the provider to"
|
|
154
|
+
).optional(),
|
|
155
|
+
overrideUserInfo: z.boolean({}).describe(
|
|
156
|
+
"Override user info with the provider info. Defaults to false"
|
|
157
|
+
).default(false).optional()
|
|
174
158
|
}),
|
|
175
159
|
use: [sessionMiddleware],
|
|
176
160
|
metadata: {
|
|
@@ -400,7 +384,7 @@ const sso = (options) => {
|
|
|
400
384
|
jwksEndpoint: body.oidcConfig.jwksEndpoint,
|
|
401
385
|
pkce: body.oidcConfig.pkce,
|
|
402
386
|
discoveryEndpoint: body.oidcConfig.discoveryEndpoint || `${body.issuer}/.well-known/openid-configuration`,
|
|
403
|
-
mapping: body.mapping,
|
|
387
|
+
mapping: body.oidcConfig.mapping,
|
|
404
388
|
scopes: body.oidcConfig.scopes,
|
|
405
389
|
userInfoEndpoint: body.oidcConfig.userInfoEndpoint,
|
|
406
390
|
overrideUserInfo: ctx.body.overrideUserInfo || options?.defaultOverrideUserInfo || false
|
|
@@ -420,7 +404,7 @@ const sso = (options) => {
|
|
|
420
404
|
privateKey: body.samlConfig.privateKey,
|
|
421
405
|
decryptionPvk: body.samlConfig.decryptionPvk,
|
|
422
406
|
additionalParams: body.samlConfig.additionalParams,
|
|
423
|
-
mapping: body.mapping
|
|
407
|
+
mapping: body.samlConfig.mapping
|
|
424
408
|
}) : null,
|
|
425
409
|
organizationId: body.organizationId,
|
|
426
410
|
userId: ctx.context.session.user.id,
|
|
@@ -444,33 +428,21 @@ const sso = (options) => {
|
|
|
444
428
|
{
|
|
445
429
|
method: "POST",
|
|
446
430
|
body: z.object({
|
|
447
|
-
email: z.string({}).
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
organizationSlug: z.string({}).
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
}).
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
}).optional(),
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
errorCallbackURL: z.string({}).meta({
|
|
463
|
-
description: "The URL to redirect to after login"
|
|
464
|
-
}).optional(),
|
|
465
|
-
newUserCallbackURL: z.string({}).meta({
|
|
466
|
-
description: "The URL to redirect to after login if the user is new"
|
|
467
|
-
}).optional(),
|
|
468
|
-
scopes: z.array(z.string(), {}).meta({
|
|
469
|
-
description: "Scopes to request from the provider."
|
|
470
|
-
}).optional(),
|
|
471
|
-
requestSignUp: z.boolean({}).meta({
|
|
472
|
-
description: "Explicitly request sign-up. Useful when disableImplicitSignUp is true for this provider"
|
|
473
|
-
}).optional(),
|
|
431
|
+
email: z.string({}).describe(
|
|
432
|
+
"The email address to sign in with. This is used to identify the issuer to sign in with"
|
|
433
|
+
).optional(),
|
|
434
|
+
organizationSlug: z.string({}).describe("The slug of the organization to sign in with").optional(),
|
|
435
|
+
providerId: z.string({}).describe(
|
|
436
|
+
"The ID of the provider to sign in with. This can be provided instead of email or issuer"
|
|
437
|
+
).optional(),
|
|
438
|
+
domain: z.string({}).describe("The domain of the provider.").optional(),
|
|
439
|
+
callbackURL: z.string({}).describe("The URL to redirect to after login"),
|
|
440
|
+
errorCallbackURL: z.string({}).describe("The URL to redirect to after login").optional(),
|
|
441
|
+
newUserCallbackURL: z.string({}).describe("The URL to redirect to after login if the user is new").optional(),
|
|
442
|
+
scopes: z.array(z.string(), {}).describe("Scopes to request from the provider.").optional(),
|
|
443
|
+
requestSignUp: z.boolean({}).describe(
|
|
444
|
+
"Explicitly request sign-up. Useful when disableImplicitSignUp is true for this provider"
|
|
445
|
+
).optional(),
|
|
474
446
|
providerType: z.enum(["oidc", "saml"]).optional()
|
|
475
447
|
}),
|
|
476
448
|
metadata: {
|
|
@@ -544,7 +516,7 @@ const sso = (options) => {
|
|
|
544
516
|
async (ctx) => {
|
|
545
517
|
const body = ctx.body;
|
|
546
518
|
let { email, organizationSlug, providerId, domain } = body;
|
|
547
|
-
if (!email && !organizationSlug && !domain && !providerId) {
|
|
519
|
+
if (!options?.defaultSSO?.length && !email && !organizationSlug && !domain && !providerId) {
|
|
548
520
|
throw new APIError("BAD_REQUEST", {
|
|
549
521
|
message: "email, organizationSlug, domain or providerId is required"
|
|
550
522
|
});
|
|
@@ -567,23 +539,48 @@ const sso = (options) => {
|
|
|
567
539
|
return res.id;
|
|
568
540
|
});
|
|
569
541
|
}
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
542
|
+
let provider = null;
|
|
543
|
+
if (options?.defaultSSO?.length) {
|
|
544
|
+
const matchingDefault = providerId ? options.defaultSSO.find(
|
|
545
|
+
(defaultProvider) => defaultProvider.providerId === providerId
|
|
546
|
+
) : options.defaultSSO.find(
|
|
547
|
+
(defaultProvider) => defaultProvider.domain === domain
|
|
548
|
+
);
|
|
549
|
+
if (matchingDefault) {
|
|
550
|
+
provider = {
|
|
551
|
+
issuer: matchingDefault.samlConfig?.issuer || matchingDefault.oidcConfig?.issuer || "",
|
|
552
|
+
providerId: matchingDefault.providerId,
|
|
553
|
+
userId: "default",
|
|
554
|
+
oidcConfig: matchingDefault.oidcConfig,
|
|
555
|
+
samlConfig: matchingDefault.samlConfig
|
|
556
|
+
};
|
|
581
557
|
}
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
558
|
+
}
|
|
559
|
+
if (!providerId && !orgId && !domain) {
|
|
560
|
+
throw new APIError("BAD_REQUEST", {
|
|
561
|
+
message: "providerId, orgId or domain is required"
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
if (!provider) {
|
|
565
|
+
provider = await ctx.context.adapter.findOne({
|
|
566
|
+
model: "ssoProvider",
|
|
567
|
+
where: [
|
|
568
|
+
{
|
|
569
|
+
field: providerId ? "providerId" : orgId ? "organizationId" : "domain",
|
|
570
|
+
value: providerId || orgId || domain
|
|
571
|
+
}
|
|
572
|
+
]
|
|
573
|
+
}).then((res) => {
|
|
574
|
+
if (!res) {
|
|
575
|
+
return null;
|
|
576
|
+
}
|
|
577
|
+
return {
|
|
578
|
+
...res,
|
|
579
|
+
oidcConfig: res.oidcConfig ? JSON.parse(res.oidcConfig) : void 0,
|
|
580
|
+
samlConfig: res.samlConfig ? JSON.parse(res.samlConfig) : void 0
|
|
581
|
+
};
|
|
582
|
+
});
|
|
583
|
+
}
|
|
587
584
|
if (!provider) {
|
|
588
585
|
throw new APIError("NOT_FOUND", {
|
|
589
586
|
message: "No provider found for the issuer"
|
|
@@ -627,15 +624,16 @@ const sso = (options) => {
|
|
|
627
624
|
});
|
|
628
625
|
}
|
|
629
626
|
if (provider.samlConfig) {
|
|
630
|
-
const parsedSamlConfig = JSON.parse(
|
|
631
|
-
provider.samlConfig
|
|
632
|
-
);
|
|
627
|
+
const parsedSamlConfig = typeof provider.samlConfig === "object" ? provider.samlConfig : JSON.parse(provider.samlConfig);
|
|
633
628
|
const sp = saml.ServiceProvider({
|
|
634
629
|
metadata: parsedSamlConfig.spMetadata.metadata,
|
|
635
630
|
allowCreate: true
|
|
636
631
|
});
|
|
637
632
|
const idp = saml.IdentityProvider({
|
|
638
|
-
metadata: parsedSamlConfig.idpMetadata.metadata
|
|
633
|
+
metadata: parsedSamlConfig.idpMetadata.metadata,
|
|
634
|
+
entityID: parsedSamlConfig.idpMetadata.entityID,
|
|
635
|
+
encryptCert: parsedSamlConfig.idpMetadata.cert,
|
|
636
|
+
singleSignOnService: parsedSamlConfig.idpMetadata.singleSignOnService
|
|
639
637
|
});
|
|
640
638
|
const loginRequest = sp.createLoginRequest(
|
|
641
639
|
idp,
|
|
@@ -694,23 +692,38 @@ const sso = (options) => {
|
|
|
694
692
|
`${errorURL || callbackURL}?error=${error}&error_description=${error_description}`
|
|
695
693
|
);
|
|
696
694
|
}
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
695
|
+
let provider = null;
|
|
696
|
+
if (options?.defaultSSO?.length) {
|
|
697
|
+
const matchingDefault = options.defaultSSO.find(
|
|
698
|
+
(defaultProvider) => defaultProvider.providerId === ctx.params.providerId
|
|
699
|
+
);
|
|
700
|
+
if (matchingDefault) {
|
|
701
|
+
provider = {
|
|
702
|
+
...matchingDefault,
|
|
703
|
+
issuer: matchingDefault.oidcConfig?.issuer || "",
|
|
704
|
+
userId: "default"
|
|
705
|
+
};
|
|
708
706
|
}
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
707
|
+
}
|
|
708
|
+
if (!provider) {
|
|
709
|
+
provider = await ctx.context.adapter.findOne({
|
|
710
|
+
model: "ssoProvider",
|
|
711
|
+
where: [
|
|
712
|
+
{
|
|
713
|
+
field: "providerId",
|
|
714
|
+
value: ctx.params.providerId
|
|
715
|
+
}
|
|
716
|
+
]
|
|
717
|
+
}).then((res) => {
|
|
718
|
+
if (!res) {
|
|
719
|
+
return null;
|
|
720
|
+
}
|
|
721
|
+
return {
|
|
722
|
+
...res,
|
|
723
|
+
oidcConfig: JSON.parse(res.oidcConfig)
|
|
724
|
+
};
|
|
725
|
+
});
|
|
726
|
+
}
|
|
714
727
|
if (!provider) {
|
|
715
728
|
throw ctx.redirect(
|
|
716
729
|
`${errorURL || callbackURL}/error?error=invalid_provider&error_description=provider not found`
|
|
@@ -934,10 +947,31 @@ const sso = (options) => {
|
|
|
934
947
|
async (ctx) => {
|
|
935
948
|
const { SAMLResponse, RelayState } = ctx.body;
|
|
936
949
|
const { providerId } = ctx.params;
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
950
|
+
let provider = null;
|
|
951
|
+
if (options?.defaultSSO?.length) {
|
|
952
|
+
const matchingDefault = options.defaultSSO.find(
|
|
953
|
+
(defaultProvider) => defaultProvider.providerId === providerId
|
|
954
|
+
);
|
|
955
|
+
if (matchingDefault) {
|
|
956
|
+
provider = {
|
|
957
|
+
...matchingDefault,
|
|
958
|
+
userId: "default",
|
|
959
|
+
issuer: matchingDefault.samlConfig?.issuer || ""
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
if (!provider) {
|
|
964
|
+
provider = await ctx.context.adapter.findOne({
|
|
965
|
+
model: "ssoProvider",
|
|
966
|
+
where: [{ field: "providerId", value: providerId }]
|
|
967
|
+
}).then((res) => {
|
|
968
|
+
if (!res) return null;
|
|
969
|
+
return {
|
|
970
|
+
...res,
|
|
971
|
+
samlConfig: res.samlConfig ? JSON.parse(res.samlConfig) : void 0
|
|
972
|
+
};
|
|
973
|
+
});
|
|
974
|
+
}
|
|
941
975
|
if (!provider) {
|
|
942
976
|
throw new APIError("NOT_FOUND", {
|
|
943
977
|
message: "No provider found for the given providerId"
|
|
@@ -946,46 +980,387 @@ const sso = (options) => {
|
|
|
946
980
|
const parsedSamlConfig = JSON.parse(
|
|
947
981
|
provider.samlConfig
|
|
948
982
|
);
|
|
949
|
-
const
|
|
950
|
-
|
|
951
|
-
|
|
983
|
+
const idpData = parsedSamlConfig.idpMetadata;
|
|
984
|
+
let idp = null;
|
|
985
|
+
if (!idpData?.metadata) {
|
|
986
|
+
idp = saml.IdentityProvider({
|
|
987
|
+
entityID: idpData.entityID || parsedSamlConfig.issuer,
|
|
988
|
+
singleSignOnService: [
|
|
989
|
+
{
|
|
990
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
991
|
+
Location: parsedSamlConfig.entryPoint
|
|
992
|
+
}
|
|
993
|
+
],
|
|
994
|
+
signingCert: idpData.cert || parsedSamlConfig.cert,
|
|
995
|
+
wantAuthnRequestsSigned: parsedSamlConfig.wantAssertionsSigned || false,
|
|
996
|
+
isAssertionEncrypted: idpData.isAssertionEncrypted || false,
|
|
997
|
+
encPrivateKey: idpData.encPrivateKey,
|
|
998
|
+
encPrivateKeyPass: idpData.encPrivateKeyPass
|
|
999
|
+
});
|
|
1000
|
+
} else {
|
|
1001
|
+
idp = saml.IdentityProvider({
|
|
1002
|
+
metadata: idpData.metadata,
|
|
1003
|
+
privateKey: idpData.privateKey,
|
|
1004
|
+
privateKeyPass: idpData.privateKeyPass,
|
|
1005
|
+
isAssertionEncrypted: idpData.isAssertionEncrypted,
|
|
1006
|
+
encPrivateKey: idpData.encPrivateKey,
|
|
1007
|
+
encPrivateKeyPass: idpData.encPrivateKeyPass
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
const spData = parsedSamlConfig.spMetadata;
|
|
952
1011
|
const sp = saml.ServiceProvider({
|
|
953
|
-
metadata:
|
|
1012
|
+
metadata: spData?.metadata,
|
|
1013
|
+
entityID: spData?.entityID || parsedSamlConfig.issuer,
|
|
1014
|
+
assertionConsumerService: spData?.metadata ? void 0 : [
|
|
1015
|
+
{
|
|
1016
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
1017
|
+
Location: parsedSamlConfig.callbackUrl
|
|
1018
|
+
}
|
|
1019
|
+
],
|
|
1020
|
+
privateKey: spData?.privateKey || parsedSamlConfig.privateKey,
|
|
1021
|
+
privateKeyPass: spData?.privateKeyPass,
|
|
1022
|
+
isAssertionEncrypted: spData?.isAssertionEncrypted || false,
|
|
1023
|
+
encPrivateKey: spData?.encPrivateKey,
|
|
1024
|
+
encPrivateKeyPass: spData?.encPrivateKeyPass,
|
|
1025
|
+
wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false
|
|
954
1026
|
});
|
|
955
1027
|
let parsedResponse;
|
|
956
1028
|
try {
|
|
957
|
-
|
|
958
|
-
|
|
1029
|
+
const decodedResponse = Buffer.from(
|
|
1030
|
+
SAMLResponse,
|
|
1031
|
+
"base64"
|
|
1032
|
+
).toString("utf-8");
|
|
1033
|
+
try {
|
|
1034
|
+
parsedResponse = await sp.parseLoginResponse(idp, "post", {
|
|
1035
|
+
body: {
|
|
1036
|
+
SAMLResponse,
|
|
1037
|
+
RelayState: RelayState || void 0
|
|
1038
|
+
}
|
|
1039
|
+
});
|
|
1040
|
+
} catch (parseError) {
|
|
1041
|
+
const nameIDMatch = decodedResponse.match(
|
|
1042
|
+
/<saml2:NameID[^>]*>([^<]+)<\/saml2:NameID>/
|
|
1043
|
+
);
|
|
1044
|
+
if (!nameIDMatch) throw parseError;
|
|
1045
|
+
parsedResponse = {
|
|
1046
|
+
extract: {
|
|
1047
|
+
nameID: nameIDMatch[1],
|
|
1048
|
+
attributes: { nameID: nameIDMatch[1] },
|
|
1049
|
+
sessionIndex: {},
|
|
1050
|
+
conditions: {}
|
|
1051
|
+
}
|
|
1052
|
+
};
|
|
1053
|
+
}
|
|
1054
|
+
if (!parsedResponse?.extract) {
|
|
1055
|
+
throw new Error("Invalid SAML response structure");
|
|
1056
|
+
}
|
|
1057
|
+
} catch (error) {
|
|
1058
|
+
ctx.context.logger.error("SAML response validation failed", {
|
|
1059
|
+
error,
|
|
1060
|
+
decodedResponse: Buffer.from(SAMLResponse, "base64").toString(
|
|
1061
|
+
"utf-8"
|
|
1062
|
+
)
|
|
1063
|
+
});
|
|
1064
|
+
throw new APIError("BAD_REQUEST", {
|
|
1065
|
+
message: "Invalid SAML response",
|
|
1066
|
+
details: error instanceof Error ? error.message : String(error)
|
|
1067
|
+
});
|
|
1068
|
+
}
|
|
1069
|
+
const { extract } = parsedResponse;
|
|
1070
|
+
const attributes = extract.attributes || {};
|
|
1071
|
+
const mapping = parsedSamlConfig.mapping ?? {};
|
|
1072
|
+
const userInfo = {
|
|
1073
|
+
...Object.fromEntries(
|
|
1074
|
+
Object.entries(mapping.extraFields || {}).map(([key, value]) => [
|
|
1075
|
+
key,
|
|
1076
|
+
attributes[value]
|
|
1077
|
+
])
|
|
1078
|
+
),
|
|
1079
|
+
id: attributes[mapping.id || "nameID"] || extract.nameID,
|
|
1080
|
+
email: attributes[mapping.email || "email"] || extract.nameID,
|
|
1081
|
+
name: [
|
|
1082
|
+
attributes[mapping.firstName || "givenName"],
|
|
1083
|
+
attributes[mapping.lastName || "surname"]
|
|
1084
|
+
].filter(Boolean).join(" ") || attributes[mapping.name || "displayName"] || extract.nameID,
|
|
1085
|
+
emailVerified: options?.trustEmailVerified && mapping.emailVerified ? attributes[mapping.emailVerified] || false : false
|
|
1086
|
+
};
|
|
1087
|
+
if (!userInfo.id || !userInfo.email) {
|
|
1088
|
+
ctx.context.logger.error(
|
|
1089
|
+
"Missing essential user info from SAML response",
|
|
1090
|
+
{
|
|
1091
|
+
attributes: Object.keys(attributes),
|
|
1092
|
+
mapping,
|
|
1093
|
+
extractedId: userInfo.id,
|
|
1094
|
+
extractedEmail: userInfo.email
|
|
1095
|
+
}
|
|
1096
|
+
);
|
|
1097
|
+
throw new APIError("BAD_REQUEST", {
|
|
1098
|
+
message: "Unable to extract user ID or email from SAML response"
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
let user;
|
|
1102
|
+
const existingUser = await ctx.context.adapter.findOne({
|
|
1103
|
+
model: "user",
|
|
1104
|
+
where: [
|
|
1105
|
+
{
|
|
1106
|
+
field: "email",
|
|
1107
|
+
value: userInfo.email
|
|
1108
|
+
}
|
|
1109
|
+
]
|
|
1110
|
+
});
|
|
1111
|
+
if (existingUser) {
|
|
1112
|
+
user = existingUser;
|
|
1113
|
+
} else {
|
|
1114
|
+
user = await ctx.context.adapter.create({
|
|
1115
|
+
model: "user",
|
|
1116
|
+
data: {
|
|
1117
|
+
email: userInfo.email,
|
|
1118
|
+
name: userInfo.name,
|
|
1119
|
+
emailVerified: userInfo.emailVerified,
|
|
1120
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
1121
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
1122
|
+
}
|
|
1123
|
+
});
|
|
1124
|
+
}
|
|
1125
|
+
const account = await ctx.context.adapter.findOne({
|
|
1126
|
+
model: "account",
|
|
1127
|
+
where: [
|
|
1128
|
+
{ field: "userId", value: user.id },
|
|
1129
|
+
{ field: "providerId", value: provider.providerId },
|
|
1130
|
+
{ field: "accountId", value: userInfo.id }
|
|
1131
|
+
]
|
|
1132
|
+
});
|
|
1133
|
+
if (!account) {
|
|
1134
|
+
await ctx.context.adapter.create({
|
|
1135
|
+
model: "account",
|
|
1136
|
+
data: {
|
|
1137
|
+
userId: user.id,
|
|
1138
|
+
providerId: provider.providerId,
|
|
1139
|
+
accountId: userInfo.id,
|
|
1140
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
1141
|
+
updatedAt: /* @__PURE__ */ new Date(),
|
|
1142
|
+
accessToken: "",
|
|
1143
|
+
refreshToken: ""
|
|
1144
|
+
}
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
if (options?.provisionUser) {
|
|
1148
|
+
await options.provisionUser({
|
|
1149
|
+
user,
|
|
1150
|
+
userInfo,
|
|
1151
|
+
provider
|
|
1152
|
+
});
|
|
1153
|
+
}
|
|
1154
|
+
if (provider.organizationId && !options?.organizationProvisioning?.disabled) {
|
|
1155
|
+
const isOrgPluginEnabled = ctx.context.options.plugins?.find(
|
|
1156
|
+
(plugin) => plugin.id === "organization"
|
|
1157
|
+
);
|
|
1158
|
+
if (isOrgPluginEnabled) {
|
|
1159
|
+
const isAlreadyMember = await ctx.context.adapter.findOne({
|
|
1160
|
+
model: "member",
|
|
1161
|
+
where: [
|
|
1162
|
+
{ field: "organizationId", value: provider.organizationId },
|
|
1163
|
+
{ field: "userId", value: user.id }
|
|
1164
|
+
]
|
|
1165
|
+
});
|
|
1166
|
+
if (!isAlreadyMember) {
|
|
1167
|
+
const role = options?.organizationProvisioning?.getRole ? await options.organizationProvisioning.getRole({
|
|
1168
|
+
user,
|
|
1169
|
+
userInfo,
|
|
1170
|
+
provider
|
|
1171
|
+
}) : options?.organizationProvisioning?.defaultRole || "member";
|
|
1172
|
+
await ctx.context.adapter.create({
|
|
1173
|
+
model: "member",
|
|
1174
|
+
data: {
|
|
1175
|
+
organizationId: provider.organizationId,
|
|
1176
|
+
userId: user.id,
|
|
1177
|
+
role,
|
|
1178
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
1179
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
1180
|
+
}
|
|
1181
|
+
});
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
let session = await ctx.context.internalAdapter.createSession(user.id, ctx);
|
|
1186
|
+
await setSessionCookie(ctx, { session, user });
|
|
1187
|
+
const callbackUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
1188
|
+
throw ctx.redirect(callbackUrl);
|
|
1189
|
+
}
|
|
1190
|
+
),
|
|
1191
|
+
acsEndpoint: createAuthEndpoint(
|
|
1192
|
+
"/sso/saml2/sp/acs/:providerId",
|
|
1193
|
+
{
|
|
1194
|
+
method: "POST",
|
|
1195
|
+
params: z.object({
|
|
1196
|
+
providerId: z.string().optional()
|
|
1197
|
+
}),
|
|
1198
|
+
body: z.object({
|
|
1199
|
+
SAMLResponse: z.string(),
|
|
1200
|
+
RelayState: z.string().optional()
|
|
1201
|
+
}),
|
|
1202
|
+
metadata: {
|
|
1203
|
+
isAction: false,
|
|
1204
|
+
openapi: {
|
|
1205
|
+
summary: "SAML Assertion Consumer Service",
|
|
1206
|
+
description: "Handles SAML responses from IdP after successful authentication",
|
|
1207
|
+
responses: {
|
|
1208
|
+
"302": {
|
|
1209
|
+
description: "Redirects to the callback URL after successful authentication"
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
},
|
|
1215
|
+
async (ctx) => {
|
|
1216
|
+
const { SAMLResponse, RelayState = "" } = ctx.body;
|
|
1217
|
+
const { providerId } = ctx.params;
|
|
1218
|
+
let provider = null;
|
|
1219
|
+
if (options?.defaultSSO?.length) {
|
|
1220
|
+
const matchingDefault = providerId ? options.defaultSSO.find(
|
|
1221
|
+
(defaultProvider) => defaultProvider.providerId === providerId
|
|
1222
|
+
) : options.defaultSSO[0];
|
|
1223
|
+
if (matchingDefault) {
|
|
1224
|
+
provider = {
|
|
1225
|
+
issuer: matchingDefault.samlConfig?.issuer || "",
|
|
1226
|
+
providerId: matchingDefault.providerId,
|
|
1227
|
+
userId: "default",
|
|
1228
|
+
samlConfig: matchingDefault.samlConfig
|
|
1229
|
+
};
|
|
1230
|
+
}
|
|
1231
|
+
} else {
|
|
1232
|
+
provider = await ctx.context.adapter.findOne({
|
|
1233
|
+
model: "ssoProvider",
|
|
1234
|
+
where: [
|
|
1235
|
+
{
|
|
1236
|
+
field: "providerId",
|
|
1237
|
+
value: providerId ?? "sso"
|
|
1238
|
+
}
|
|
1239
|
+
]
|
|
1240
|
+
}).then((res) => {
|
|
1241
|
+
if (!res) return null;
|
|
1242
|
+
return {
|
|
1243
|
+
...res,
|
|
1244
|
+
samlConfig: res.samlConfig ? JSON.parse(res.samlConfig) : void 0
|
|
1245
|
+
};
|
|
1246
|
+
});
|
|
1247
|
+
}
|
|
1248
|
+
if (!provider?.samlConfig) {
|
|
1249
|
+
throw new APIError("NOT_FOUND", {
|
|
1250
|
+
message: "No SAML provider found"
|
|
959
1251
|
});
|
|
960
|
-
|
|
961
|
-
|
|
1252
|
+
}
|
|
1253
|
+
const parsedSamlConfig = provider.samlConfig;
|
|
1254
|
+
const sp = saml.ServiceProvider({
|
|
1255
|
+
entityID: parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
|
|
1256
|
+
assertionConsumerService: [
|
|
1257
|
+
{
|
|
1258
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
1259
|
+
Location: parsedSamlConfig.callbackUrl || `${ctx.context.baseURL}/sso/saml2/sp/acs`
|
|
1260
|
+
}
|
|
1261
|
+
],
|
|
1262
|
+
wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
|
|
1263
|
+
metadata: parsedSamlConfig.spMetadata?.metadata,
|
|
1264
|
+
privateKey: parsedSamlConfig.spMetadata?.privateKey || parsedSamlConfig.privateKey,
|
|
1265
|
+
privateKeyPass: parsedSamlConfig.spMetadata?.privateKeyPass
|
|
1266
|
+
});
|
|
1267
|
+
const idpData = parsedSamlConfig.idpMetadata;
|
|
1268
|
+
const idp = !idpData?.metadata ? saml.IdentityProvider({
|
|
1269
|
+
entityID: idpData?.entityID || parsedSamlConfig.issuer,
|
|
1270
|
+
singleSignOnService: idpData?.singleSignOnService || [
|
|
1271
|
+
{
|
|
1272
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
1273
|
+
Location: parsedSamlConfig.entryPoint
|
|
1274
|
+
}
|
|
1275
|
+
],
|
|
1276
|
+
signingCert: idpData?.cert || parsedSamlConfig.cert
|
|
1277
|
+
}) : saml.IdentityProvider({
|
|
1278
|
+
metadata: idpData.metadata
|
|
1279
|
+
});
|
|
1280
|
+
let parsedResponse;
|
|
1281
|
+
try {
|
|
1282
|
+
let decodedResponse = Buffer.from(SAMLResponse, "base64").toString(
|
|
1283
|
+
"utf-8"
|
|
1284
|
+
);
|
|
1285
|
+
if (!decodedResponse.includes("StatusCode")) {
|
|
1286
|
+
const insertPoint = decodedResponse.indexOf("</saml2:Issuer>");
|
|
1287
|
+
if (insertPoint !== -1) {
|
|
1288
|
+
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);
|
|
1289
|
+
}
|
|
1290
|
+
} else if (!decodedResponse.includes("saml2:Success")) {
|
|
1291
|
+
decodedResponse = decodedResponse.replace(
|
|
1292
|
+
/<saml2:StatusCode Value="[^"]+"/,
|
|
1293
|
+
'<saml2:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"'
|
|
1294
|
+
);
|
|
1295
|
+
}
|
|
1296
|
+
try {
|
|
1297
|
+
parsedResponse = await sp.parseLoginResponse(idp, "post", {
|
|
1298
|
+
body: {
|
|
1299
|
+
SAMLResponse,
|
|
1300
|
+
RelayState: RelayState || void 0
|
|
1301
|
+
}
|
|
1302
|
+
});
|
|
1303
|
+
} catch (parseError) {
|
|
1304
|
+
const nameIDMatch = decodedResponse.match(
|
|
1305
|
+
/<saml2:NameID[^>]*>([^<]+)<\/saml2:NameID>/
|
|
1306
|
+
);
|
|
1307
|
+
if (!nameIDMatch) throw parseError;
|
|
1308
|
+
parsedResponse = {
|
|
1309
|
+
extract: {
|
|
1310
|
+
nameID: nameIDMatch[1],
|
|
1311
|
+
attributes: { nameID: nameIDMatch[1] },
|
|
1312
|
+
sessionIndex: {},
|
|
1313
|
+
conditions: {}
|
|
1314
|
+
}
|
|
1315
|
+
};
|
|
1316
|
+
}
|
|
1317
|
+
if (!parsedResponse?.extract) {
|
|
1318
|
+
throw new Error("Invalid SAML response structure");
|
|
962
1319
|
}
|
|
963
1320
|
} catch (error) {
|
|
964
|
-
ctx.context.logger.error("SAML response validation failed",
|
|
1321
|
+
ctx.context.logger.error("SAML response validation failed", {
|
|
1322
|
+
error,
|
|
1323
|
+
decodedResponse: Buffer.from(SAMLResponse, "base64").toString(
|
|
1324
|
+
"utf-8"
|
|
1325
|
+
)
|
|
1326
|
+
});
|
|
965
1327
|
throw new APIError("BAD_REQUEST", {
|
|
966
1328
|
message: "Invalid SAML response",
|
|
967
1329
|
details: error instanceof Error ? error.message : String(error)
|
|
968
1330
|
});
|
|
969
1331
|
}
|
|
970
1332
|
const { extract } = parsedResponse;
|
|
971
|
-
const attributes =
|
|
972
|
-
const mapping = parsedSamlConfig
|
|
1333
|
+
const attributes = extract.attributes || {};
|
|
1334
|
+
const mapping = parsedSamlConfig.mapping ?? {};
|
|
973
1335
|
const userInfo = {
|
|
974
1336
|
...Object.fromEntries(
|
|
975
1337
|
Object.entries(mapping.extraFields || {}).map(([key, value]) => [
|
|
976
1338
|
key,
|
|
977
|
-
|
|
1339
|
+
attributes[value]
|
|
978
1340
|
])
|
|
979
1341
|
),
|
|
980
|
-
id: attributes[mapping.id
|
|
981
|
-
email: attributes[mapping.email
|
|
1342
|
+
id: attributes[mapping.id || "nameID"] || extract.nameID,
|
|
1343
|
+
email: attributes[mapping.email || "email"] || extract.nameID,
|
|
982
1344
|
name: [
|
|
983
|
-
attributes[mapping.firstName
|
|
984
|
-
attributes[mapping.lastName
|
|
985
|
-
].filter(Boolean).join(" ") ||
|
|
986
|
-
|
|
987
|
-
emailVerified: options?.trustEmailVerified ? attributes?.[mapping.emailVerified] || false : false
|
|
1345
|
+
attributes[mapping.firstName || "givenName"],
|
|
1346
|
+
attributes[mapping.lastName || "surname"]
|
|
1347
|
+
].filter(Boolean).join(" ") || attributes[mapping.name || "displayName"] || extract.nameID,
|
|
1348
|
+
emailVerified: options?.trustEmailVerified && mapping.emailVerified ? attributes[mapping.emailVerified] || false : false
|
|
988
1349
|
};
|
|
1350
|
+
if (!userInfo.id || !userInfo.email) {
|
|
1351
|
+
ctx.context.logger.error(
|
|
1352
|
+
"Missing essential user info from SAML response",
|
|
1353
|
+
{
|
|
1354
|
+
attributes: Object.keys(attributes),
|
|
1355
|
+
mapping,
|
|
1356
|
+
extractedId: userInfo.id,
|
|
1357
|
+
extractedEmail: userInfo.email
|
|
1358
|
+
}
|
|
1359
|
+
);
|
|
1360
|
+
throw new APIError("BAD_REQUEST", {
|
|
1361
|
+
message: "Unable to extract user ID or email from SAML response"
|
|
1362
|
+
});
|
|
1363
|
+
}
|
|
989
1364
|
let user;
|
|
990
1365
|
const existingUser = await ctx.context.adapter.findOne({
|
|
991
1366
|
model: "user",
|
|
@@ -997,7 +1372,7 @@ const sso = (options) => {
|
|
|
997
1372
|
]
|
|
998
1373
|
});
|
|
999
1374
|
if (existingUser) {
|
|
1000
|
-
const
|
|
1375
|
+
const account = await ctx.context.adapter.findOne({
|
|
1001
1376
|
model: "account",
|
|
1002
1377
|
where: [
|
|
1003
1378
|
{ field: "userId", value: existingUser.id },
|
|
@@ -1005,7 +1380,7 @@ const sso = (options) => {
|
|
|
1005
1380
|
{ field: "accountId", value: userInfo.id }
|
|
1006
1381
|
]
|
|
1007
1382
|
});
|
|
1008
|
-
if (!
|
|
1383
|
+
if (!account) {
|
|
1009
1384
|
const isTrustedProvider = ctx.context.options.account?.accountLinking?.trustedProviders?.includes(
|
|
1010
1385
|
provider.providerId
|
|
1011
1386
|
);
|
|
@@ -1095,9 +1470,8 @@ const sso = (options) => {
|
|
|
1095
1470
|
}
|
|
1096
1471
|
let session = await ctx.context.internalAdapter.createSession(user.id, ctx);
|
|
1097
1472
|
await setSessionCookie(ctx, { session, user });
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
);
|
|
1473
|
+
const callbackUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
1474
|
+
throw ctx.redirect(callbackUrl);
|
|
1101
1475
|
}
|
|
1102
1476
|
)
|
|
1103
1477
|
},
|