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