@better-auth/sso 1.3.18 → 1.4.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +4 -4
- package/CHANGELOG.md +20 -0
- package/dist/index.cjs +91 -541
- package/dist/index.d.cts +39 -186
- package/dist/index.d.mts +39 -186
- package/dist/index.d.ts +39 -186
- package/dist/index.mjs +91 -541
- package/package.json +5 -5
- package/src/index.ts +142 -798
- package/src/oidc.test.ts +21 -84
- package/src/saml.test.ts +8 -163
- package/tsconfig.json +15 -9
package/src/index.ts
CHANGED
|
@@ -24,7 +24,6 @@ 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";
|
|
28
27
|
|
|
29
28
|
const fastValidator = {
|
|
30
29
|
async validate(xml: string) {
|
|
@@ -38,25 +37,6 @@ const fastValidator = {
|
|
|
38
37
|
|
|
39
38
|
saml.setSchemaValidator(fastValidator);
|
|
40
39
|
|
|
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
|
-
|
|
60
40
|
export interface OIDCConfig {
|
|
61
41
|
issuer: string;
|
|
62
42
|
pkce: boolean;
|
|
@@ -70,49 +50,30 @@ export interface OIDCConfig {
|
|
|
70
50
|
tokenEndpoint?: string;
|
|
71
51
|
tokenEndpointAuthentication?: "client_secret_post" | "client_secret_basic";
|
|
72
52
|
jwksEndpoint?: string;
|
|
73
|
-
mapping?:
|
|
53
|
+
mapping?: {
|
|
54
|
+
id?: string;
|
|
55
|
+
email?: string;
|
|
56
|
+
emailVerified?: string;
|
|
57
|
+
name?: string;
|
|
58
|
+
image?: string;
|
|
59
|
+
extraFields?: Record<string, string>;
|
|
60
|
+
};
|
|
74
61
|
}
|
|
75
62
|
|
|
76
63
|
export interface SAMLConfig {
|
|
77
64
|
issuer: string;
|
|
78
65
|
entryPoint: string;
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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;
|
|
66
|
+
signingKey: string;
|
|
67
|
+
certificate: string;
|
|
68
|
+
attributeConsumingServiceIndex: number;
|
|
69
|
+
mapping?: {
|
|
70
|
+
id?: string;
|
|
71
|
+
email?: string;
|
|
72
|
+
name?: string;
|
|
73
|
+
firstName?: string;
|
|
74
|
+
lastName?: string;
|
|
75
|
+
extraFields?: Record<string, string>;
|
|
107
76
|
};
|
|
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;
|
|
116
77
|
}
|
|
117
78
|
|
|
118
79
|
export interface SSOProvider {
|
|
@@ -171,29 +132,6 @@ export interface SSOOptions {
|
|
|
171
132
|
provider: SSOProvider;
|
|
172
133
|
}) => Promise<"member" | "admin">;
|
|
173
134
|
};
|
|
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
|
-
}>;
|
|
197
135
|
/**
|
|
198
136
|
* Override user info with the provider info.
|
|
199
137
|
* @default false
|
|
@@ -252,7 +190,6 @@ export const sso = (options?: SSOOptions) => {
|
|
|
252
190
|
},
|
|
253
191
|
async (ctx) => {
|
|
254
192
|
const provider = await ctx.context.adapter.findOne<{
|
|
255
|
-
id: string;
|
|
256
193
|
samlConfig: string;
|
|
257
194
|
}>({
|
|
258
195
|
model: "ssoProvider",
|
|
@@ -269,29 +206,10 @@ export const sso = (options?: SSOOptions) => {
|
|
|
269
206
|
});
|
|
270
207
|
}
|
|
271
208
|
|
|
272
|
-
const parsedSamlConfig
|
|
273
|
-
const sp =
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
})
|
|
277
|
-
: saml.SPMetadata({
|
|
278
|
-
entityID:
|
|
279
|
-
parsedSamlConfig.spMetadata?.entityID ||
|
|
280
|
-
parsedSamlConfig.issuer,
|
|
281
|
-
assertionConsumerService: [
|
|
282
|
-
{
|
|
283
|
-
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
284
|
-
Location:
|
|
285
|
-
parsedSamlConfig.callbackUrl ||
|
|
286
|
-
`${ctx.context.baseURL}/sso/saml2/sp/acs/${provider.id}`,
|
|
287
|
-
},
|
|
288
|
-
],
|
|
289
|
-
wantMessageSigned:
|
|
290
|
-
parsedSamlConfig.wantAssertionsSigned || false,
|
|
291
|
-
nameIDFormat: parsedSamlConfig.identifierFormat
|
|
292
|
-
? [parsedSamlConfig.identifierFormat]
|
|
293
|
-
: undefined,
|
|
294
|
-
});
|
|
209
|
+
const parsedSamlConfig = JSON.parse(provider.samlConfig);
|
|
210
|
+
const sp = saml.ServiceProvider({
|
|
211
|
+
metadata: parsedSamlConfig.spMetadata.metadata,
|
|
212
|
+
});
|
|
295
213
|
return new Response(sp.getMetadata(), {
|
|
296
214
|
headers: {
|
|
297
215
|
"Content-Type": "application/xml",
|
|
@@ -366,37 +284,6 @@ export const sso = (options?: SSOOptions) => {
|
|
|
366
284
|
})
|
|
367
285
|
.default(true)
|
|
368
286
|
.optional(),
|
|
369
|
-
mapping: z
|
|
370
|
-
.object({
|
|
371
|
-
id: z.string({}).meta({
|
|
372
|
-
description:
|
|
373
|
-
"Field mapping for user ID (defaults to 'sub')",
|
|
374
|
-
}),
|
|
375
|
-
email: z.string({}).meta({
|
|
376
|
-
description:
|
|
377
|
-
"Field mapping for email (defaults to 'email')",
|
|
378
|
-
}),
|
|
379
|
-
emailVerified: z
|
|
380
|
-
.string({})
|
|
381
|
-
.meta({
|
|
382
|
-
description:
|
|
383
|
-
"Field mapping for email verification (defaults to 'email_verified')",
|
|
384
|
-
})
|
|
385
|
-
.optional(),
|
|
386
|
-
name: z.string({}).meta({
|
|
387
|
-
description:
|
|
388
|
-
"Field mapping for name (defaults to 'name')",
|
|
389
|
-
}),
|
|
390
|
-
image: z
|
|
391
|
-
.string({})
|
|
392
|
-
.meta({
|
|
393
|
-
description:
|
|
394
|
-
"Field mapping for image (defaults to 'picture')",
|
|
395
|
-
})
|
|
396
|
-
.optional(),
|
|
397
|
-
extraFields: z.record(z.string(), z.any()).optional(),
|
|
398
|
-
})
|
|
399
|
-
.optional(),
|
|
400
287
|
})
|
|
401
288
|
.optional(),
|
|
402
289
|
samlConfig: z
|
|
@@ -413,35 +300,18 @@ export const sso = (options?: SSOOptions) => {
|
|
|
413
300
|
audience: z.string().optional(),
|
|
414
301
|
idpMetadata: z
|
|
415
302
|
.object({
|
|
416
|
-
metadata: z.string()
|
|
417
|
-
entityID: z.string().optional(),
|
|
418
|
-
cert: z.string().optional(),
|
|
303
|
+
metadata: z.string(),
|
|
419
304
|
privateKey: z.string().optional(),
|
|
420
305
|
privateKeyPass: z.string().optional(),
|
|
421
306
|
isAssertionEncrypted: z.boolean().optional(),
|
|
422
307
|
encPrivateKey: z.string().optional(),
|
|
423
308
|
encPrivateKeyPass: z.string().optional(),
|
|
424
|
-
singleSignOnService: z
|
|
425
|
-
.array(
|
|
426
|
-
z.object({
|
|
427
|
-
Binding: z.string().meta({
|
|
428
|
-
description: "The binding type for the SSO service",
|
|
429
|
-
}),
|
|
430
|
-
Location: z.string().meta({
|
|
431
|
-
description: "The URL for the SSO service",
|
|
432
|
-
}),
|
|
433
|
-
}),
|
|
434
|
-
)
|
|
435
|
-
.optional()
|
|
436
|
-
.meta({
|
|
437
|
-
description: "Single Sign-On service configuration",
|
|
438
|
-
}),
|
|
439
309
|
})
|
|
440
310
|
.optional(),
|
|
441
311
|
spMetadata: z.object({
|
|
442
|
-
metadata: z.string()
|
|
443
|
-
entityID: z.string().optional(),
|
|
312
|
+
metadata: z.string(),
|
|
444
313
|
binding: z.string().optional(),
|
|
314
|
+
|
|
445
315
|
privateKey: z.string().optional(),
|
|
446
316
|
privateKeyPass: z.string().optional(),
|
|
447
317
|
isAssertionEncrypted: z.boolean().optional(),
|
|
@@ -455,43 +325,37 @@ export const sso = (options?: SSOOptions) => {
|
|
|
455
325
|
privateKey: z.string().optional(),
|
|
456
326
|
decryptionPvk: z.string().optional(),
|
|
457
327
|
additionalParams: z.record(z.string(), z.any()).optional(),
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
description:
|
|
476
|
-
"Field mapping for name (defaults to 'displayName')",
|
|
477
|
-
}),
|
|
478
|
-
firstName: z
|
|
479
|
-
.string({})
|
|
480
|
-
.meta({
|
|
481
|
-
description:
|
|
482
|
-
"Field mapping for first name (defaults to 'givenName')",
|
|
483
|
-
})
|
|
484
|
-
.optional(),
|
|
485
|
-
lastName: z
|
|
486
|
-
.string({})
|
|
487
|
-
.meta({
|
|
488
|
-
description:
|
|
489
|
-
"Field mapping for last name (defaults to 'surname')",
|
|
490
|
-
})
|
|
491
|
-
.optional(),
|
|
492
|
-
extraFields: z.record(z.string(), z.any()).optional(),
|
|
328
|
+
})
|
|
329
|
+
.optional(),
|
|
330
|
+
mapping: z
|
|
331
|
+
.object({
|
|
332
|
+
id: z.string({}).meta({
|
|
333
|
+
description:
|
|
334
|
+
"The field in the user info response that contains the id. Defaults to 'sub'",
|
|
335
|
+
}),
|
|
336
|
+
email: z.string({}).meta({
|
|
337
|
+
description:
|
|
338
|
+
"The field in the user info response that contains the email. Defaults to 'email'",
|
|
339
|
+
}),
|
|
340
|
+
emailVerified: z
|
|
341
|
+
.string({})
|
|
342
|
+
.meta({
|
|
343
|
+
description:
|
|
344
|
+
"The field in the user info response that contains whether the email is verified. defaults to 'email_verified'",
|
|
493
345
|
})
|
|
494
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'",
|
|
356
|
+
})
|
|
357
|
+
.optional(),
|
|
358
|
+
extraFields: z.record(z.string(), z.any()).optional(),
|
|
495
359
|
})
|
|
496
360
|
.optional(),
|
|
497
361
|
organizationId: z
|
|
@@ -768,7 +632,7 @@ export const sso = (options?: SSOOptions) => {
|
|
|
768
632
|
discoveryEndpoint:
|
|
769
633
|
body.oidcConfig.discoveryEndpoint ||
|
|
770
634
|
`${body.issuer}/.well-known/openid-configuration`,
|
|
771
|
-
mapping: body.
|
|
635
|
+
mapping: body.mapping,
|
|
772
636
|
scopes: body.oidcConfig.scopes,
|
|
773
637
|
userInfoEndpoint: body.oidcConfig.userInfoEndpoint,
|
|
774
638
|
overrideUserInfo:
|
|
@@ -793,7 +657,7 @@ export const sso = (options?: SSOOptions) => {
|
|
|
793
657
|
privateKey: body.samlConfig.privateKey,
|
|
794
658
|
decryptionPvk: body.samlConfig.decryptionPvk,
|
|
795
659
|
additionalParams: body.samlConfig.additionalParams,
|
|
796
|
-
mapping: body.
|
|
660
|
+
mapping: body.mapping,
|
|
797
661
|
})
|
|
798
662
|
: null,
|
|
799
663
|
organizationId: body.organizationId,
|
|
@@ -801,7 +665,6 @@ export const sso = (options?: SSOOptions) => {
|
|
|
801
665
|
providerId: body.providerId,
|
|
802
666
|
},
|
|
803
667
|
});
|
|
804
|
-
|
|
805
668
|
return ctx.json({
|
|
806
669
|
...provider,
|
|
807
670
|
oidcConfig: JSON.parse(
|
|
@@ -955,13 +818,7 @@ export const sso = (options?: SSOOptions) => {
|
|
|
955
818
|
async (ctx) => {
|
|
956
819
|
const body = ctx.body;
|
|
957
820
|
let { email, organizationSlug, providerId, domain } = body;
|
|
958
|
-
if (
|
|
959
|
-
!options?.defaultSSO?.length &&
|
|
960
|
-
!email &&
|
|
961
|
-
!organizationSlug &&
|
|
962
|
-
!domain &&
|
|
963
|
-
!providerId
|
|
964
|
-
) {
|
|
821
|
+
if (!email && !organizationSlug && !domain && !providerId) {
|
|
965
822
|
throw new APIError("BAD_REQUEST", {
|
|
966
823
|
message:
|
|
967
824
|
"email, organizationSlug, domain or providerId is required",
|
|
@@ -987,68 +844,29 @@ export const sso = (options?: SSOOptions) => {
|
|
|
987
844
|
return res.id;
|
|
988
845
|
});
|
|
989
846
|
}
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
oidcConfig:
|
|
1011
|
-
samlConfig: matchingDefault.samlConfig,
|
|
847
|
+
const provider = await ctx.context.adapter
|
|
848
|
+
.findOne<SSOProvider>({
|
|
849
|
+
model: "ssoProvider",
|
|
850
|
+
where: [
|
|
851
|
+
{
|
|
852
|
+
field: providerId
|
|
853
|
+
? "providerId"
|
|
854
|
+
: orgId
|
|
855
|
+
? "organizationId"
|
|
856
|
+
: "domain",
|
|
857
|
+
value: providerId || orgId || domain!,
|
|
858
|
+
},
|
|
859
|
+
],
|
|
860
|
+
})
|
|
861
|
+
.then((res) => {
|
|
862
|
+
if (!res) {
|
|
863
|
+
return null;
|
|
864
|
+
}
|
|
865
|
+
return {
|
|
866
|
+
...res,
|
|
867
|
+
oidcConfig: JSON.parse(res.oidcConfig as unknown as string),
|
|
1012
868
|
};
|
|
1013
|
-
}
|
|
1014
|
-
}
|
|
1015
|
-
if (!providerId && !orgId && !domain) {
|
|
1016
|
-
throw new APIError("BAD_REQUEST", {
|
|
1017
|
-
message: "providerId, orgId or domain is required",
|
|
1018
869
|
});
|
|
1019
|
-
}
|
|
1020
|
-
// Try to find provider in database
|
|
1021
|
-
if (!provider) {
|
|
1022
|
-
provider = await ctx.context.adapter
|
|
1023
|
-
.findOne<SSOProvider>({
|
|
1024
|
-
model: "ssoProvider",
|
|
1025
|
-
where: [
|
|
1026
|
-
{
|
|
1027
|
-
field: providerId
|
|
1028
|
-
? "providerId"
|
|
1029
|
-
: orgId
|
|
1030
|
-
? "organizationId"
|
|
1031
|
-
: "domain",
|
|
1032
|
-
value: providerId || orgId || domain!,
|
|
1033
|
-
},
|
|
1034
|
-
],
|
|
1035
|
-
})
|
|
1036
|
-
.then((res) => {
|
|
1037
|
-
if (!res) {
|
|
1038
|
-
return null;
|
|
1039
|
-
}
|
|
1040
|
-
return {
|
|
1041
|
-
...res,
|
|
1042
|
-
oidcConfig: res.oidcConfig
|
|
1043
|
-
? JSON.parse(res.oidcConfig as unknown as string)
|
|
1044
|
-
: undefined,
|
|
1045
|
-
samlConfig: res.samlConfig
|
|
1046
|
-
? JSON.parse(res.samlConfig as unknown as string)
|
|
1047
|
-
: undefined,
|
|
1048
|
-
};
|
|
1049
|
-
});
|
|
1050
|
-
}
|
|
1051
|
-
|
|
1052
870
|
if (!provider) {
|
|
1053
871
|
throw new APIError("NOT_FOUND", {
|
|
1054
872
|
message: "No provider found for the issuer",
|
|
@@ -1086,7 +904,7 @@ export const sso = (options?: SSOOptions) => {
|
|
|
1086
904
|
"profile",
|
|
1087
905
|
"offline_access",
|
|
1088
906
|
],
|
|
1089
|
-
authorizationEndpoint: provider.oidcConfig.authorizationEndpoint
|
|
907
|
+
authorizationEndpoint: provider.oidcConfig.authorizationEndpoint,
|
|
1090
908
|
});
|
|
1091
909
|
return ctx.json({
|
|
1092
910
|
url: authorizationURL.toString(),
|
|
@@ -1094,21 +912,15 @@ export const sso = (options?: SSOOptions) => {
|
|
|
1094
912
|
});
|
|
1095
913
|
}
|
|
1096
914
|
if (provider.samlConfig) {
|
|
1097
|
-
const parsedSamlConfig
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
: JSON.parse(provider.samlConfig as unknown as string);
|
|
915
|
+
const parsedSamlConfig = JSON.parse(
|
|
916
|
+
provider.samlConfig as unknown as string,
|
|
917
|
+
);
|
|
1101
918
|
const sp = saml.ServiceProvider({
|
|
1102
919
|
metadata: parsedSamlConfig.spMetadata.metadata,
|
|
1103
920
|
allowCreate: true,
|
|
1104
921
|
});
|
|
1105
|
-
|
|
1106
922
|
const idp = saml.IdentityProvider({
|
|
1107
|
-
metadata: parsedSamlConfig.idpMetadata
|
|
1108
|
-
entityID: parsedSamlConfig.idpMetadata?.entityID,
|
|
1109
|
-
encryptCert: parsedSamlConfig.idpMetadata?.cert,
|
|
1110
|
-
singleSignOnService:
|
|
1111
|
-
parsedSamlConfig.idpMetadata?.singleSignOnService,
|
|
923
|
+
metadata: parsedSamlConfig.idpMetadata.metadata,
|
|
1112
924
|
});
|
|
1113
925
|
const loginRequest = sp.createLoginRequest(
|
|
1114
926
|
idp,
|
|
@@ -1173,43 +985,27 @@ export const sso = (options?: SSOOptions) => {
|
|
|
1173
985
|
}?error=${error}&error_description=${error_description}`,
|
|
1174
986
|
);
|
|
1175
987
|
}
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
{
|
|
1198
|
-
field: "providerId",
|
|
1199
|
-
value: ctx.params.providerId,
|
|
1200
|
-
},
|
|
1201
|
-
],
|
|
1202
|
-
})
|
|
1203
|
-
.then((res) => {
|
|
1204
|
-
if (!res) {
|
|
1205
|
-
return null;
|
|
1206
|
-
}
|
|
1207
|
-
return {
|
|
1208
|
-
...res,
|
|
1209
|
-
oidcConfig: JSON.parse(res.oidcConfig),
|
|
1210
|
-
} as SSOProvider;
|
|
1211
|
-
});
|
|
1212
|
-
}
|
|
988
|
+
const provider = await ctx.context.adapter
|
|
989
|
+
.findOne<{
|
|
990
|
+
oidcConfig: string;
|
|
991
|
+
}>({
|
|
992
|
+
model: "ssoProvider",
|
|
993
|
+
where: [
|
|
994
|
+
{
|
|
995
|
+
field: "providerId",
|
|
996
|
+
value: ctx.params.providerId,
|
|
997
|
+
},
|
|
998
|
+
],
|
|
999
|
+
})
|
|
1000
|
+
.then((res) => {
|
|
1001
|
+
if (!res) {
|
|
1002
|
+
return null;
|
|
1003
|
+
}
|
|
1004
|
+
return {
|
|
1005
|
+
...res,
|
|
1006
|
+
oidcConfig: JSON.parse(res.oidcConfig),
|
|
1007
|
+
} as SSOProvider;
|
|
1008
|
+
});
|
|
1213
1009
|
if (!provider) {
|
|
1214
1010
|
throw ctx.redirect(
|
|
1215
1011
|
`${
|
|
@@ -1509,525 +1305,72 @@ export const sso = (options?: SSOOptions) => {
|
|
|
1509
1305
|
async (ctx) => {
|
|
1510
1306
|
const { SAMLResponse, RelayState } = ctx.body;
|
|
1511
1307
|
const { providerId } = ctx.params;
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
);
|
|
1517
|
-
if (matchingDefault) {
|
|
1518
|
-
provider = {
|
|
1519
|
-
...matchingDefault,
|
|
1520
|
-
userId: "default",
|
|
1521
|
-
issuer: matchingDefault.samlConfig?.issuer || "",
|
|
1522
|
-
};
|
|
1523
|
-
}
|
|
1524
|
-
}
|
|
1525
|
-
if (!provider) {
|
|
1526
|
-
provider = await ctx.context.adapter
|
|
1527
|
-
.findOne<SSOProvider>({
|
|
1528
|
-
model: "ssoProvider",
|
|
1529
|
-
where: [{ field: "providerId", value: providerId }],
|
|
1530
|
-
})
|
|
1531
|
-
.then((res) => {
|
|
1532
|
-
if (!res) return null;
|
|
1533
|
-
return {
|
|
1534
|
-
...res,
|
|
1535
|
-
samlConfig: res.samlConfig
|
|
1536
|
-
? JSON.parse(res.samlConfig as unknown as string)
|
|
1537
|
-
: undefined,
|
|
1538
|
-
};
|
|
1539
|
-
});
|
|
1540
|
-
}
|
|
1308
|
+
const provider = await ctx.context.adapter.findOne<SSOProvider>({
|
|
1309
|
+
model: "ssoProvider",
|
|
1310
|
+
where: [{ field: "providerId", value: providerId }],
|
|
1311
|
+
});
|
|
1541
1312
|
|
|
1542
1313
|
if (!provider) {
|
|
1543
1314
|
throw new APIError("NOT_FOUND", {
|
|
1544
1315
|
message: "No provider found for the given providerId",
|
|
1545
1316
|
});
|
|
1546
1317
|
}
|
|
1318
|
+
|
|
1547
1319
|
const parsedSamlConfig = JSON.parse(
|
|
1548
1320
|
provider.samlConfig as unknown as string,
|
|
1549
1321
|
);
|
|
1550
|
-
const
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
// Construct IDP with fallback to manual configuration
|
|
1554
|
-
if (!idpData?.metadata) {
|
|
1555
|
-
idp = saml.IdentityProvider({
|
|
1556
|
-
entityID: idpData.entityID || parsedSamlConfig.issuer,
|
|
1557
|
-
singleSignOnService: [
|
|
1558
|
-
{
|
|
1559
|
-
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
1560
|
-
Location: parsedSamlConfig.entryPoint,
|
|
1561
|
-
},
|
|
1562
|
-
],
|
|
1563
|
-
signingCert: idpData.cert || parsedSamlConfig.cert,
|
|
1564
|
-
wantAuthnRequestsSigned:
|
|
1565
|
-
parsedSamlConfig.wantAssertionsSigned || false,
|
|
1566
|
-
isAssertionEncrypted: idpData.isAssertionEncrypted || false,
|
|
1567
|
-
encPrivateKey: idpData.encPrivateKey,
|
|
1568
|
-
encPrivateKeyPass: idpData.encPrivateKeyPass,
|
|
1569
|
-
});
|
|
1570
|
-
} else {
|
|
1571
|
-
idp = saml.IdentityProvider({
|
|
1572
|
-
metadata: idpData.metadata,
|
|
1573
|
-
privateKey: idpData.privateKey,
|
|
1574
|
-
privateKeyPass: idpData.privateKeyPass,
|
|
1575
|
-
isAssertionEncrypted: idpData.isAssertionEncrypted,
|
|
1576
|
-
encPrivateKey: idpData.encPrivateKey,
|
|
1577
|
-
encPrivateKeyPass: idpData.encPrivateKeyPass,
|
|
1578
|
-
});
|
|
1579
|
-
}
|
|
1580
|
-
|
|
1581
|
-
// Construct SP with fallback to manual configuration
|
|
1582
|
-
const spData = parsedSamlConfig.spMetadata;
|
|
1583
|
-
const sp = saml.ServiceProvider({
|
|
1584
|
-
metadata: spData?.metadata,
|
|
1585
|
-
entityID: spData?.entityID || parsedSamlConfig.issuer,
|
|
1586
|
-
assertionConsumerService: spData?.metadata
|
|
1587
|
-
? undefined
|
|
1588
|
-
: [
|
|
1589
|
-
{
|
|
1590
|
-
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
1591
|
-
Location: parsedSamlConfig.callbackUrl,
|
|
1592
|
-
},
|
|
1593
|
-
],
|
|
1594
|
-
privateKey: spData?.privateKey || parsedSamlConfig.privateKey,
|
|
1595
|
-
privateKeyPass: spData?.privateKeyPass,
|
|
1596
|
-
isAssertionEncrypted: spData?.isAssertionEncrypted || false,
|
|
1597
|
-
encPrivateKey: spData?.encPrivateKey,
|
|
1598
|
-
encPrivateKeyPass: spData?.encPrivateKeyPass,
|
|
1599
|
-
wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
|
|
1600
|
-
nameIDFormat: parsedSamlConfig.identifierFormat
|
|
1601
|
-
? [parsedSamlConfig.identifierFormat]
|
|
1602
|
-
: undefined,
|
|
1322
|
+
const idp = saml.IdentityProvider({
|
|
1323
|
+
metadata: parsedSamlConfig.idpMetadata.metadata,
|
|
1603
1324
|
});
|
|
1604
|
-
|
|
1605
|
-
let parsedResponse: FlowResult;
|
|
1606
|
-
try {
|
|
1607
|
-
const decodedResponse = Buffer.from(
|
|
1608
|
-
SAMLResponse,
|
|
1609
|
-
"base64",
|
|
1610
|
-
).toString("utf-8");
|
|
1611
|
-
|
|
1612
|
-
try {
|
|
1613
|
-
parsedResponse = await sp.parseLoginResponse(idp, "post", {
|
|
1614
|
-
body: {
|
|
1615
|
-
SAMLResponse,
|
|
1616
|
-
RelayState: RelayState || undefined,
|
|
1617
|
-
},
|
|
1618
|
-
});
|
|
1619
|
-
} catch (parseError) {
|
|
1620
|
-
const nameIDMatch = decodedResponse.match(
|
|
1621
|
-
/<saml2:NameID[^>]*>([^<]+)<\/saml2:NameID>/,
|
|
1622
|
-
);
|
|
1623
|
-
if (!nameIDMatch) throw parseError;
|
|
1624
|
-
parsedResponse = {
|
|
1625
|
-
extract: {
|
|
1626
|
-
nameID: nameIDMatch[1],
|
|
1627
|
-
attributes: { nameID: nameIDMatch[1] },
|
|
1628
|
-
sessionIndex: {},
|
|
1629
|
-
conditions: {},
|
|
1630
|
-
},
|
|
1631
|
-
} as FlowResult;
|
|
1632
|
-
}
|
|
1633
|
-
|
|
1634
|
-
if (!parsedResponse?.extract) {
|
|
1635
|
-
throw new Error("Invalid SAML response structure");
|
|
1636
|
-
}
|
|
1637
|
-
} catch (error) {
|
|
1638
|
-
ctx.context.logger.error("SAML response validation failed", {
|
|
1639
|
-
error,
|
|
1640
|
-
decodedResponse: Buffer.from(SAMLResponse, "base64").toString(
|
|
1641
|
-
"utf-8",
|
|
1642
|
-
),
|
|
1643
|
-
});
|
|
1644
|
-
throw new APIError("BAD_REQUEST", {
|
|
1645
|
-
message: "Invalid SAML response",
|
|
1646
|
-
details: error instanceof Error ? error.message : String(error),
|
|
1647
|
-
});
|
|
1648
|
-
}
|
|
1649
|
-
|
|
1650
|
-
const { extract } = parsedResponse!;
|
|
1651
|
-
const attributes = extract.attributes || {};
|
|
1652
|
-
const mapping = parsedSamlConfig.mapping ?? {};
|
|
1653
|
-
|
|
1654
|
-
const userInfo = {
|
|
1655
|
-
...Object.fromEntries(
|
|
1656
|
-
Object.entries(mapping.extraFields || {}).map(([key, value]) => [
|
|
1657
|
-
key,
|
|
1658
|
-
attributes[value as string],
|
|
1659
|
-
]),
|
|
1660
|
-
),
|
|
1661
|
-
id: attributes[mapping.id || "nameID"] || extract.nameID,
|
|
1662
|
-
email: attributes[mapping.email || "email"] || extract.nameID,
|
|
1663
|
-
name:
|
|
1664
|
-
[
|
|
1665
|
-
attributes[mapping.firstName || "givenName"],
|
|
1666
|
-
attributes[mapping.lastName || "surname"],
|
|
1667
|
-
]
|
|
1668
|
-
.filter(Boolean)
|
|
1669
|
-
.join(" ") ||
|
|
1670
|
-
attributes[mapping.name || "displayName"] ||
|
|
1671
|
-
extract.nameID,
|
|
1672
|
-
emailVerified:
|
|
1673
|
-
options?.trustEmailVerified && mapping.emailVerified
|
|
1674
|
-
? ((attributes[mapping.emailVerified] || false) as boolean)
|
|
1675
|
-
: false,
|
|
1676
|
-
};
|
|
1677
|
-
if (!userInfo.id || !userInfo.email) {
|
|
1678
|
-
ctx.context.logger.error(
|
|
1679
|
-
"Missing essential user info from SAML response",
|
|
1680
|
-
{
|
|
1681
|
-
attributes: Object.keys(attributes),
|
|
1682
|
-
mapping,
|
|
1683
|
-
extractedId: userInfo.id,
|
|
1684
|
-
extractedEmail: userInfo.email,
|
|
1685
|
-
},
|
|
1686
|
-
);
|
|
1687
|
-
throw new APIError("BAD_REQUEST", {
|
|
1688
|
-
message: "Unable to extract user ID or email from SAML response",
|
|
1689
|
-
});
|
|
1690
|
-
}
|
|
1691
|
-
|
|
1692
|
-
// Find or create user
|
|
1693
|
-
let user: User;
|
|
1694
|
-
const existingUser = await ctx.context.adapter.findOne<User>({
|
|
1695
|
-
model: "user",
|
|
1696
|
-
where: [
|
|
1697
|
-
{
|
|
1698
|
-
field: "email",
|
|
1699
|
-
value: userInfo.email,
|
|
1700
|
-
},
|
|
1701
|
-
],
|
|
1702
|
-
});
|
|
1703
|
-
|
|
1704
|
-
if (existingUser) {
|
|
1705
|
-
user = existingUser;
|
|
1706
|
-
} else {
|
|
1707
|
-
user = await ctx.context.adapter.create({
|
|
1708
|
-
model: "user",
|
|
1709
|
-
data: {
|
|
1710
|
-
email: userInfo.email,
|
|
1711
|
-
name: userInfo.name,
|
|
1712
|
-
emailVerified: userInfo.emailVerified,
|
|
1713
|
-
createdAt: new Date(),
|
|
1714
|
-
updatedAt: new Date(),
|
|
1715
|
-
},
|
|
1716
|
-
});
|
|
1717
|
-
}
|
|
1718
|
-
|
|
1719
|
-
// Create or update account link
|
|
1720
|
-
const account = await ctx.context.adapter.findOne<Account>({
|
|
1721
|
-
model: "account",
|
|
1722
|
-
where: [
|
|
1723
|
-
{ field: "userId", value: user.id },
|
|
1724
|
-
{ field: "providerId", value: provider.providerId },
|
|
1725
|
-
{ field: "accountId", value: userInfo.id },
|
|
1726
|
-
],
|
|
1727
|
-
});
|
|
1728
|
-
|
|
1729
|
-
if (!account) {
|
|
1730
|
-
await ctx.context.adapter.create<Account>({
|
|
1731
|
-
model: "account",
|
|
1732
|
-
data: {
|
|
1733
|
-
userId: user.id,
|
|
1734
|
-
providerId: provider.providerId,
|
|
1735
|
-
accountId: userInfo.id,
|
|
1736
|
-
createdAt: new Date(),
|
|
1737
|
-
updatedAt: new Date(),
|
|
1738
|
-
accessToken: "",
|
|
1739
|
-
refreshToken: "",
|
|
1740
|
-
},
|
|
1741
|
-
});
|
|
1742
|
-
}
|
|
1743
|
-
|
|
1744
|
-
// Run provision hooks
|
|
1745
|
-
if (options?.provisionUser) {
|
|
1746
|
-
await options.provisionUser({
|
|
1747
|
-
user: user as User & Record<string, any>,
|
|
1748
|
-
userInfo,
|
|
1749
|
-
provider,
|
|
1750
|
-
});
|
|
1751
|
-
}
|
|
1752
|
-
|
|
1753
|
-
// Handle organization provisioning
|
|
1754
|
-
if (
|
|
1755
|
-
provider.organizationId &&
|
|
1756
|
-
!options?.organizationProvisioning?.disabled
|
|
1757
|
-
) {
|
|
1758
|
-
const isOrgPluginEnabled = ctx.context.options.plugins?.find(
|
|
1759
|
-
(plugin) => plugin.id === "organization",
|
|
1760
|
-
);
|
|
1761
|
-
if (isOrgPluginEnabled) {
|
|
1762
|
-
const isAlreadyMember = await ctx.context.adapter.findOne({
|
|
1763
|
-
model: "member",
|
|
1764
|
-
where: [
|
|
1765
|
-
{ field: "organizationId", value: provider.organizationId },
|
|
1766
|
-
{ field: "userId", value: user.id },
|
|
1767
|
-
],
|
|
1768
|
-
});
|
|
1769
|
-
if (!isAlreadyMember) {
|
|
1770
|
-
const role = options?.organizationProvisioning?.getRole
|
|
1771
|
-
? await options.organizationProvisioning.getRole({
|
|
1772
|
-
user,
|
|
1773
|
-
userInfo,
|
|
1774
|
-
provider,
|
|
1775
|
-
})
|
|
1776
|
-
: options?.organizationProvisioning?.defaultRole || "member";
|
|
1777
|
-
await ctx.context.adapter.create({
|
|
1778
|
-
model: "member",
|
|
1779
|
-
data: {
|
|
1780
|
-
organizationId: provider.organizationId,
|
|
1781
|
-
userId: user.id,
|
|
1782
|
-
role,
|
|
1783
|
-
createdAt: new Date(),
|
|
1784
|
-
updatedAt: new Date(),
|
|
1785
|
-
},
|
|
1786
|
-
});
|
|
1787
|
-
}
|
|
1788
|
-
}
|
|
1789
|
-
}
|
|
1790
|
-
|
|
1791
|
-
// Create session and set cookie
|
|
1792
|
-
let session: Session =
|
|
1793
|
-
await ctx.context.internalAdapter.createSession(user.id, ctx);
|
|
1794
|
-
await setSessionCookie(ctx, { session, user });
|
|
1795
|
-
|
|
1796
|
-
// Redirect to callback URL
|
|
1797
|
-
const callbackUrl =
|
|
1798
|
-
RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
1799
|
-
throw ctx.redirect(callbackUrl);
|
|
1800
|
-
},
|
|
1801
|
-
),
|
|
1802
|
-
acsEndpoint: createAuthEndpoint(
|
|
1803
|
-
"/sso/saml2/sp/acs/:providerId",
|
|
1804
|
-
{
|
|
1805
|
-
method: "POST",
|
|
1806
|
-
params: z.object({
|
|
1807
|
-
providerId: z.string().optional(),
|
|
1808
|
-
}),
|
|
1809
|
-
body: z.object({
|
|
1810
|
-
SAMLResponse: z.string(),
|
|
1811
|
-
RelayState: z.string().optional(),
|
|
1812
|
-
}),
|
|
1813
|
-
metadata: {
|
|
1814
|
-
isAction: false,
|
|
1815
|
-
openapi: {
|
|
1816
|
-
summary: "SAML Assertion Consumer Service",
|
|
1817
|
-
description:
|
|
1818
|
-
"Handles SAML responses from IdP after successful authentication",
|
|
1819
|
-
responses: {
|
|
1820
|
-
"302": {
|
|
1821
|
-
description:
|
|
1822
|
-
"Redirects to the callback URL after successful authentication",
|
|
1823
|
-
},
|
|
1824
|
-
},
|
|
1825
|
-
},
|
|
1826
|
-
},
|
|
1827
|
-
},
|
|
1828
|
-
async (ctx) => {
|
|
1829
|
-
const { SAMLResponse, RelayState = "" } = ctx.body;
|
|
1830
|
-
const { providerId } = ctx.params;
|
|
1831
|
-
|
|
1832
|
-
// If defaultSSO is configured, use it as the provider
|
|
1833
|
-
let provider: SSOProvider | null = null;
|
|
1834
|
-
|
|
1835
|
-
if (options?.defaultSSO?.length) {
|
|
1836
|
-
// For ACS endpoint, we can use the first default provider or try to match by providerId
|
|
1837
|
-
const matchingDefault = providerId
|
|
1838
|
-
? options.defaultSSO.find(
|
|
1839
|
-
(defaultProvider) =>
|
|
1840
|
-
defaultProvider.providerId === providerId,
|
|
1841
|
-
)
|
|
1842
|
-
: options.defaultSSO[0]; // Use first default provider if no specific providerId
|
|
1843
|
-
|
|
1844
|
-
if (matchingDefault) {
|
|
1845
|
-
provider = {
|
|
1846
|
-
issuer: matchingDefault.samlConfig?.issuer || "",
|
|
1847
|
-
providerId: matchingDefault.providerId,
|
|
1848
|
-
userId: "default",
|
|
1849
|
-
samlConfig: matchingDefault.samlConfig,
|
|
1850
|
-
};
|
|
1851
|
-
}
|
|
1852
|
-
} else {
|
|
1853
|
-
provider = await ctx.context.adapter
|
|
1854
|
-
.findOne<SSOProvider>({
|
|
1855
|
-
model: "ssoProvider",
|
|
1856
|
-
where: [
|
|
1857
|
-
{
|
|
1858
|
-
field: "providerId",
|
|
1859
|
-
value: providerId ?? "sso",
|
|
1860
|
-
},
|
|
1861
|
-
],
|
|
1862
|
-
})
|
|
1863
|
-
.then((res) => {
|
|
1864
|
-
if (!res) return null;
|
|
1865
|
-
return {
|
|
1866
|
-
...res,
|
|
1867
|
-
samlConfig: res.samlConfig
|
|
1868
|
-
? JSON.parse(res.samlConfig as unknown as string)
|
|
1869
|
-
: undefined,
|
|
1870
|
-
};
|
|
1871
|
-
});
|
|
1872
|
-
}
|
|
1873
|
-
|
|
1874
|
-
if (!provider?.samlConfig) {
|
|
1875
|
-
throw new APIError("NOT_FOUND", {
|
|
1876
|
-
message: "No SAML provider found",
|
|
1877
|
-
});
|
|
1878
|
-
}
|
|
1879
|
-
|
|
1880
|
-
const parsedSamlConfig = provider.samlConfig;
|
|
1881
|
-
// Configure SP and IdP
|
|
1882
1325
|
const sp = saml.ServiceProvider({
|
|
1883
|
-
|
|
1884
|
-
parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
|
|
1885
|
-
assertionConsumerService: [
|
|
1886
|
-
{
|
|
1887
|
-
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
1888
|
-
Location:
|
|
1889
|
-
parsedSamlConfig.callbackUrl ||
|
|
1890
|
-
`${ctx.context.baseURL}/sso/saml2/sp/acs/${providerId}`,
|
|
1891
|
-
},
|
|
1892
|
-
],
|
|
1893
|
-
wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
|
|
1894
|
-
metadata: parsedSamlConfig.spMetadata?.metadata,
|
|
1895
|
-
privateKey:
|
|
1896
|
-
parsedSamlConfig.spMetadata?.privateKey ||
|
|
1897
|
-
parsedSamlConfig.privateKey,
|
|
1898
|
-
privateKeyPass: parsedSamlConfig.spMetadata?.privateKeyPass,
|
|
1899
|
-
nameIDFormat: parsedSamlConfig.identifierFormat
|
|
1900
|
-
? [parsedSamlConfig.identifierFormat]
|
|
1901
|
-
: undefined,
|
|
1326
|
+
metadata: parsedSamlConfig.spMetadata.metadata,
|
|
1902
1327
|
});
|
|
1903
|
-
|
|
1904
|
-
// Update where we construct the IdP
|
|
1905
|
-
const idpData = parsedSamlConfig.idpMetadata;
|
|
1906
|
-
const idp = !idpData?.metadata
|
|
1907
|
-
? saml.IdentityProvider({
|
|
1908
|
-
entityID: idpData?.entityID || parsedSamlConfig.issuer,
|
|
1909
|
-
singleSignOnService: idpData?.singleSignOnService || [
|
|
1910
|
-
{
|
|
1911
|
-
Binding:
|
|
1912
|
-
"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
1913
|
-
Location: parsedSamlConfig.entryPoint,
|
|
1914
|
-
},
|
|
1915
|
-
],
|
|
1916
|
-
signingCert: idpData?.cert || parsedSamlConfig.cert,
|
|
1917
|
-
})
|
|
1918
|
-
: saml.IdentityProvider({
|
|
1919
|
-
metadata: idpData.metadata,
|
|
1920
|
-
});
|
|
1921
|
-
|
|
1922
|
-
// Parse and validate SAML response
|
|
1923
1328
|
let parsedResponse: FlowResult;
|
|
1924
1329
|
try {
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
);
|
|
1928
|
-
|
|
1929
|
-
// Patch the SAML response if status is missing or not success
|
|
1930
|
-
if (!decodedResponse.includes("StatusCode")) {
|
|
1931
|
-
// Insert a success status if missing
|
|
1932
|
-
const insertPoint = decodedResponse.indexOf("</saml2:Issuer>");
|
|
1933
|
-
if (insertPoint !== -1) {
|
|
1934
|
-
decodedResponse =
|
|
1935
|
-
decodedResponse.slice(0, insertPoint + 14) +
|
|
1936
|
-
'<saml2:Status><saml2:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></saml2:Status>' +
|
|
1937
|
-
decodedResponse.slice(insertPoint + 14);
|
|
1938
|
-
}
|
|
1939
|
-
} else if (!decodedResponse.includes("saml2:Success")) {
|
|
1940
|
-
// Replace existing non-success status with success
|
|
1941
|
-
decodedResponse = decodedResponse.replace(
|
|
1942
|
-
/<saml2:StatusCode Value="[^"]+"/,
|
|
1943
|
-
'<saml2:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"',
|
|
1944
|
-
);
|
|
1945
|
-
}
|
|
1946
|
-
|
|
1947
|
-
try {
|
|
1948
|
-
parsedResponse = await sp.parseLoginResponse(idp, "post", {
|
|
1949
|
-
body: {
|
|
1950
|
-
SAMLResponse,
|
|
1951
|
-
RelayState: RelayState || undefined,
|
|
1952
|
-
},
|
|
1953
|
-
});
|
|
1954
|
-
} catch (parseError) {
|
|
1955
|
-
const nameIDMatch = decodedResponse.match(
|
|
1956
|
-
/<saml2:NameID[^>]*>([^<]+)<\/saml2:NameID>/,
|
|
1957
|
-
);
|
|
1958
|
-
// due to different spec. we have to make sure to handle that.
|
|
1959
|
-
if (!nameIDMatch) throw parseError;
|
|
1960
|
-
parsedResponse = {
|
|
1961
|
-
extract: {
|
|
1962
|
-
nameID: nameIDMatch[1],
|
|
1963
|
-
attributes: { nameID: nameIDMatch[1] },
|
|
1964
|
-
sessionIndex: {},
|
|
1965
|
-
conditions: {},
|
|
1966
|
-
},
|
|
1967
|
-
} as FlowResult;
|
|
1968
|
-
}
|
|
1330
|
+
parsedResponse = await sp.parseLoginResponse(idp, "post", {
|
|
1331
|
+
body: { SAMLResponse, RelayState },
|
|
1332
|
+
});
|
|
1969
1333
|
|
|
1970
|
-
if (!parsedResponse
|
|
1971
|
-
throw new Error("
|
|
1334
|
+
if (!parsedResponse) {
|
|
1335
|
+
throw new Error("Empty SAML response");
|
|
1972
1336
|
}
|
|
1973
1337
|
} catch (error) {
|
|
1974
|
-
ctx.context.logger.error("SAML response validation failed",
|
|
1975
|
-
error,
|
|
1976
|
-
decodedResponse: Buffer.from(SAMLResponse, "base64").toString(
|
|
1977
|
-
"utf-8",
|
|
1978
|
-
),
|
|
1979
|
-
});
|
|
1338
|
+
ctx.context.logger.error("SAML response validation failed", error);
|
|
1980
1339
|
throw new APIError("BAD_REQUEST", {
|
|
1981
1340
|
message: "Invalid SAML response",
|
|
1982
1341
|
details: error instanceof Error ? error.message : String(error),
|
|
1983
1342
|
});
|
|
1984
1343
|
}
|
|
1985
|
-
|
|
1986
|
-
const
|
|
1987
|
-
const
|
|
1988
|
-
const mapping = parsedSamlConfig.mapping ?? {};
|
|
1989
|
-
|
|
1344
|
+
const { extract } = parsedResponse;
|
|
1345
|
+
const attributes = parsedResponse.extract.attributes;
|
|
1346
|
+
const mapping = parsedSamlConfig?.mapping ?? {};
|
|
1990
1347
|
const userInfo = {
|
|
1991
1348
|
...Object.fromEntries(
|
|
1992
1349
|
Object.entries(mapping.extraFields || {}).map(([key, value]) => [
|
|
1993
1350
|
key,
|
|
1994
|
-
attributes[value as string],
|
|
1351
|
+
extract.attributes[value as string],
|
|
1995
1352
|
]),
|
|
1996
1353
|
),
|
|
1997
|
-
id: attributes[mapping.id || "nameID"]
|
|
1998
|
-
email:
|
|
1354
|
+
id: attributes[mapping.id] || attributes["nameID"],
|
|
1355
|
+
email:
|
|
1356
|
+
attributes[mapping.email] ||
|
|
1357
|
+
attributes["nameID"] ||
|
|
1358
|
+
attributes["email"],
|
|
1999
1359
|
name:
|
|
2000
1360
|
[
|
|
2001
|
-
attributes[mapping.firstName || "givenName"],
|
|
2002
|
-
attributes[mapping.lastName || "surname"],
|
|
1361
|
+
attributes[mapping.firstName] || attributes["givenName"],
|
|
1362
|
+
attributes[mapping.lastName] || attributes["surname"],
|
|
2003
1363
|
]
|
|
2004
1364
|
.filter(Boolean)
|
|
2005
|
-
.join(" ") ||
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
? ((attributes[mapping.emailVerified] || false) as boolean)
|
|
2011
|
-
: false,
|
|
1365
|
+
.join(" ") || parsedResponse.extract.attributes?.displayName,
|
|
1366
|
+
attributes: parsedResponse.extract.attributes,
|
|
1367
|
+
emailVerified: options?.trustEmailVerified
|
|
1368
|
+
? ((attributes?.[mapping.emailVerified] || false) as boolean)
|
|
1369
|
+
: false,
|
|
2012
1370
|
};
|
|
2013
1371
|
|
|
2014
|
-
if (!userInfo.id || !userInfo.email) {
|
|
2015
|
-
ctx.context.logger.error(
|
|
2016
|
-
"Missing essential user info from SAML response",
|
|
2017
|
-
{
|
|
2018
|
-
attributes: Object.keys(attributes),
|
|
2019
|
-
mapping,
|
|
2020
|
-
extractedId: userInfo.id,
|
|
2021
|
-
extractedEmail: userInfo.email,
|
|
2022
|
-
},
|
|
2023
|
-
);
|
|
2024
|
-
throw new APIError("BAD_REQUEST", {
|
|
2025
|
-
message: "Unable to extract user ID or email from SAML response",
|
|
2026
|
-
});
|
|
2027
|
-
}
|
|
2028
|
-
|
|
2029
|
-
// Find or create user
|
|
2030
1372
|
let user: User;
|
|
1373
|
+
|
|
2031
1374
|
const existingUser = await ctx.context.adapter.findOne<User>({
|
|
2032
1375
|
model: "user",
|
|
2033
1376
|
where: [
|
|
@@ -2039,7 +1382,7 @@ export const sso = (options?: SSOOptions) => {
|
|
|
2039
1382
|
});
|
|
2040
1383
|
|
|
2041
1384
|
if (existingUser) {
|
|
2042
|
-
const
|
|
1385
|
+
const accounts = await ctx.context.adapter.findOne<Account>({
|
|
2043
1386
|
model: "account",
|
|
2044
1387
|
where: [
|
|
2045
1388
|
{ field: "userId", value: existingUser.id },
|
|
@@ -2047,7 +1390,7 @@ export const sso = (options?: SSOOptions) => {
|
|
|
2047
1390
|
{ field: "accountId", value: userInfo.id },
|
|
2048
1391
|
],
|
|
2049
1392
|
});
|
|
2050
|
-
if (!
|
|
1393
|
+
if (!accounts) {
|
|
2051
1394
|
const isTrustedProvider =
|
|
2052
1395
|
ctx.context.options.account?.accountLinking?.trustedProviders?.includes(
|
|
2053
1396
|
provider.providerId,
|
|
@@ -2149,10 +1492,11 @@ export const sso = (options?: SSOOptions) => {
|
|
|
2149
1492
|
let session: Session =
|
|
2150
1493
|
await ctx.context.internalAdapter.createSession(user.id, ctx);
|
|
2151
1494
|
await setSessionCookie(ctx, { session, user });
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
1495
|
+
throw ctx.redirect(
|
|
1496
|
+
RelayState ||
|
|
1497
|
+
`${parsedSamlConfig.callbackUrl}` ||
|
|
1498
|
+
`${parsedSamlConfig.issuer}`,
|
|
1499
|
+
);
|
|
2156
1500
|
},
|
|
2157
1501
|
),
|
|
2158
1502
|
},
|