@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/src/index.ts
CHANGED
|
@@ -24,6 +24,7 @@ import { decodeJwt } from "jose";
|
|
|
24
24
|
import { setSessionCookie } from "better-auth/cookies";
|
|
25
25
|
import type { FlowResult } from "samlify/types/src/flow";
|
|
26
26
|
import { XMLValidator } from "fast-xml-parser";
|
|
27
|
+
import type { IdentityProvider } from "samlify/types/src/entity-idp";
|
|
27
28
|
|
|
28
29
|
const fastValidator = {
|
|
29
30
|
async validate(xml: string) {
|
|
@@ -37,6 +38,25 @@ const fastValidator = {
|
|
|
37
38
|
|
|
38
39
|
saml.setSchemaValidator(fastValidator);
|
|
39
40
|
|
|
41
|
+
export interface OIDCMapping {
|
|
42
|
+
id?: string;
|
|
43
|
+
email?: string;
|
|
44
|
+
emailVerified?: string;
|
|
45
|
+
name?: string;
|
|
46
|
+
image?: string;
|
|
47
|
+
extraFields?: Record<string, string>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface SAMLMapping {
|
|
51
|
+
id?: string;
|
|
52
|
+
email?: string;
|
|
53
|
+
emailVerified?: string;
|
|
54
|
+
name?: string;
|
|
55
|
+
firstName?: string;
|
|
56
|
+
lastName?: string;
|
|
57
|
+
extraFields?: Record<string, string>;
|
|
58
|
+
}
|
|
59
|
+
|
|
40
60
|
export interface OIDCConfig {
|
|
41
61
|
issuer: string;
|
|
42
62
|
pkce: boolean;
|
|
@@ -50,30 +70,49 @@ export interface OIDCConfig {
|
|
|
50
70
|
tokenEndpoint?: string;
|
|
51
71
|
tokenEndpointAuthentication?: "client_secret_post" | "client_secret_basic";
|
|
52
72
|
jwksEndpoint?: string;
|
|
53
|
-
mapping?:
|
|
54
|
-
id?: string;
|
|
55
|
-
email?: string;
|
|
56
|
-
emailVerified?: string;
|
|
57
|
-
name?: string;
|
|
58
|
-
image?: string;
|
|
59
|
-
extraFields?: Record<string, string>;
|
|
60
|
-
};
|
|
73
|
+
mapping?: OIDCMapping;
|
|
61
74
|
}
|
|
62
75
|
|
|
63
76
|
export interface SAMLConfig {
|
|
64
77
|
issuer: string;
|
|
65
78
|
entryPoint: string;
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
79
|
+
cert: string;
|
|
80
|
+
callbackUrl: string;
|
|
81
|
+
audience?: string;
|
|
82
|
+
idpMetadata?: {
|
|
83
|
+
metadata?: string;
|
|
84
|
+
entityID?: string;
|
|
85
|
+
entityURL?: string;
|
|
86
|
+
redirectURL?: string;
|
|
87
|
+
cert?: string;
|
|
88
|
+
privateKey?: string;
|
|
89
|
+
privateKeyPass?: string;
|
|
90
|
+
isAssertionEncrypted?: boolean;
|
|
91
|
+
encPrivateKey?: string;
|
|
92
|
+
encPrivateKeyPass?: string;
|
|
93
|
+
singleSignOnService?: Array<{
|
|
94
|
+
Binding: string;
|
|
95
|
+
Location: string;
|
|
96
|
+
}>;
|
|
97
|
+
};
|
|
98
|
+
spMetadata: {
|
|
99
|
+
metadata?: string;
|
|
100
|
+
entityID?: string;
|
|
101
|
+
binding?: string;
|
|
102
|
+
privateKey?: string;
|
|
103
|
+
privateKeyPass?: string;
|
|
104
|
+
isAssertionEncrypted?: boolean;
|
|
105
|
+
encPrivateKey?: string;
|
|
106
|
+
encPrivateKeyPass?: string;
|
|
76
107
|
};
|
|
108
|
+
wantAssertionsSigned?: boolean;
|
|
109
|
+
signatureAlgorithm?: string;
|
|
110
|
+
digestAlgorithm?: string;
|
|
111
|
+
identifierFormat?: string;
|
|
112
|
+
privateKey?: string;
|
|
113
|
+
decryptionPvk?: string;
|
|
114
|
+
additionalParams?: Record<string, any>;
|
|
115
|
+
mapping?: SAMLMapping;
|
|
77
116
|
}
|
|
78
117
|
|
|
79
118
|
export interface SSOProvider {
|
|
@@ -132,6 +171,29 @@ export interface SSOOptions {
|
|
|
132
171
|
provider: SSOProvider;
|
|
133
172
|
}) => Promise<"member" | "admin">;
|
|
134
173
|
};
|
|
174
|
+
/**
|
|
175
|
+
* Default SSO provider configurations for testing.
|
|
176
|
+
* These will take the precedence over the database providers.
|
|
177
|
+
*/
|
|
178
|
+
defaultSSO?: Array<{
|
|
179
|
+
/**
|
|
180
|
+
* The domain to match for this default provider.
|
|
181
|
+
* This is only used to match incoming requests to this default provider.
|
|
182
|
+
*/
|
|
183
|
+
domain: string;
|
|
184
|
+
/**
|
|
185
|
+
* The provider ID to use
|
|
186
|
+
*/
|
|
187
|
+
providerId: string;
|
|
188
|
+
/**
|
|
189
|
+
* SAML configuration
|
|
190
|
+
*/
|
|
191
|
+
samlConfig?: SAMLConfig;
|
|
192
|
+
/**
|
|
193
|
+
* OIDC configuration
|
|
194
|
+
*/
|
|
195
|
+
oidcConfig?: OIDCConfig;
|
|
196
|
+
}>;
|
|
135
197
|
/**
|
|
136
198
|
* Override user info with the provider info.
|
|
137
199
|
* @default false
|
|
@@ -222,96 +284,107 @@ export const sso = (options?: SSOOptions) => {
|
|
|
222
284
|
{
|
|
223
285
|
method: "POST",
|
|
224
286
|
body: z.object({
|
|
225
|
-
providerId: z
|
|
226
|
-
|
|
287
|
+
providerId: z
|
|
288
|
+
.string({})
|
|
289
|
+
.describe(
|
|
227
290
|
"The ID of the provider. This is used to identify the provider during login and callback",
|
|
228
|
-
|
|
229
|
-
issuer: z.string({}).
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
description:
|
|
291
|
+
),
|
|
292
|
+
issuer: z.string({}).describe("The issuer of the provider"),
|
|
293
|
+
domain: z
|
|
294
|
+
.string({})
|
|
295
|
+
.describe(
|
|
234
296
|
"The domain of the provider. This is used for email matching",
|
|
235
|
-
|
|
297
|
+
),
|
|
236
298
|
oidcConfig: z
|
|
237
299
|
.object({
|
|
238
|
-
clientId: z.string({}).
|
|
239
|
-
|
|
240
|
-
}),
|
|
241
|
-
clientSecret: z.string({}).meta({
|
|
242
|
-
description: "The client secret",
|
|
243
|
-
}),
|
|
300
|
+
clientId: z.string({}).describe("The client ID"),
|
|
301
|
+
clientSecret: z.string({}).describe("The client secret"),
|
|
244
302
|
authorizationEndpoint: z
|
|
245
303
|
.string({})
|
|
246
|
-
.
|
|
247
|
-
description: "The authorization endpoint",
|
|
248
|
-
})
|
|
304
|
+
.describe("The authorization endpoint")
|
|
249
305
|
.optional(),
|
|
250
306
|
tokenEndpoint: z
|
|
251
307
|
.string({})
|
|
252
|
-
.
|
|
253
|
-
description: "The token endpoint",
|
|
254
|
-
})
|
|
308
|
+
.describe("The token endpoint")
|
|
255
309
|
.optional(),
|
|
256
310
|
userInfoEndpoint: z
|
|
257
311
|
.string({})
|
|
258
|
-
.
|
|
259
|
-
description: "The user info endpoint",
|
|
260
|
-
})
|
|
312
|
+
.describe("The user info endpoint")
|
|
261
313
|
.optional(),
|
|
262
314
|
tokenEndpointAuthentication: z
|
|
263
315
|
.enum(["client_secret_post", "client_secret_basic"])
|
|
264
316
|
.optional(),
|
|
265
317
|
jwksEndpoint: z
|
|
266
318
|
.string({})
|
|
267
|
-
.
|
|
268
|
-
description: "The JWKS endpoint",
|
|
269
|
-
})
|
|
319
|
+
.describe("The JWKS endpoint")
|
|
270
320
|
.optional(),
|
|
271
321
|
discoveryEndpoint: z.string().optional(),
|
|
272
322
|
scopes: z
|
|
273
323
|
.array(z.string(), {})
|
|
274
|
-
.
|
|
275
|
-
description:
|
|
276
|
-
"The scopes to request. Defaults to ['openid', 'email', 'profile', 'offline_access']",
|
|
277
|
-
})
|
|
324
|
+
.describe("The scopes to request. ")
|
|
278
325
|
.optional(),
|
|
279
326
|
pkce: z
|
|
280
327
|
.boolean({})
|
|
281
|
-
.
|
|
282
|
-
description:
|
|
283
|
-
"Whether to use PKCE for the authorization flow",
|
|
284
|
-
})
|
|
328
|
+
.describe("Whether to use PKCE for the authorization flow")
|
|
285
329
|
.default(true)
|
|
286
330
|
.optional(),
|
|
331
|
+
mapping: z
|
|
332
|
+
.object({
|
|
333
|
+
id: z.string({}).describe("Field mapping for user ID ("),
|
|
334
|
+
email: z.string({}).describe("Field mapping for email ("),
|
|
335
|
+
emailVerified: z
|
|
336
|
+
.string({})
|
|
337
|
+
.describe("Field mapping for email verification (")
|
|
338
|
+
.optional(),
|
|
339
|
+
name: z.string({}).describe("Field mapping for name ("),
|
|
340
|
+
image: z
|
|
341
|
+
.string({})
|
|
342
|
+
.describe("Field mapping for image (")
|
|
343
|
+
.optional(),
|
|
344
|
+
extraFields: z.record(z.string(), z.any()).optional(),
|
|
345
|
+
})
|
|
346
|
+
.optional(),
|
|
287
347
|
})
|
|
288
348
|
.optional(),
|
|
289
349
|
samlConfig: z
|
|
290
350
|
.object({
|
|
291
|
-
entryPoint: z
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
cert: z.string({}).
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
description: "The callback URL of the provider",
|
|
299
|
-
}),
|
|
351
|
+
entryPoint: z
|
|
352
|
+
.string({})
|
|
353
|
+
.describe("The entry point of the provider"),
|
|
354
|
+
cert: z.string({}).describe("The certificate of the provider"),
|
|
355
|
+
callbackUrl: z
|
|
356
|
+
.string({})
|
|
357
|
+
.describe("The callback URL of the provider"),
|
|
300
358
|
audience: z.string().optional(),
|
|
301
359
|
idpMetadata: z
|
|
302
360
|
.object({
|
|
303
|
-
metadata: z.string(),
|
|
361
|
+
metadata: z.string().optional(),
|
|
362
|
+
entityID: z.string().optional(),
|
|
363
|
+
cert: z.string().optional(),
|
|
304
364
|
privateKey: z.string().optional(),
|
|
305
365
|
privateKeyPass: z.string().optional(),
|
|
306
366
|
isAssertionEncrypted: z.boolean().optional(),
|
|
307
367
|
encPrivateKey: z.string().optional(),
|
|
308
368
|
encPrivateKeyPass: z.string().optional(),
|
|
369
|
+
singleSignOnService: z
|
|
370
|
+
.array(
|
|
371
|
+
z.object({
|
|
372
|
+
Binding: z
|
|
373
|
+
.string()
|
|
374
|
+
.describe("The binding type for the SSO service"),
|
|
375
|
+
Location: z
|
|
376
|
+
.string()
|
|
377
|
+
.describe("The URL for the SSO service"),
|
|
378
|
+
}),
|
|
379
|
+
)
|
|
380
|
+
.optional()
|
|
381
|
+
.describe("Single Sign-On service configuration"),
|
|
309
382
|
})
|
|
310
383
|
.optional(),
|
|
311
384
|
spMetadata: z.object({
|
|
312
|
-
metadata: z.string(),
|
|
385
|
+
metadata: z.string().optional(),
|
|
386
|
+
entityID: z.string().optional(),
|
|
313
387
|
binding: z.string().optional(),
|
|
314
|
-
|
|
315
388
|
privateKey: z.string().optional(),
|
|
316
389
|
privateKeyPass: z.string().optional(),
|
|
317
390
|
isAssertionEncrypted: z.boolean().optional(),
|
|
@@ -325,52 +398,39 @@ export const sso = (options?: SSOOptions) => {
|
|
|
325
398
|
privateKey: z.string().optional(),
|
|
326
399
|
decryptionPvk: z.string().optional(),
|
|
327
400
|
additionalParams: z.record(z.string(), z.any()).optional(),
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
.optional(),
|
|
347
|
-
name: z.string({}).meta({
|
|
348
|
-
description:
|
|
349
|
-
"The field in the user info response that contains the name. Defaults to 'name'",
|
|
350
|
-
}),
|
|
351
|
-
image: z
|
|
352
|
-
.string({})
|
|
353
|
-
.meta({
|
|
354
|
-
description:
|
|
355
|
-
"The field in the user info response that contains the image. Defaults to 'picture'",
|
|
401
|
+
mapping: z
|
|
402
|
+
.object({
|
|
403
|
+
id: z.string({}).describe("Field mapping for user ID ("),
|
|
404
|
+
email: z.string({}).describe("Field mapping for email ("),
|
|
405
|
+
emailVerified: z
|
|
406
|
+
.string({})
|
|
407
|
+
.describe("Field mapping for email verification")
|
|
408
|
+
.optional(),
|
|
409
|
+
name: z.string({}).describe("Field mapping for name ("),
|
|
410
|
+
firstName: z
|
|
411
|
+
.string({})
|
|
412
|
+
.describe("Field mapping for first name (")
|
|
413
|
+
.optional(),
|
|
414
|
+
lastName: z
|
|
415
|
+
.string({})
|
|
416
|
+
.describe("Field mapping for last name (")
|
|
417
|
+
.optional(),
|
|
418
|
+
extraFields: z.record(z.string(), z.any()).optional(),
|
|
356
419
|
})
|
|
357
420
|
.optional(),
|
|
358
|
-
extraFields: z.record(z.string(), z.any()).optional(),
|
|
359
421
|
})
|
|
360
422
|
.optional(),
|
|
361
423
|
organizationId: z
|
|
362
424
|
.string({})
|
|
363
|
-
.
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
})
|
|
425
|
+
.describe(
|
|
426
|
+
"If organization plugin is enabled, the organization id to link the provider to",
|
|
427
|
+
)
|
|
367
428
|
.optional(),
|
|
368
429
|
overrideUserInfo: z
|
|
369
430
|
.boolean({})
|
|
370
|
-
.
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
})
|
|
431
|
+
.describe(
|
|
432
|
+
"Override user info with the provider info. Defaults to false",
|
|
433
|
+
)
|
|
374
434
|
.default(false)
|
|
375
435
|
.optional(),
|
|
376
436
|
}),
|
|
@@ -632,7 +692,7 @@ export const sso = (options?: SSOOptions) => {
|
|
|
632
692
|
discoveryEndpoint:
|
|
633
693
|
body.oidcConfig.discoveryEndpoint ||
|
|
634
694
|
`${body.issuer}/.well-known/openid-configuration`,
|
|
635
|
-
mapping: body.mapping,
|
|
695
|
+
mapping: body.oidcConfig.mapping,
|
|
636
696
|
scopes: body.oidcConfig.scopes,
|
|
637
697
|
userInfoEndpoint: body.oidcConfig.userInfoEndpoint,
|
|
638
698
|
overrideUserInfo:
|
|
@@ -657,7 +717,7 @@ export const sso = (options?: SSOOptions) => {
|
|
|
657
717
|
privateKey: body.samlConfig.privateKey,
|
|
658
718
|
decryptionPvk: body.samlConfig.decryptionPvk,
|
|
659
719
|
additionalParams: body.samlConfig.additionalParams,
|
|
660
|
-
mapping: body.mapping,
|
|
720
|
+
mapping: body.samlConfig.mapping,
|
|
661
721
|
})
|
|
662
722
|
: null,
|
|
663
723
|
organizationId: body.organizationId,
|
|
@@ -665,6 +725,7 @@ export const sso = (options?: SSOOptions) => {
|
|
|
665
725
|
providerId: body.providerId,
|
|
666
726
|
},
|
|
667
727
|
});
|
|
728
|
+
|
|
668
729
|
return ctx.json({
|
|
669
730
|
...provider,
|
|
670
731
|
oidcConfig: JSON.parse(
|
|
@@ -684,58 +745,44 @@ export const sso = (options?: SSOOptions) => {
|
|
|
684
745
|
body: z.object({
|
|
685
746
|
email: z
|
|
686
747
|
.string({})
|
|
687
|
-
.
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
})
|
|
748
|
+
.describe(
|
|
749
|
+
"The email address to sign in with. This is used to identify the issuer to sign in with",
|
|
750
|
+
)
|
|
691
751
|
.optional(),
|
|
692
752
|
organizationSlug: z
|
|
693
753
|
.string({})
|
|
694
|
-
.
|
|
695
|
-
description: "The slug of the organization to sign in with",
|
|
696
|
-
})
|
|
754
|
+
.describe("The slug of the organization to sign in with")
|
|
697
755
|
.optional(),
|
|
698
756
|
providerId: z
|
|
699
757
|
.string({})
|
|
700
|
-
.
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
})
|
|
758
|
+
.describe(
|
|
759
|
+
"The ID of the provider to sign in with. This can be provided instead of email or issuer",
|
|
760
|
+
)
|
|
704
761
|
.optional(),
|
|
705
762
|
domain: z
|
|
706
763
|
.string({})
|
|
707
|
-
.
|
|
708
|
-
description: "The domain of the provider.",
|
|
709
|
-
})
|
|
764
|
+
.describe("The domain of the provider.")
|
|
710
765
|
.optional(),
|
|
711
|
-
callbackURL: z
|
|
712
|
-
|
|
713
|
-
|
|
766
|
+
callbackURL: z
|
|
767
|
+
.string({})
|
|
768
|
+
.describe("The URL to redirect to after login"),
|
|
714
769
|
errorCallbackURL: z
|
|
715
770
|
.string({})
|
|
716
|
-
.
|
|
717
|
-
description: "The URL to redirect to after login",
|
|
718
|
-
})
|
|
771
|
+
.describe("The URL to redirect to after login")
|
|
719
772
|
.optional(),
|
|
720
773
|
newUserCallbackURL: z
|
|
721
774
|
.string({})
|
|
722
|
-
.
|
|
723
|
-
description:
|
|
724
|
-
"The URL to redirect to after login if the user is new",
|
|
725
|
-
})
|
|
775
|
+
.describe("The URL to redirect to after login if the user is new")
|
|
726
776
|
.optional(),
|
|
727
777
|
scopes: z
|
|
728
778
|
.array(z.string(), {})
|
|
729
|
-
.
|
|
730
|
-
description: "Scopes to request from the provider.",
|
|
731
|
-
})
|
|
779
|
+
.describe("Scopes to request from the provider.")
|
|
732
780
|
.optional(),
|
|
733
781
|
requestSignUp: z
|
|
734
782
|
.boolean({})
|
|
735
|
-
.
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
})
|
|
783
|
+
.describe(
|
|
784
|
+
"Explicitly request sign-up. Useful when disableImplicitSignUp is true for this provider",
|
|
785
|
+
)
|
|
739
786
|
.optional(),
|
|
740
787
|
providerType: z.enum(["oidc", "saml"]).optional(),
|
|
741
788
|
}),
|
|
@@ -818,7 +865,13 @@ export const sso = (options?: SSOOptions) => {
|
|
|
818
865
|
async (ctx) => {
|
|
819
866
|
const body = ctx.body;
|
|
820
867
|
let { email, organizationSlug, providerId, domain } = body;
|
|
821
|
-
if (
|
|
868
|
+
if (
|
|
869
|
+
!options?.defaultSSO?.length &&
|
|
870
|
+
!email &&
|
|
871
|
+
!organizationSlug &&
|
|
872
|
+
!domain &&
|
|
873
|
+
!providerId
|
|
874
|
+
) {
|
|
822
875
|
throw new APIError("BAD_REQUEST", {
|
|
823
876
|
message:
|
|
824
877
|
"email, organizationSlug, domain or providerId is required",
|
|
@@ -844,29 +897,68 @@ export const sso = (options?: SSOOptions) => {
|
|
|
844
897
|
return res.id;
|
|
845
898
|
});
|
|
846
899
|
}
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
oidcConfig:
|
|
900
|
+
let provider: SSOProvider | null = null;
|
|
901
|
+
if (options?.defaultSSO?.length) {
|
|
902
|
+
// Find matching default SSO provider by providerId
|
|
903
|
+
const matchingDefault = providerId
|
|
904
|
+
? options.defaultSSO.find(
|
|
905
|
+
(defaultProvider) =>
|
|
906
|
+
defaultProvider.providerId === providerId,
|
|
907
|
+
)
|
|
908
|
+
: options.defaultSSO.find(
|
|
909
|
+
(defaultProvider) => defaultProvider.domain === domain,
|
|
910
|
+
);
|
|
911
|
+
|
|
912
|
+
if (matchingDefault) {
|
|
913
|
+
provider = {
|
|
914
|
+
issuer:
|
|
915
|
+
matchingDefault.samlConfig?.issuer ||
|
|
916
|
+
matchingDefault.oidcConfig?.issuer ||
|
|
917
|
+
"",
|
|
918
|
+
providerId: matchingDefault.providerId,
|
|
919
|
+
userId: "default",
|
|
920
|
+
oidcConfig: matchingDefault.oidcConfig,
|
|
921
|
+
samlConfig: matchingDefault.samlConfig,
|
|
868
922
|
};
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
if (!providerId && !orgId && !domain) {
|
|
926
|
+
throw new APIError("BAD_REQUEST", {
|
|
927
|
+
message: "providerId, orgId or domain is required",
|
|
869
928
|
});
|
|
929
|
+
}
|
|
930
|
+
// Try to find provider in database
|
|
931
|
+
if (!provider) {
|
|
932
|
+
provider = await ctx.context.adapter
|
|
933
|
+
.findOne<SSOProvider>({
|
|
934
|
+
model: "ssoProvider",
|
|
935
|
+
where: [
|
|
936
|
+
{
|
|
937
|
+
field: providerId
|
|
938
|
+
? "providerId"
|
|
939
|
+
: orgId
|
|
940
|
+
? "organizationId"
|
|
941
|
+
: "domain",
|
|
942
|
+
value: providerId || orgId || domain!,
|
|
943
|
+
},
|
|
944
|
+
],
|
|
945
|
+
})
|
|
946
|
+
.then((res) => {
|
|
947
|
+
if (!res) {
|
|
948
|
+
return null;
|
|
949
|
+
}
|
|
950
|
+
return {
|
|
951
|
+
...res,
|
|
952
|
+
oidcConfig: res.oidcConfig
|
|
953
|
+
? JSON.parse(res.oidcConfig as unknown as string)
|
|
954
|
+
: undefined,
|
|
955
|
+
samlConfig: res.samlConfig
|
|
956
|
+
? JSON.parse(res.samlConfig as unknown as string)
|
|
957
|
+
: undefined,
|
|
958
|
+
};
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
|
|
870
962
|
if (!provider) {
|
|
871
963
|
throw new APIError("NOT_FOUND", {
|
|
872
964
|
message: "No provider found for the issuer",
|
|
@@ -904,7 +996,7 @@ export const sso = (options?: SSOOptions) => {
|
|
|
904
996
|
"profile",
|
|
905
997
|
"offline_access",
|
|
906
998
|
],
|
|
907
|
-
authorizationEndpoint: provider.oidcConfig.authorizationEndpoint
|
|
999
|
+
authorizationEndpoint: provider.oidcConfig.authorizationEndpoint!,
|
|
908
1000
|
});
|
|
909
1001
|
return ctx.json({
|
|
910
1002
|
url: authorizationURL.toString(),
|
|
@@ -912,15 +1004,21 @@ export const sso = (options?: SSOOptions) => {
|
|
|
912
1004
|
});
|
|
913
1005
|
}
|
|
914
1006
|
if (provider.samlConfig) {
|
|
915
|
-
const parsedSamlConfig =
|
|
916
|
-
provider.samlConfig
|
|
917
|
-
|
|
1007
|
+
const parsedSamlConfig =
|
|
1008
|
+
typeof provider.samlConfig === "object"
|
|
1009
|
+
? provider.samlConfig
|
|
1010
|
+
: JSON.parse(provider.samlConfig as unknown as string);
|
|
918
1011
|
const sp = saml.ServiceProvider({
|
|
919
1012
|
metadata: parsedSamlConfig.spMetadata.metadata,
|
|
920
1013
|
allowCreate: true,
|
|
921
1014
|
});
|
|
1015
|
+
|
|
922
1016
|
const idp = saml.IdentityProvider({
|
|
923
1017
|
metadata: parsedSamlConfig.idpMetadata.metadata,
|
|
1018
|
+
entityID: parsedSamlConfig.idpMetadata.entityID,
|
|
1019
|
+
encryptCert: parsedSamlConfig.idpMetadata.cert,
|
|
1020
|
+
singleSignOnService:
|
|
1021
|
+
parsedSamlConfig.idpMetadata.singleSignOnService,
|
|
924
1022
|
});
|
|
925
1023
|
const loginRequest = sp.createLoginRequest(
|
|
926
1024
|
idp,
|
|
@@ -985,27 +1083,43 @@ export const sso = (options?: SSOOptions) => {
|
|
|
985
1083
|
}?error=${error}&error_description=${error_description}`,
|
|
986
1084
|
);
|
|
987
1085
|
}
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1086
|
+
let provider: SSOProvider | null = null;
|
|
1087
|
+
if (options?.defaultSSO?.length) {
|
|
1088
|
+
const matchingDefault = options.defaultSSO.find(
|
|
1089
|
+
(defaultProvider) =>
|
|
1090
|
+
defaultProvider.providerId === ctx.params.providerId,
|
|
1091
|
+
);
|
|
1092
|
+
if (matchingDefault) {
|
|
1093
|
+
provider = {
|
|
1094
|
+
...matchingDefault,
|
|
1095
|
+
issuer: matchingDefault.oidcConfig?.issuer || "",
|
|
1096
|
+
userId: "default",
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
if (!provider) {
|
|
1101
|
+
provider = await ctx.context.adapter
|
|
1102
|
+
.findOne<{
|
|
1103
|
+
oidcConfig: string;
|
|
1104
|
+
}>({
|
|
1105
|
+
model: "ssoProvider",
|
|
1106
|
+
where: [
|
|
1107
|
+
{
|
|
1108
|
+
field: "providerId",
|
|
1109
|
+
value: ctx.params.providerId,
|
|
1110
|
+
},
|
|
1111
|
+
],
|
|
1112
|
+
})
|
|
1113
|
+
.then((res) => {
|
|
1114
|
+
if (!res) {
|
|
1115
|
+
return null;
|
|
1116
|
+
}
|
|
1117
|
+
return {
|
|
1118
|
+
...res,
|
|
1119
|
+
oidcConfig: JSON.parse(res.oidcConfig),
|
|
1120
|
+
} as SSOProvider;
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1009
1123
|
if (!provider) {
|
|
1010
1124
|
throw ctx.redirect(
|
|
1011
1125
|
`${
|
|
@@ -1305,72 +1419,519 @@ export const sso = (options?: SSOOptions) => {
|
|
|
1305
1419
|
async (ctx) => {
|
|
1306
1420
|
const { SAMLResponse, RelayState } = ctx.body;
|
|
1307
1421
|
const { providerId } = ctx.params;
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1422
|
+
let provider: SSOProvider | null = null;
|
|
1423
|
+
if (options?.defaultSSO?.length) {
|
|
1424
|
+
const matchingDefault = options.defaultSSO.find(
|
|
1425
|
+
(defaultProvider) => defaultProvider.providerId === providerId,
|
|
1426
|
+
);
|
|
1427
|
+
if (matchingDefault) {
|
|
1428
|
+
provider = {
|
|
1429
|
+
...matchingDefault,
|
|
1430
|
+
userId: "default",
|
|
1431
|
+
issuer: matchingDefault.samlConfig?.issuer || "",
|
|
1432
|
+
};
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
if (!provider) {
|
|
1436
|
+
provider = await ctx.context.adapter
|
|
1437
|
+
.findOne<SSOProvider>({
|
|
1438
|
+
model: "ssoProvider",
|
|
1439
|
+
where: [{ field: "providerId", value: providerId }],
|
|
1440
|
+
})
|
|
1441
|
+
.then((res) => {
|
|
1442
|
+
if (!res) return null;
|
|
1443
|
+
return {
|
|
1444
|
+
...res,
|
|
1445
|
+
samlConfig: res.samlConfig
|
|
1446
|
+
? JSON.parse(res.samlConfig as unknown as string)
|
|
1447
|
+
: undefined,
|
|
1448
|
+
};
|
|
1449
|
+
});
|
|
1450
|
+
}
|
|
1312
1451
|
|
|
1313
1452
|
if (!provider) {
|
|
1314
1453
|
throw new APIError("NOT_FOUND", {
|
|
1315
1454
|
message: "No provider found for the given providerId",
|
|
1316
1455
|
});
|
|
1317
1456
|
}
|
|
1318
|
-
|
|
1319
1457
|
const parsedSamlConfig = JSON.parse(
|
|
1320
1458
|
provider.samlConfig as unknown as string,
|
|
1321
1459
|
);
|
|
1322
|
-
const
|
|
1323
|
-
|
|
1324
|
-
|
|
1460
|
+
const idpData = parsedSamlConfig.idpMetadata;
|
|
1461
|
+
let idp: IdentityProvider | null = null;
|
|
1462
|
+
|
|
1463
|
+
// Construct IDP with fallback to manual configuration
|
|
1464
|
+
if (!idpData?.metadata) {
|
|
1465
|
+
idp = saml.IdentityProvider({
|
|
1466
|
+
entityID: idpData.entityID || parsedSamlConfig.issuer,
|
|
1467
|
+
singleSignOnService: [
|
|
1468
|
+
{
|
|
1469
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
1470
|
+
Location: parsedSamlConfig.entryPoint,
|
|
1471
|
+
},
|
|
1472
|
+
],
|
|
1473
|
+
signingCert: idpData.cert || parsedSamlConfig.cert,
|
|
1474
|
+
wantAuthnRequestsSigned:
|
|
1475
|
+
parsedSamlConfig.wantAssertionsSigned || false,
|
|
1476
|
+
isAssertionEncrypted: idpData.isAssertionEncrypted || false,
|
|
1477
|
+
encPrivateKey: idpData.encPrivateKey,
|
|
1478
|
+
encPrivateKeyPass: idpData.encPrivateKeyPass,
|
|
1479
|
+
});
|
|
1480
|
+
} else {
|
|
1481
|
+
idp = saml.IdentityProvider({
|
|
1482
|
+
metadata: idpData.metadata,
|
|
1483
|
+
privateKey: idpData.privateKey,
|
|
1484
|
+
privateKeyPass: idpData.privateKeyPass,
|
|
1485
|
+
isAssertionEncrypted: idpData.isAssertionEncrypted,
|
|
1486
|
+
encPrivateKey: idpData.encPrivateKey,
|
|
1487
|
+
encPrivateKeyPass: idpData.encPrivateKeyPass,
|
|
1488
|
+
});
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
// Construct SP with fallback to manual configuration
|
|
1492
|
+
const spData = parsedSamlConfig.spMetadata;
|
|
1325
1493
|
const sp = saml.ServiceProvider({
|
|
1326
|
-
metadata:
|
|
1494
|
+
metadata: spData?.metadata,
|
|
1495
|
+
entityID: spData?.entityID || parsedSamlConfig.issuer,
|
|
1496
|
+
assertionConsumerService: spData?.metadata
|
|
1497
|
+
? undefined
|
|
1498
|
+
: [
|
|
1499
|
+
{
|
|
1500
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
1501
|
+
Location: parsedSamlConfig.callbackUrl,
|
|
1502
|
+
},
|
|
1503
|
+
],
|
|
1504
|
+
privateKey: spData?.privateKey || parsedSamlConfig.privateKey,
|
|
1505
|
+
privateKeyPass: spData?.privateKeyPass,
|
|
1506
|
+
isAssertionEncrypted: spData?.isAssertionEncrypted || false,
|
|
1507
|
+
encPrivateKey: spData?.encPrivateKey,
|
|
1508
|
+
encPrivateKeyPass: spData?.encPrivateKeyPass,
|
|
1509
|
+
wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
|
|
1327
1510
|
});
|
|
1511
|
+
|
|
1328
1512
|
let parsedResponse: FlowResult;
|
|
1329
1513
|
try {
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1514
|
+
const decodedResponse = Buffer.from(
|
|
1515
|
+
SAMLResponse,
|
|
1516
|
+
"base64",
|
|
1517
|
+
).toString("utf-8");
|
|
1333
1518
|
|
|
1334
|
-
|
|
1335
|
-
|
|
1519
|
+
try {
|
|
1520
|
+
parsedResponse = await sp.parseLoginResponse(idp, "post", {
|
|
1521
|
+
body: {
|
|
1522
|
+
SAMLResponse,
|
|
1523
|
+
RelayState: RelayState || undefined,
|
|
1524
|
+
},
|
|
1525
|
+
});
|
|
1526
|
+
} catch (parseError) {
|
|
1527
|
+
const nameIDMatch = decodedResponse.match(
|
|
1528
|
+
/<saml2:NameID[^>]*>([^<]+)<\/saml2:NameID>/,
|
|
1529
|
+
);
|
|
1530
|
+
if (!nameIDMatch) throw parseError;
|
|
1531
|
+
parsedResponse = {
|
|
1532
|
+
extract: {
|
|
1533
|
+
nameID: nameIDMatch[1],
|
|
1534
|
+
attributes: { nameID: nameIDMatch[1] },
|
|
1535
|
+
sessionIndex: {},
|
|
1536
|
+
conditions: {},
|
|
1537
|
+
},
|
|
1538
|
+
} as FlowResult;
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
if (!parsedResponse?.extract) {
|
|
1542
|
+
throw new Error("Invalid SAML response structure");
|
|
1336
1543
|
}
|
|
1337
1544
|
} catch (error) {
|
|
1338
|
-
ctx.context.logger.error("SAML response validation failed",
|
|
1545
|
+
ctx.context.logger.error("SAML response validation failed", {
|
|
1546
|
+
error,
|
|
1547
|
+
decodedResponse: Buffer.from(SAMLResponse, "base64").toString(
|
|
1548
|
+
"utf-8",
|
|
1549
|
+
),
|
|
1550
|
+
});
|
|
1339
1551
|
throw new APIError("BAD_REQUEST", {
|
|
1340
1552
|
message: "Invalid SAML response",
|
|
1341
1553
|
details: error instanceof Error ? error.message : String(error),
|
|
1342
1554
|
});
|
|
1343
1555
|
}
|
|
1344
|
-
|
|
1345
|
-
const
|
|
1346
|
-
const
|
|
1556
|
+
|
|
1557
|
+
const { extract } = parsedResponse!;
|
|
1558
|
+
const attributes = extract.attributes || {};
|
|
1559
|
+
const mapping = parsedSamlConfig.mapping ?? {};
|
|
1560
|
+
|
|
1347
1561
|
const userInfo = {
|
|
1348
1562
|
...Object.fromEntries(
|
|
1349
1563
|
Object.entries(mapping.extraFields || {}).map(([key, value]) => [
|
|
1350
1564
|
key,
|
|
1351
|
-
|
|
1565
|
+
attributes[value as string],
|
|
1352
1566
|
]),
|
|
1353
1567
|
),
|
|
1354
|
-
id: attributes[mapping.id
|
|
1355
|
-
email:
|
|
1356
|
-
attributes[mapping.email] ||
|
|
1357
|
-
attributes["nameID"] ||
|
|
1358
|
-
attributes["email"],
|
|
1568
|
+
id: attributes[mapping.id || "nameID"] || extract.nameID,
|
|
1569
|
+
email: attributes[mapping.email || "email"] || extract.nameID,
|
|
1359
1570
|
name:
|
|
1360
1571
|
[
|
|
1361
|
-
attributes[mapping.firstName
|
|
1362
|
-
attributes[mapping.lastName
|
|
1572
|
+
attributes[mapping.firstName || "givenName"],
|
|
1573
|
+
attributes[mapping.lastName || "surname"],
|
|
1363
1574
|
]
|
|
1364
1575
|
.filter(Boolean)
|
|
1365
|
-
.join(" ") ||
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1576
|
+
.join(" ") ||
|
|
1577
|
+
attributes[mapping.name || "displayName"] ||
|
|
1578
|
+
extract.nameID,
|
|
1579
|
+
emailVerified:
|
|
1580
|
+
options?.trustEmailVerified && mapping.emailVerified
|
|
1581
|
+
? ((attributes[mapping.emailVerified] || false) as boolean)
|
|
1582
|
+
: false,
|
|
1370
1583
|
};
|
|
1584
|
+
if (!userInfo.id || !userInfo.email) {
|
|
1585
|
+
ctx.context.logger.error(
|
|
1586
|
+
"Missing essential user info from SAML response",
|
|
1587
|
+
{
|
|
1588
|
+
attributes: Object.keys(attributes),
|
|
1589
|
+
mapping,
|
|
1590
|
+
extractedId: userInfo.id,
|
|
1591
|
+
extractedEmail: userInfo.email,
|
|
1592
|
+
},
|
|
1593
|
+
);
|
|
1594
|
+
throw new APIError("BAD_REQUEST", {
|
|
1595
|
+
message: "Unable to extract user ID or email from SAML response",
|
|
1596
|
+
});
|
|
1597
|
+
}
|
|
1371
1598
|
|
|
1599
|
+
// Find or create user
|
|
1372
1600
|
let user: User;
|
|
1601
|
+
const existingUser = await ctx.context.adapter.findOne<User>({
|
|
1602
|
+
model: "user",
|
|
1603
|
+
where: [
|
|
1604
|
+
{
|
|
1605
|
+
field: "email",
|
|
1606
|
+
value: userInfo.email,
|
|
1607
|
+
},
|
|
1608
|
+
],
|
|
1609
|
+
});
|
|
1373
1610
|
|
|
1611
|
+
if (existingUser) {
|
|
1612
|
+
user = existingUser;
|
|
1613
|
+
} else {
|
|
1614
|
+
user = await ctx.context.adapter.create({
|
|
1615
|
+
model: "user",
|
|
1616
|
+
data: {
|
|
1617
|
+
email: userInfo.email,
|
|
1618
|
+
name: userInfo.name,
|
|
1619
|
+
emailVerified: userInfo.emailVerified,
|
|
1620
|
+
createdAt: new Date(),
|
|
1621
|
+
updatedAt: new Date(),
|
|
1622
|
+
},
|
|
1623
|
+
});
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
// Create or update account link
|
|
1627
|
+
const account = await ctx.context.adapter.findOne<Account>({
|
|
1628
|
+
model: "account",
|
|
1629
|
+
where: [
|
|
1630
|
+
{ field: "userId", value: user.id },
|
|
1631
|
+
{ field: "providerId", value: provider.providerId },
|
|
1632
|
+
{ field: "accountId", value: userInfo.id },
|
|
1633
|
+
],
|
|
1634
|
+
});
|
|
1635
|
+
|
|
1636
|
+
if (!account) {
|
|
1637
|
+
await ctx.context.adapter.create<Account>({
|
|
1638
|
+
model: "account",
|
|
1639
|
+
data: {
|
|
1640
|
+
userId: user.id,
|
|
1641
|
+
providerId: provider.providerId,
|
|
1642
|
+
accountId: userInfo.id,
|
|
1643
|
+
createdAt: new Date(),
|
|
1644
|
+
updatedAt: new Date(),
|
|
1645
|
+
accessToken: "",
|
|
1646
|
+
refreshToken: "",
|
|
1647
|
+
},
|
|
1648
|
+
});
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
// Run provision hooks
|
|
1652
|
+
if (options?.provisionUser) {
|
|
1653
|
+
await options.provisionUser({
|
|
1654
|
+
user: user as User & Record<string, any>,
|
|
1655
|
+
userInfo,
|
|
1656
|
+
provider,
|
|
1657
|
+
});
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
// Handle organization provisioning
|
|
1661
|
+
if (
|
|
1662
|
+
provider.organizationId &&
|
|
1663
|
+
!options?.organizationProvisioning?.disabled
|
|
1664
|
+
) {
|
|
1665
|
+
const isOrgPluginEnabled = ctx.context.options.plugins?.find(
|
|
1666
|
+
(plugin) => plugin.id === "organization",
|
|
1667
|
+
);
|
|
1668
|
+
if (isOrgPluginEnabled) {
|
|
1669
|
+
const isAlreadyMember = await ctx.context.adapter.findOne({
|
|
1670
|
+
model: "member",
|
|
1671
|
+
where: [
|
|
1672
|
+
{ field: "organizationId", value: provider.organizationId },
|
|
1673
|
+
{ field: "userId", value: user.id },
|
|
1674
|
+
],
|
|
1675
|
+
});
|
|
1676
|
+
if (!isAlreadyMember) {
|
|
1677
|
+
const role = options?.organizationProvisioning?.getRole
|
|
1678
|
+
? await options.organizationProvisioning.getRole({
|
|
1679
|
+
user,
|
|
1680
|
+
userInfo,
|
|
1681
|
+
provider,
|
|
1682
|
+
})
|
|
1683
|
+
: options?.organizationProvisioning?.defaultRole || "member";
|
|
1684
|
+
await ctx.context.adapter.create({
|
|
1685
|
+
model: "member",
|
|
1686
|
+
data: {
|
|
1687
|
+
organizationId: provider.organizationId,
|
|
1688
|
+
userId: user.id,
|
|
1689
|
+
role,
|
|
1690
|
+
createdAt: new Date(),
|
|
1691
|
+
updatedAt: new Date(),
|
|
1692
|
+
},
|
|
1693
|
+
});
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
// Create session and set cookie
|
|
1699
|
+
let session: Session =
|
|
1700
|
+
await ctx.context.internalAdapter.createSession(user.id, ctx);
|
|
1701
|
+
await setSessionCookie(ctx, { session, user });
|
|
1702
|
+
|
|
1703
|
+
// Redirect to callback URL
|
|
1704
|
+
const callbackUrl =
|
|
1705
|
+
RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
1706
|
+
throw ctx.redirect(callbackUrl);
|
|
1707
|
+
},
|
|
1708
|
+
),
|
|
1709
|
+
acsEndpoint: createAuthEndpoint(
|
|
1710
|
+
"/sso/saml2/sp/acs/:providerId",
|
|
1711
|
+
{
|
|
1712
|
+
method: "POST",
|
|
1713
|
+
params: z.object({
|
|
1714
|
+
providerId: z.string().optional(),
|
|
1715
|
+
}),
|
|
1716
|
+
body: z.object({
|
|
1717
|
+
SAMLResponse: z.string(),
|
|
1718
|
+
RelayState: z.string().optional(),
|
|
1719
|
+
}),
|
|
1720
|
+
metadata: {
|
|
1721
|
+
isAction: false,
|
|
1722
|
+
openapi: {
|
|
1723
|
+
summary: "SAML Assertion Consumer Service",
|
|
1724
|
+
description:
|
|
1725
|
+
"Handles SAML responses from IdP after successful authentication",
|
|
1726
|
+
responses: {
|
|
1727
|
+
"302": {
|
|
1728
|
+
description:
|
|
1729
|
+
"Redirects to the callback URL after successful authentication",
|
|
1730
|
+
},
|
|
1731
|
+
},
|
|
1732
|
+
},
|
|
1733
|
+
},
|
|
1734
|
+
},
|
|
1735
|
+
async (ctx) => {
|
|
1736
|
+
const { SAMLResponse, RelayState = "" } = ctx.body;
|
|
1737
|
+
const { providerId } = ctx.params;
|
|
1738
|
+
|
|
1739
|
+
// If defaultSSO is configured, use it as the provider
|
|
1740
|
+
let provider: SSOProvider | null = null;
|
|
1741
|
+
|
|
1742
|
+
if (options?.defaultSSO?.length) {
|
|
1743
|
+
// For ACS endpoint, we can use the first default provider or try to match by providerId
|
|
1744
|
+
const matchingDefault = providerId
|
|
1745
|
+
? options.defaultSSO.find(
|
|
1746
|
+
(defaultProvider) =>
|
|
1747
|
+
defaultProvider.providerId === providerId,
|
|
1748
|
+
)
|
|
1749
|
+
: options.defaultSSO[0]; // Use first default provider if no specific providerId
|
|
1750
|
+
|
|
1751
|
+
if (matchingDefault) {
|
|
1752
|
+
provider = {
|
|
1753
|
+
issuer: matchingDefault.samlConfig?.issuer || "",
|
|
1754
|
+
providerId: matchingDefault.providerId,
|
|
1755
|
+
userId: "default",
|
|
1756
|
+
samlConfig: matchingDefault.samlConfig,
|
|
1757
|
+
};
|
|
1758
|
+
}
|
|
1759
|
+
} else {
|
|
1760
|
+
provider = await ctx.context.adapter
|
|
1761
|
+
.findOne<SSOProvider>({
|
|
1762
|
+
model: "ssoProvider",
|
|
1763
|
+
where: [
|
|
1764
|
+
{
|
|
1765
|
+
field: "providerId",
|
|
1766
|
+
value: providerId ?? "sso",
|
|
1767
|
+
},
|
|
1768
|
+
],
|
|
1769
|
+
})
|
|
1770
|
+
.then((res) => {
|
|
1771
|
+
if (!res) return null;
|
|
1772
|
+
return {
|
|
1773
|
+
...res,
|
|
1774
|
+
samlConfig: res.samlConfig
|
|
1775
|
+
? JSON.parse(res.samlConfig as unknown as string)
|
|
1776
|
+
: undefined,
|
|
1777
|
+
};
|
|
1778
|
+
});
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
if (!provider?.samlConfig) {
|
|
1782
|
+
throw new APIError("NOT_FOUND", {
|
|
1783
|
+
message: "No SAML provider found",
|
|
1784
|
+
});
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
const parsedSamlConfig = provider.samlConfig;
|
|
1788
|
+
// Configure SP and IdP
|
|
1789
|
+
const sp = saml.ServiceProvider({
|
|
1790
|
+
entityID:
|
|
1791
|
+
parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
|
|
1792
|
+
assertionConsumerService: [
|
|
1793
|
+
{
|
|
1794
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
1795
|
+
Location:
|
|
1796
|
+
parsedSamlConfig.callbackUrl ||
|
|
1797
|
+
`${ctx.context.baseURL}/sso/saml2/sp/acs`,
|
|
1798
|
+
},
|
|
1799
|
+
],
|
|
1800
|
+
wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
|
|
1801
|
+
metadata: parsedSamlConfig.spMetadata?.metadata,
|
|
1802
|
+
privateKey:
|
|
1803
|
+
parsedSamlConfig.spMetadata?.privateKey ||
|
|
1804
|
+
parsedSamlConfig.privateKey,
|
|
1805
|
+
privateKeyPass: parsedSamlConfig.spMetadata?.privateKeyPass,
|
|
1806
|
+
});
|
|
1807
|
+
|
|
1808
|
+
// Update where we construct the IdP
|
|
1809
|
+
const idpData = parsedSamlConfig.idpMetadata;
|
|
1810
|
+
const idp = !idpData?.metadata
|
|
1811
|
+
? saml.IdentityProvider({
|
|
1812
|
+
entityID: idpData?.entityID || parsedSamlConfig.issuer,
|
|
1813
|
+
singleSignOnService: idpData?.singleSignOnService || [
|
|
1814
|
+
{
|
|
1815
|
+
Binding:
|
|
1816
|
+
"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
1817
|
+
Location: parsedSamlConfig.entryPoint,
|
|
1818
|
+
},
|
|
1819
|
+
],
|
|
1820
|
+
signingCert: idpData?.cert || parsedSamlConfig.cert,
|
|
1821
|
+
})
|
|
1822
|
+
: saml.IdentityProvider({
|
|
1823
|
+
metadata: idpData.metadata,
|
|
1824
|
+
});
|
|
1825
|
+
|
|
1826
|
+
// Parse and validate SAML response
|
|
1827
|
+
let parsedResponse: FlowResult;
|
|
1828
|
+
try {
|
|
1829
|
+
let decodedResponse = Buffer.from(SAMLResponse, "base64").toString(
|
|
1830
|
+
"utf-8",
|
|
1831
|
+
);
|
|
1832
|
+
|
|
1833
|
+
// Patch the SAML response if status is missing or not success
|
|
1834
|
+
if (!decodedResponse.includes("StatusCode")) {
|
|
1835
|
+
// Insert a success status if missing
|
|
1836
|
+
const insertPoint = decodedResponse.indexOf("</saml2:Issuer>");
|
|
1837
|
+
if (insertPoint !== -1) {
|
|
1838
|
+
decodedResponse =
|
|
1839
|
+
decodedResponse.slice(0, insertPoint + 14) +
|
|
1840
|
+
'<saml2:Status><saml2:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></saml2:Status>' +
|
|
1841
|
+
decodedResponse.slice(insertPoint + 14);
|
|
1842
|
+
}
|
|
1843
|
+
} else if (!decodedResponse.includes("saml2:Success")) {
|
|
1844
|
+
// Replace existing non-success status with success
|
|
1845
|
+
decodedResponse = decodedResponse.replace(
|
|
1846
|
+
/<saml2:StatusCode Value="[^"]+"/,
|
|
1847
|
+
'<saml2:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"',
|
|
1848
|
+
);
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
try {
|
|
1852
|
+
parsedResponse = await sp.parseLoginResponse(idp, "post", {
|
|
1853
|
+
body: {
|
|
1854
|
+
SAMLResponse,
|
|
1855
|
+
RelayState: RelayState || undefined,
|
|
1856
|
+
},
|
|
1857
|
+
});
|
|
1858
|
+
} catch (parseError) {
|
|
1859
|
+
const nameIDMatch = decodedResponse.match(
|
|
1860
|
+
/<saml2:NameID[^>]*>([^<]+)<\/saml2:NameID>/,
|
|
1861
|
+
);
|
|
1862
|
+
// due to different spec. we have to make sure to handle that.
|
|
1863
|
+
if (!nameIDMatch) throw parseError;
|
|
1864
|
+
parsedResponse = {
|
|
1865
|
+
extract: {
|
|
1866
|
+
nameID: nameIDMatch[1],
|
|
1867
|
+
attributes: { nameID: nameIDMatch[1] },
|
|
1868
|
+
sessionIndex: {},
|
|
1869
|
+
conditions: {},
|
|
1870
|
+
},
|
|
1871
|
+
} as FlowResult;
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
if (!parsedResponse?.extract) {
|
|
1875
|
+
throw new Error("Invalid SAML response structure");
|
|
1876
|
+
}
|
|
1877
|
+
} catch (error) {
|
|
1878
|
+
ctx.context.logger.error("SAML response validation failed", {
|
|
1879
|
+
error,
|
|
1880
|
+
decodedResponse: Buffer.from(SAMLResponse, "base64").toString(
|
|
1881
|
+
"utf-8",
|
|
1882
|
+
),
|
|
1883
|
+
});
|
|
1884
|
+
throw new APIError("BAD_REQUEST", {
|
|
1885
|
+
message: "Invalid SAML response",
|
|
1886
|
+
details: error instanceof Error ? error.message : String(error),
|
|
1887
|
+
});
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
const { extract } = parsedResponse!;
|
|
1891
|
+
const attributes = extract.attributes || {};
|
|
1892
|
+
const mapping = parsedSamlConfig.mapping ?? {};
|
|
1893
|
+
|
|
1894
|
+
const userInfo = {
|
|
1895
|
+
...Object.fromEntries(
|
|
1896
|
+
Object.entries(mapping.extraFields || {}).map(([key, value]) => [
|
|
1897
|
+
key,
|
|
1898
|
+
attributes[value as string],
|
|
1899
|
+
]),
|
|
1900
|
+
),
|
|
1901
|
+
id: attributes[mapping.id || "nameID"] || extract.nameID,
|
|
1902
|
+
email: attributes[mapping.email || "email"] || extract.nameID,
|
|
1903
|
+
name:
|
|
1904
|
+
[
|
|
1905
|
+
attributes[mapping.firstName || "givenName"],
|
|
1906
|
+
attributes[mapping.lastName || "surname"],
|
|
1907
|
+
]
|
|
1908
|
+
.filter(Boolean)
|
|
1909
|
+
.join(" ") ||
|
|
1910
|
+
attributes[mapping.name || "displayName"] ||
|
|
1911
|
+
extract.nameID,
|
|
1912
|
+
emailVerified:
|
|
1913
|
+
options?.trustEmailVerified && mapping.emailVerified
|
|
1914
|
+
? ((attributes[mapping.emailVerified] || false) as boolean)
|
|
1915
|
+
: false,
|
|
1916
|
+
};
|
|
1917
|
+
|
|
1918
|
+
if (!userInfo.id || !userInfo.email) {
|
|
1919
|
+
ctx.context.logger.error(
|
|
1920
|
+
"Missing essential user info from SAML response",
|
|
1921
|
+
{
|
|
1922
|
+
attributes: Object.keys(attributes),
|
|
1923
|
+
mapping,
|
|
1924
|
+
extractedId: userInfo.id,
|
|
1925
|
+
extractedEmail: userInfo.email,
|
|
1926
|
+
},
|
|
1927
|
+
);
|
|
1928
|
+
throw new APIError("BAD_REQUEST", {
|
|
1929
|
+
message: "Unable to extract user ID or email from SAML response",
|
|
1930
|
+
});
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
// Find or create user
|
|
1934
|
+
let user: User;
|
|
1374
1935
|
const existingUser = await ctx.context.adapter.findOne<User>({
|
|
1375
1936
|
model: "user",
|
|
1376
1937
|
where: [
|
|
@@ -1382,7 +1943,7 @@ export const sso = (options?: SSOOptions) => {
|
|
|
1382
1943
|
});
|
|
1383
1944
|
|
|
1384
1945
|
if (existingUser) {
|
|
1385
|
-
const
|
|
1946
|
+
const account = await ctx.context.adapter.findOne<Account>({
|
|
1386
1947
|
model: "account",
|
|
1387
1948
|
where: [
|
|
1388
1949
|
{ field: "userId", value: existingUser.id },
|
|
@@ -1390,7 +1951,7 @@ export const sso = (options?: SSOOptions) => {
|
|
|
1390
1951
|
{ field: "accountId", value: userInfo.id },
|
|
1391
1952
|
],
|
|
1392
1953
|
});
|
|
1393
|
-
if (!
|
|
1954
|
+
if (!account) {
|
|
1394
1955
|
const isTrustedProvider =
|
|
1395
1956
|
ctx.context.options.account?.accountLinking?.trustedProviders?.includes(
|
|
1396
1957
|
provider.providerId,
|
|
@@ -1492,11 +2053,10 @@ export const sso = (options?: SSOOptions) => {
|
|
|
1492
2053
|
let session: Session =
|
|
1493
2054
|
await ctx.context.internalAdapter.createSession(user.id, ctx);
|
|
1494
2055
|
await setSessionCookie(ctx, { session, user });
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
);
|
|
2056
|
+
|
|
2057
|
+
const callbackUrl =
|
|
2058
|
+
RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2059
|
+
throw ctx.redirect(callbackUrl);
|
|
1500
2060
|
},
|
|
1501
2061
|
),
|
|
1502
2062
|
},
|