@better-auth/sso 1.3.13 → 1.3.14
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 +528 -90
- 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 +528 -90
- package/package.json +3 -3
- package/src/index.ts +767 -137
- package/src/oidc.test.ts +84 -21
- package/src/saml.test.ts +92 -0
- 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
|
|
@@ -284,6 +346,37 @@ export const sso = (options?: SSOOptions) => {
|
|
|
284
346
|
})
|
|
285
347
|
.default(true)
|
|
286
348
|
.optional(),
|
|
349
|
+
mapping: z
|
|
350
|
+
.object({
|
|
351
|
+
id: z.string({}).meta({
|
|
352
|
+
description:
|
|
353
|
+
"Field mapping for user ID (defaults to 'sub')",
|
|
354
|
+
}),
|
|
355
|
+
email: z.string({}).meta({
|
|
356
|
+
description:
|
|
357
|
+
"Field mapping for email (defaults to 'email')",
|
|
358
|
+
}),
|
|
359
|
+
emailVerified: z
|
|
360
|
+
.string({})
|
|
361
|
+
.meta({
|
|
362
|
+
description:
|
|
363
|
+
"Field mapping for email verification (defaults to 'email_verified')",
|
|
364
|
+
})
|
|
365
|
+
.optional(),
|
|
366
|
+
name: z.string({}).meta({
|
|
367
|
+
description:
|
|
368
|
+
"Field mapping for name (defaults to 'name')",
|
|
369
|
+
}),
|
|
370
|
+
image: z
|
|
371
|
+
.string({})
|
|
372
|
+
.meta({
|
|
373
|
+
description:
|
|
374
|
+
"Field mapping for image (defaults to 'picture')",
|
|
375
|
+
})
|
|
376
|
+
.optional(),
|
|
377
|
+
extraFields: z.record(z.string(), z.any()).optional(),
|
|
378
|
+
})
|
|
379
|
+
.optional(),
|
|
287
380
|
})
|
|
288
381
|
.optional(),
|
|
289
382
|
samlConfig: z
|
|
@@ -300,18 +393,35 @@ export const sso = (options?: SSOOptions) => {
|
|
|
300
393
|
audience: z.string().optional(),
|
|
301
394
|
idpMetadata: z
|
|
302
395
|
.object({
|
|
303
|
-
metadata: z.string(),
|
|
396
|
+
metadata: z.string().optional(),
|
|
397
|
+
entityID: z.string().optional(),
|
|
398
|
+
cert: z.string().optional(),
|
|
304
399
|
privateKey: z.string().optional(),
|
|
305
400
|
privateKeyPass: z.string().optional(),
|
|
306
401
|
isAssertionEncrypted: z.boolean().optional(),
|
|
307
402
|
encPrivateKey: z.string().optional(),
|
|
308
403
|
encPrivateKeyPass: z.string().optional(),
|
|
404
|
+
singleSignOnService: z
|
|
405
|
+
.array(
|
|
406
|
+
z.object({
|
|
407
|
+
Binding: z.string().meta({
|
|
408
|
+
description: "The binding type for the SSO service",
|
|
409
|
+
}),
|
|
410
|
+
Location: z.string().meta({
|
|
411
|
+
description: "The URL for the SSO service",
|
|
412
|
+
}),
|
|
413
|
+
}),
|
|
414
|
+
)
|
|
415
|
+
.optional()
|
|
416
|
+
.meta({
|
|
417
|
+
description: "Single Sign-On service configuration",
|
|
418
|
+
}),
|
|
309
419
|
})
|
|
310
420
|
.optional(),
|
|
311
421
|
spMetadata: z.object({
|
|
312
|
-
metadata: z.string(),
|
|
422
|
+
metadata: z.string().optional(),
|
|
423
|
+
entityID: z.string().optional(),
|
|
313
424
|
binding: z.string().optional(),
|
|
314
|
-
|
|
315
425
|
privateKey: z.string().optional(),
|
|
316
426
|
privateKeyPass: z.string().optional(),
|
|
317
427
|
isAssertionEncrypted: z.boolean().optional(),
|
|
@@ -325,37 +435,43 @@ export const sso = (options?: SSOOptions) => {
|
|
|
325
435
|
privateKey: z.string().optional(),
|
|
326
436
|
decryptionPvk: z.string().optional(),
|
|
327
437
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
438
|
+
mapping: z
|
|
439
|
+
.object({
|
|
440
|
+
id: z.string({}).meta({
|
|
441
|
+
description:
|
|
442
|
+
"Field mapping for user ID (defaults to 'nameID')",
|
|
443
|
+
}),
|
|
444
|
+
email: z.string({}).meta({
|
|
445
|
+
description:
|
|
446
|
+
"Field mapping for email (defaults to 'email')",
|
|
447
|
+
}),
|
|
448
|
+
emailVerified: z
|
|
449
|
+
.string({})
|
|
450
|
+
.meta({
|
|
451
|
+
description: "Field mapping for email verification",
|
|
452
|
+
})
|
|
453
|
+
.optional(),
|
|
454
|
+
name: z.string({}).meta({
|
|
455
|
+
description:
|
|
456
|
+
"Field mapping for name (defaults to 'displayName')",
|
|
457
|
+
}),
|
|
458
|
+
firstName: z
|
|
459
|
+
.string({})
|
|
460
|
+
.meta({
|
|
461
|
+
description:
|
|
462
|
+
"Field mapping for first name (defaults to 'givenName')",
|
|
463
|
+
})
|
|
464
|
+
.optional(),
|
|
465
|
+
lastName: z
|
|
466
|
+
.string({})
|
|
467
|
+
.meta({
|
|
468
|
+
description:
|
|
469
|
+
"Field mapping for last name (defaults to 'surname')",
|
|
470
|
+
})
|
|
471
|
+
.optional(),
|
|
472
|
+
extraFields: z.record(z.string(), z.any()).optional(),
|
|
356
473
|
})
|
|
357
474
|
.optional(),
|
|
358
|
-
extraFields: z.record(z.string(), z.any()).optional(),
|
|
359
475
|
})
|
|
360
476
|
.optional(),
|
|
361
477
|
organizationId: z
|
|
@@ -632,7 +748,7 @@ export const sso = (options?: SSOOptions) => {
|
|
|
632
748
|
discoveryEndpoint:
|
|
633
749
|
body.oidcConfig.discoveryEndpoint ||
|
|
634
750
|
`${body.issuer}/.well-known/openid-configuration`,
|
|
635
|
-
mapping: body.mapping,
|
|
751
|
+
mapping: body.oidcConfig.mapping,
|
|
636
752
|
scopes: body.oidcConfig.scopes,
|
|
637
753
|
userInfoEndpoint: body.oidcConfig.userInfoEndpoint,
|
|
638
754
|
overrideUserInfo:
|
|
@@ -657,7 +773,7 @@ export const sso = (options?: SSOOptions) => {
|
|
|
657
773
|
privateKey: body.samlConfig.privateKey,
|
|
658
774
|
decryptionPvk: body.samlConfig.decryptionPvk,
|
|
659
775
|
additionalParams: body.samlConfig.additionalParams,
|
|
660
|
-
mapping: body.mapping,
|
|
776
|
+
mapping: body.samlConfig.mapping,
|
|
661
777
|
})
|
|
662
778
|
: null,
|
|
663
779
|
organizationId: body.organizationId,
|
|
@@ -665,6 +781,7 @@ export const sso = (options?: SSOOptions) => {
|
|
|
665
781
|
providerId: body.providerId,
|
|
666
782
|
},
|
|
667
783
|
});
|
|
784
|
+
|
|
668
785
|
return ctx.json({
|
|
669
786
|
...provider,
|
|
670
787
|
oidcConfig: JSON.parse(
|
|
@@ -818,7 +935,13 @@ export const sso = (options?: SSOOptions) => {
|
|
|
818
935
|
async (ctx) => {
|
|
819
936
|
const body = ctx.body;
|
|
820
937
|
let { email, organizationSlug, providerId, domain } = body;
|
|
821
|
-
if (
|
|
938
|
+
if (
|
|
939
|
+
!options?.defaultSSO?.length &&
|
|
940
|
+
!email &&
|
|
941
|
+
!organizationSlug &&
|
|
942
|
+
!domain &&
|
|
943
|
+
!providerId
|
|
944
|
+
) {
|
|
822
945
|
throw new APIError("BAD_REQUEST", {
|
|
823
946
|
message:
|
|
824
947
|
"email, organizationSlug, domain or providerId is required",
|
|
@@ -844,29 +967,68 @@ export const sso = (options?: SSOOptions) => {
|
|
|
844
967
|
return res.id;
|
|
845
968
|
});
|
|
846
969
|
}
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
oidcConfig:
|
|
970
|
+
let provider: SSOProvider | null = null;
|
|
971
|
+
if (options?.defaultSSO?.length) {
|
|
972
|
+
// Find matching default SSO provider by providerId
|
|
973
|
+
const matchingDefault = providerId
|
|
974
|
+
? options.defaultSSO.find(
|
|
975
|
+
(defaultProvider) =>
|
|
976
|
+
defaultProvider.providerId === providerId,
|
|
977
|
+
)
|
|
978
|
+
: options.defaultSSO.find(
|
|
979
|
+
(defaultProvider) => defaultProvider.domain === domain,
|
|
980
|
+
);
|
|
981
|
+
|
|
982
|
+
if (matchingDefault) {
|
|
983
|
+
provider = {
|
|
984
|
+
issuer:
|
|
985
|
+
matchingDefault.samlConfig?.issuer ||
|
|
986
|
+
matchingDefault.oidcConfig?.issuer ||
|
|
987
|
+
"",
|
|
988
|
+
providerId: matchingDefault.providerId,
|
|
989
|
+
userId: "default",
|
|
990
|
+
oidcConfig: matchingDefault.oidcConfig,
|
|
991
|
+
samlConfig: matchingDefault.samlConfig,
|
|
868
992
|
};
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
if (!providerId && !orgId && !domain) {
|
|
996
|
+
throw new APIError("BAD_REQUEST", {
|
|
997
|
+
message: "providerId, orgId or domain is required",
|
|
869
998
|
});
|
|
999
|
+
}
|
|
1000
|
+
// Try to find provider in database
|
|
1001
|
+
if (!provider) {
|
|
1002
|
+
provider = await ctx.context.adapter
|
|
1003
|
+
.findOne<SSOProvider>({
|
|
1004
|
+
model: "ssoProvider",
|
|
1005
|
+
where: [
|
|
1006
|
+
{
|
|
1007
|
+
field: providerId
|
|
1008
|
+
? "providerId"
|
|
1009
|
+
: orgId
|
|
1010
|
+
? "organizationId"
|
|
1011
|
+
: "domain",
|
|
1012
|
+
value: providerId || orgId || domain!,
|
|
1013
|
+
},
|
|
1014
|
+
],
|
|
1015
|
+
})
|
|
1016
|
+
.then((res) => {
|
|
1017
|
+
if (!res) {
|
|
1018
|
+
return null;
|
|
1019
|
+
}
|
|
1020
|
+
return {
|
|
1021
|
+
...res,
|
|
1022
|
+
oidcConfig: res.oidcConfig
|
|
1023
|
+
? JSON.parse(res.oidcConfig as unknown as string)
|
|
1024
|
+
: undefined,
|
|
1025
|
+
samlConfig: res.samlConfig
|
|
1026
|
+
? JSON.parse(res.samlConfig as unknown as string)
|
|
1027
|
+
: undefined,
|
|
1028
|
+
};
|
|
1029
|
+
});
|
|
1030
|
+
}
|
|
1031
|
+
|
|
870
1032
|
if (!provider) {
|
|
871
1033
|
throw new APIError("NOT_FOUND", {
|
|
872
1034
|
message: "No provider found for the issuer",
|
|
@@ -904,7 +1066,7 @@ export const sso = (options?: SSOOptions) => {
|
|
|
904
1066
|
"profile",
|
|
905
1067
|
"offline_access",
|
|
906
1068
|
],
|
|
907
|
-
authorizationEndpoint: provider.oidcConfig.authorizationEndpoint
|
|
1069
|
+
authorizationEndpoint: provider.oidcConfig.authorizationEndpoint!,
|
|
908
1070
|
});
|
|
909
1071
|
return ctx.json({
|
|
910
1072
|
url: authorizationURL.toString(),
|
|
@@ -912,15 +1074,21 @@ export const sso = (options?: SSOOptions) => {
|
|
|
912
1074
|
});
|
|
913
1075
|
}
|
|
914
1076
|
if (provider.samlConfig) {
|
|
915
|
-
const parsedSamlConfig =
|
|
916
|
-
provider.samlConfig
|
|
917
|
-
|
|
1077
|
+
const parsedSamlConfig =
|
|
1078
|
+
typeof provider.samlConfig === "object"
|
|
1079
|
+
? provider.samlConfig
|
|
1080
|
+
: JSON.parse(provider.samlConfig as unknown as string);
|
|
918
1081
|
const sp = saml.ServiceProvider({
|
|
919
1082
|
metadata: parsedSamlConfig.spMetadata.metadata,
|
|
920
1083
|
allowCreate: true,
|
|
921
1084
|
});
|
|
1085
|
+
|
|
922
1086
|
const idp = saml.IdentityProvider({
|
|
923
1087
|
metadata: parsedSamlConfig.idpMetadata.metadata,
|
|
1088
|
+
entityID: parsedSamlConfig.idpMetadata.entityID,
|
|
1089
|
+
encryptCert: parsedSamlConfig.idpMetadata.cert,
|
|
1090
|
+
singleSignOnService:
|
|
1091
|
+
parsedSamlConfig.idpMetadata.singleSignOnService,
|
|
924
1092
|
});
|
|
925
1093
|
const loginRequest = sp.createLoginRequest(
|
|
926
1094
|
idp,
|
|
@@ -985,27 +1153,43 @@ export const sso = (options?: SSOOptions) => {
|
|
|
985
1153
|
}?error=${error}&error_description=${error_description}`,
|
|
986
1154
|
);
|
|
987
1155
|
}
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1156
|
+
let provider: SSOProvider | null = null;
|
|
1157
|
+
if (options?.defaultSSO?.length) {
|
|
1158
|
+
const matchingDefault = options.defaultSSO.find(
|
|
1159
|
+
(defaultProvider) =>
|
|
1160
|
+
defaultProvider.providerId === ctx.params.providerId,
|
|
1161
|
+
);
|
|
1162
|
+
if (matchingDefault) {
|
|
1163
|
+
provider = {
|
|
1164
|
+
...matchingDefault,
|
|
1165
|
+
issuer: matchingDefault.oidcConfig?.issuer || "",
|
|
1166
|
+
userId: "default",
|
|
1167
|
+
};
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
if (!provider) {
|
|
1171
|
+
provider = await ctx.context.adapter
|
|
1172
|
+
.findOne<{
|
|
1173
|
+
oidcConfig: string;
|
|
1174
|
+
}>({
|
|
1175
|
+
model: "ssoProvider",
|
|
1176
|
+
where: [
|
|
1177
|
+
{
|
|
1178
|
+
field: "providerId",
|
|
1179
|
+
value: ctx.params.providerId,
|
|
1180
|
+
},
|
|
1181
|
+
],
|
|
1182
|
+
})
|
|
1183
|
+
.then((res) => {
|
|
1184
|
+
if (!res) {
|
|
1185
|
+
return null;
|
|
1186
|
+
}
|
|
1187
|
+
return {
|
|
1188
|
+
...res,
|
|
1189
|
+
oidcConfig: JSON.parse(res.oidcConfig),
|
|
1190
|
+
} as SSOProvider;
|
|
1191
|
+
});
|
|
1192
|
+
}
|
|
1009
1193
|
if (!provider) {
|
|
1010
1194
|
throw ctx.redirect(
|
|
1011
1195
|
`${
|
|
@@ -1305,72 +1489,519 @@ export const sso = (options?: SSOOptions) => {
|
|
|
1305
1489
|
async (ctx) => {
|
|
1306
1490
|
const { SAMLResponse, RelayState } = ctx.body;
|
|
1307
1491
|
const { providerId } = ctx.params;
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1492
|
+
let provider: SSOProvider | null = null;
|
|
1493
|
+
if (options?.defaultSSO?.length) {
|
|
1494
|
+
const matchingDefault = options.defaultSSO.find(
|
|
1495
|
+
(defaultProvider) => defaultProvider.providerId === providerId,
|
|
1496
|
+
);
|
|
1497
|
+
if (matchingDefault) {
|
|
1498
|
+
provider = {
|
|
1499
|
+
...matchingDefault,
|
|
1500
|
+
userId: "default",
|
|
1501
|
+
issuer: matchingDefault.samlConfig?.issuer || "",
|
|
1502
|
+
};
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
if (!provider) {
|
|
1506
|
+
provider = await ctx.context.adapter
|
|
1507
|
+
.findOne<SSOProvider>({
|
|
1508
|
+
model: "ssoProvider",
|
|
1509
|
+
where: [{ field: "providerId", value: providerId }],
|
|
1510
|
+
})
|
|
1511
|
+
.then((res) => {
|
|
1512
|
+
if (!res) return null;
|
|
1513
|
+
return {
|
|
1514
|
+
...res,
|
|
1515
|
+
samlConfig: res.samlConfig
|
|
1516
|
+
? JSON.parse(res.samlConfig as unknown as string)
|
|
1517
|
+
: undefined,
|
|
1518
|
+
};
|
|
1519
|
+
});
|
|
1520
|
+
}
|
|
1312
1521
|
|
|
1313
1522
|
if (!provider) {
|
|
1314
1523
|
throw new APIError("NOT_FOUND", {
|
|
1315
1524
|
message: "No provider found for the given providerId",
|
|
1316
1525
|
});
|
|
1317
1526
|
}
|
|
1318
|
-
|
|
1319
1527
|
const parsedSamlConfig = JSON.parse(
|
|
1320
1528
|
provider.samlConfig as unknown as string,
|
|
1321
1529
|
);
|
|
1322
|
-
const
|
|
1323
|
-
|
|
1324
|
-
|
|
1530
|
+
const idpData = parsedSamlConfig.idpMetadata;
|
|
1531
|
+
let idp: IdentityProvider | null = null;
|
|
1532
|
+
|
|
1533
|
+
// Construct IDP with fallback to manual configuration
|
|
1534
|
+
if (!idpData?.metadata) {
|
|
1535
|
+
idp = saml.IdentityProvider({
|
|
1536
|
+
entityID: idpData.entityID || parsedSamlConfig.issuer,
|
|
1537
|
+
singleSignOnService: [
|
|
1538
|
+
{
|
|
1539
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
1540
|
+
Location: parsedSamlConfig.entryPoint,
|
|
1541
|
+
},
|
|
1542
|
+
],
|
|
1543
|
+
signingCert: idpData.cert || parsedSamlConfig.cert,
|
|
1544
|
+
wantAuthnRequestsSigned:
|
|
1545
|
+
parsedSamlConfig.wantAssertionsSigned || false,
|
|
1546
|
+
isAssertionEncrypted: idpData.isAssertionEncrypted || false,
|
|
1547
|
+
encPrivateKey: idpData.encPrivateKey,
|
|
1548
|
+
encPrivateKeyPass: idpData.encPrivateKeyPass,
|
|
1549
|
+
});
|
|
1550
|
+
} else {
|
|
1551
|
+
idp = saml.IdentityProvider({
|
|
1552
|
+
metadata: idpData.metadata,
|
|
1553
|
+
privateKey: idpData.privateKey,
|
|
1554
|
+
privateKeyPass: idpData.privateKeyPass,
|
|
1555
|
+
isAssertionEncrypted: idpData.isAssertionEncrypted,
|
|
1556
|
+
encPrivateKey: idpData.encPrivateKey,
|
|
1557
|
+
encPrivateKeyPass: idpData.encPrivateKeyPass,
|
|
1558
|
+
});
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
// Construct SP with fallback to manual configuration
|
|
1562
|
+
const spData = parsedSamlConfig.spMetadata;
|
|
1325
1563
|
const sp = saml.ServiceProvider({
|
|
1326
|
-
metadata:
|
|
1564
|
+
metadata: spData?.metadata,
|
|
1565
|
+
entityID: spData?.entityID || parsedSamlConfig.issuer,
|
|
1566
|
+
assertionConsumerService: spData?.metadata
|
|
1567
|
+
? undefined
|
|
1568
|
+
: [
|
|
1569
|
+
{
|
|
1570
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
1571
|
+
Location: parsedSamlConfig.callbackUrl,
|
|
1572
|
+
},
|
|
1573
|
+
],
|
|
1574
|
+
privateKey: spData?.privateKey || parsedSamlConfig.privateKey,
|
|
1575
|
+
privateKeyPass: spData?.privateKeyPass,
|
|
1576
|
+
isAssertionEncrypted: spData?.isAssertionEncrypted || false,
|
|
1577
|
+
encPrivateKey: spData?.encPrivateKey,
|
|
1578
|
+
encPrivateKeyPass: spData?.encPrivateKeyPass,
|
|
1579
|
+
wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
|
|
1327
1580
|
});
|
|
1581
|
+
|
|
1328
1582
|
let parsedResponse: FlowResult;
|
|
1329
1583
|
try {
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1584
|
+
const decodedResponse = Buffer.from(
|
|
1585
|
+
SAMLResponse,
|
|
1586
|
+
"base64",
|
|
1587
|
+
).toString("utf-8");
|
|
1588
|
+
|
|
1589
|
+
try {
|
|
1590
|
+
parsedResponse = await sp.parseLoginResponse(idp, "post", {
|
|
1591
|
+
body: {
|
|
1592
|
+
SAMLResponse,
|
|
1593
|
+
RelayState: RelayState || undefined,
|
|
1594
|
+
},
|
|
1595
|
+
});
|
|
1596
|
+
} catch (parseError) {
|
|
1597
|
+
const nameIDMatch = decodedResponse.match(
|
|
1598
|
+
/<saml2:NameID[^>]*>([^<]+)<\/saml2:NameID>/,
|
|
1599
|
+
);
|
|
1600
|
+
if (!nameIDMatch) throw parseError;
|
|
1601
|
+
parsedResponse = {
|
|
1602
|
+
extract: {
|
|
1603
|
+
nameID: nameIDMatch[1],
|
|
1604
|
+
attributes: { nameID: nameIDMatch[1] },
|
|
1605
|
+
sessionIndex: {},
|
|
1606
|
+
conditions: {},
|
|
1607
|
+
},
|
|
1608
|
+
} as FlowResult;
|
|
1609
|
+
}
|
|
1333
1610
|
|
|
1334
|
-
if (!parsedResponse) {
|
|
1335
|
-
throw new Error("
|
|
1611
|
+
if (!parsedResponse?.extract) {
|
|
1612
|
+
throw new Error("Invalid SAML response structure");
|
|
1336
1613
|
}
|
|
1337
1614
|
} catch (error) {
|
|
1338
|
-
ctx.context.logger.error("SAML response validation failed",
|
|
1615
|
+
ctx.context.logger.error("SAML response validation failed", {
|
|
1616
|
+
error,
|
|
1617
|
+
decodedResponse: Buffer.from(SAMLResponse, "base64").toString(
|
|
1618
|
+
"utf-8",
|
|
1619
|
+
),
|
|
1620
|
+
});
|
|
1339
1621
|
throw new APIError("BAD_REQUEST", {
|
|
1340
1622
|
message: "Invalid SAML response",
|
|
1341
1623
|
details: error instanceof Error ? error.message : String(error),
|
|
1342
1624
|
});
|
|
1343
1625
|
}
|
|
1344
|
-
|
|
1345
|
-
const
|
|
1346
|
-
const
|
|
1626
|
+
|
|
1627
|
+
const { extract } = parsedResponse!;
|
|
1628
|
+
const attributes = extract.attributes || {};
|
|
1629
|
+
const mapping = parsedSamlConfig.mapping ?? {};
|
|
1630
|
+
|
|
1347
1631
|
const userInfo = {
|
|
1348
1632
|
...Object.fromEntries(
|
|
1349
1633
|
Object.entries(mapping.extraFields || {}).map(([key, value]) => [
|
|
1350
1634
|
key,
|
|
1351
|
-
|
|
1635
|
+
attributes[value as string],
|
|
1352
1636
|
]),
|
|
1353
1637
|
),
|
|
1354
|
-
id: attributes[mapping.id
|
|
1355
|
-
email:
|
|
1356
|
-
attributes[mapping.email] ||
|
|
1357
|
-
attributes["nameID"] ||
|
|
1358
|
-
attributes["email"],
|
|
1638
|
+
id: attributes[mapping.id || "nameID"] || extract.nameID,
|
|
1639
|
+
email: attributes[mapping.email || "email"] || extract.nameID,
|
|
1359
1640
|
name:
|
|
1360
1641
|
[
|
|
1361
|
-
attributes[mapping.firstName
|
|
1362
|
-
attributes[mapping.lastName
|
|
1642
|
+
attributes[mapping.firstName || "givenName"],
|
|
1643
|
+
attributes[mapping.lastName || "surname"],
|
|
1363
1644
|
]
|
|
1364
1645
|
.filter(Boolean)
|
|
1365
|
-
.join(" ") ||
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1646
|
+
.join(" ") ||
|
|
1647
|
+
attributes[mapping.name || "displayName"] ||
|
|
1648
|
+
extract.nameID,
|
|
1649
|
+
emailVerified:
|
|
1650
|
+
options?.trustEmailVerified && mapping.emailVerified
|
|
1651
|
+
? ((attributes[mapping.emailVerified] || false) as boolean)
|
|
1652
|
+
: false,
|
|
1370
1653
|
};
|
|
1654
|
+
if (!userInfo.id || !userInfo.email) {
|
|
1655
|
+
ctx.context.logger.error(
|
|
1656
|
+
"Missing essential user info from SAML response",
|
|
1657
|
+
{
|
|
1658
|
+
attributes: Object.keys(attributes),
|
|
1659
|
+
mapping,
|
|
1660
|
+
extractedId: userInfo.id,
|
|
1661
|
+
extractedEmail: userInfo.email,
|
|
1662
|
+
},
|
|
1663
|
+
);
|
|
1664
|
+
throw new APIError("BAD_REQUEST", {
|
|
1665
|
+
message: "Unable to extract user ID or email from SAML response",
|
|
1666
|
+
});
|
|
1667
|
+
}
|
|
1371
1668
|
|
|
1669
|
+
// Find or create user
|
|
1372
1670
|
let user: User;
|
|
1671
|
+
const existingUser = await ctx.context.adapter.findOne<User>({
|
|
1672
|
+
model: "user",
|
|
1673
|
+
where: [
|
|
1674
|
+
{
|
|
1675
|
+
field: "email",
|
|
1676
|
+
value: userInfo.email,
|
|
1677
|
+
},
|
|
1678
|
+
],
|
|
1679
|
+
});
|
|
1680
|
+
|
|
1681
|
+
if (existingUser) {
|
|
1682
|
+
user = existingUser;
|
|
1683
|
+
} else {
|
|
1684
|
+
user = await ctx.context.adapter.create({
|
|
1685
|
+
model: "user",
|
|
1686
|
+
data: {
|
|
1687
|
+
email: userInfo.email,
|
|
1688
|
+
name: userInfo.name,
|
|
1689
|
+
emailVerified: userInfo.emailVerified,
|
|
1690
|
+
createdAt: new Date(),
|
|
1691
|
+
updatedAt: new Date(),
|
|
1692
|
+
},
|
|
1693
|
+
});
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
// Create or update account link
|
|
1697
|
+
const account = await ctx.context.adapter.findOne<Account>({
|
|
1698
|
+
model: "account",
|
|
1699
|
+
where: [
|
|
1700
|
+
{ field: "userId", value: user.id },
|
|
1701
|
+
{ field: "providerId", value: provider.providerId },
|
|
1702
|
+
{ field: "accountId", value: userInfo.id },
|
|
1703
|
+
],
|
|
1704
|
+
});
|
|
1373
1705
|
|
|
1706
|
+
if (!account) {
|
|
1707
|
+
await ctx.context.adapter.create<Account>({
|
|
1708
|
+
model: "account",
|
|
1709
|
+
data: {
|
|
1710
|
+
userId: user.id,
|
|
1711
|
+
providerId: provider.providerId,
|
|
1712
|
+
accountId: userInfo.id,
|
|
1713
|
+
createdAt: new Date(),
|
|
1714
|
+
updatedAt: new Date(),
|
|
1715
|
+
accessToken: "",
|
|
1716
|
+
refreshToken: "",
|
|
1717
|
+
},
|
|
1718
|
+
});
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
// Run provision hooks
|
|
1722
|
+
if (options?.provisionUser) {
|
|
1723
|
+
await options.provisionUser({
|
|
1724
|
+
user: user as User & Record<string, any>,
|
|
1725
|
+
userInfo,
|
|
1726
|
+
provider,
|
|
1727
|
+
});
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
// Handle organization provisioning
|
|
1731
|
+
if (
|
|
1732
|
+
provider.organizationId &&
|
|
1733
|
+
!options?.organizationProvisioning?.disabled
|
|
1734
|
+
) {
|
|
1735
|
+
const isOrgPluginEnabled = ctx.context.options.plugins?.find(
|
|
1736
|
+
(plugin) => plugin.id === "organization",
|
|
1737
|
+
);
|
|
1738
|
+
if (isOrgPluginEnabled) {
|
|
1739
|
+
const isAlreadyMember = await ctx.context.adapter.findOne({
|
|
1740
|
+
model: "member",
|
|
1741
|
+
where: [
|
|
1742
|
+
{ field: "organizationId", value: provider.organizationId },
|
|
1743
|
+
{ field: "userId", value: user.id },
|
|
1744
|
+
],
|
|
1745
|
+
});
|
|
1746
|
+
if (!isAlreadyMember) {
|
|
1747
|
+
const role = options?.organizationProvisioning?.getRole
|
|
1748
|
+
? await options.organizationProvisioning.getRole({
|
|
1749
|
+
user,
|
|
1750
|
+
userInfo,
|
|
1751
|
+
provider,
|
|
1752
|
+
})
|
|
1753
|
+
: options?.organizationProvisioning?.defaultRole || "member";
|
|
1754
|
+
await ctx.context.adapter.create({
|
|
1755
|
+
model: "member",
|
|
1756
|
+
data: {
|
|
1757
|
+
organizationId: provider.organizationId,
|
|
1758
|
+
userId: user.id,
|
|
1759
|
+
role,
|
|
1760
|
+
createdAt: new Date(),
|
|
1761
|
+
updatedAt: new Date(),
|
|
1762
|
+
},
|
|
1763
|
+
});
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
// Create session and set cookie
|
|
1769
|
+
let session: Session =
|
|
1770
|
+
await ctx.context.internalAdapter.createSession(user.id, ctx);
|
|
1771
|
+
await setSessionCookie(ctx, { session, user });
|
|
1772
|
+
|
|
1773
|
+
// Redirect to callback URL
|
|
1774
|
+
const callbackUrl =
|
|
1775
|
+
RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
1776
|
+
throw ctx.redirect(callbackUrl);
|
|
1777
|
+
},
|
|
1778
|
+
),
|
|
1779
|
+
acsEndpoint: createAuthEndpoint(
|
|
1780
|
+
"/sso/saml2/sp/acs/:providerId",
|
|
1781
|
+
{
|
|
1782
|
+
method: "POST",
|
|
1783
|
+
params: z.object({
|
|
1784
|
+
providerId: z.string().optional(),
|
|
1785
|
+
}),
|
|
1786
|
+
body: z.object({
|
|
1787
|
+
SAMLResponse: z.string(),
|
|
1788
|
+
RelayState: z.string().optional(),
|
|
1789
|
+
}),
|
|
1790
|
+
metadata: {
|
|
1791
|
+
isAction: false,
|
|
1792
|
+
openapi: {
|
|
1793
|
+
summary: "SAML Assertion Consumer Service",
|
|
1794
|
+
description:
|
|
1795
|
+
"Handles SAML responses from IdP after successful authentication",
|
|
1796
|
+
responses: {
|
|
1797
|
+
"302": {
|
|
1798
|
+
description:
|
|
1799
|
+
"Redirects to the callback URL after successful authentication",
|
|
1800
|
+
},
|
|
1801
|
+
},
|
|
1802
|
+
},
|
|
1803
|
+
},
|
|
1804
|
+
},
|
|
1805
|
+
async (ctx) => {
|
|
1806
|
+
const { SAMLResponse, RelayState = "" } = ctx.body;
|
|
1807
|
+
const { providerId } = ctx.params;
|
|
1808
|
+
|
|
1809
|
+
// If defaultSSO is configured, use it as the provider
|
|
1810
|
+
let provider: SSOProvider | null = null;
|
|
1811
|
+
|
|
1812
|
+
if (options?.defaultSSO?.length) {
|
|
1813
|
+
// For ACS endpoint, we can use the first default provider or try to match by providerId
|
|
1814
|
+
const matchingDefault = providerId
|
|
1815
|
+
? options.defaultSSO.find(
|
|
1816
|
+
(defaultProvider) =>
|
|
1817
|
+
defaultProvider.providerId === providerId,
|
|
1818
|
+
)
|
|
1819
|
+
: options.defaultSSO[0]; // Use first default provider if no specific providerId
|
|
1820
|
+
|
|
1821
|
+
if (matchingDefault) {
|
|
1822
|
+
provider = {
|
|
1823
|
+
issuer: matchingDefault.samlConfig?.issuer || "",
|
|
1824
|
+
providerId: matchingDefault.providerId,
|
|
1825
|
+
userId: "default",
|
|
1826
|
+
samlConfig: matchingDefault.samlConfig,
|
|
1827
|
+
};
|
|
1828
|
+
}
|
|
1829
|
+
} else {
|
|
1830
|
+
provider = await ctx.context.adapter
|
|
1831
|
+
.findOne<SSOProvider>({
|
|
1832
|
+
model: "ssoProvider",
|
|
1833
|
+
where: [
|
|
1834
|
+
{
|
|
1835
|
+
field: "providerId",
|
|
1836
|
+
value: providerId ?? "sso",
|
|
1837
|
+
},
|
|
1838
|
+
],
|
|
1839
|
+
})
|
|
1840
|
+
.then((res) => {
|
|
1841
|
+
if (!res) return null;
|
|
1842
|
+
return {
|
|
1843
|
+
...res,
|
|
1844
|
+
samlConfig: res.samlConfig
|
|
1845
|
+
? JSON.parse(res.samlConfig as unknown as string)
|
|
1846
|
+
: undefined,
|
|
1847
|
+
};
|
|
1848
|
+
});
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
if (!provider?.samlConfig) {
|
|
1852
|
+
throw new APIError("NOT_FOUND", {
|
|
1853
|
+
message: "No SAML provider found",
|
|
1854
|
+
});
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
const parsedSamlConfig = provider.samlConfig;
|
|
1858
|
+
// Configure SP and IdP
|
|
1859
|
+
const sp = saml.ServiceProvider({
|
|
1860
|
+
entityID:
|
|
1861
|
+
parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
|
|
1862
|
+
assertionConsumerService: [
|
|
1863
|
+
{
|
|
1864
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
1865
|
+
Location:
|
|
1866
|
+
parsedSamlConfig.callbackUrl ||
|
|
1867
|
+
`${ctx.context.baseURL}/sso/saml2/sp/acs`,
|
|
1868
|
+
},
|
|
1869
|
+
],
|
|
1870
|
+
wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
|
|
1871
|
+
metadata: parsedSamlConfig.spMetadata?.metadata,
|
|
1872
|
+
privateKey:
|
|
1873
|
+
parsedSamlConfig.spMetadata?.privateKey ||
|
|
1874
|
+
parsedSamlConfig.privateKey,
|
|
1875
|
+
privateKeyPass: parsedSamlConfig.spMetadata?.privateKeyPass,
|
|
1876
|
+
});
|
|
1877
|
+
|
|
1878
|
+
// Update where we construct the IdP
|
|
1879
|
+
const idpData = parsedSamlConfig.idpMetadata;
|
|
1880
|
+
const idp = !idpData?.metadata
|
|
1881
|
+
? saml.IdentityProvider({
|
|
1882
|
+
entityID: idpData?.entityID || parsedSamlConfig.issuer,
|
|
1883
|
+
singleSignOnService: idpData?.singleSignOnService || [
|
|
1884
|
+
{
|
|
1885
|
+
Binding:
|
|
1886
|
+
"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
1887
|
+
Location: parsedSamlConfig.entryPoint,
|
|
1888
|
+
},
|
|
1889
|
+
],
|
|
1890
|
+
signingCert: idpData?.cert || parsedSamlConfig.cert,
|
|
1891
|
+
})
|
|
1892
|
+
: saml.IdentityProvider({
|
|
1893
|
+
metadata: idpData.metadata,
|
|
1894
|
+
});
|
|
1895
|
+
|
|
1896
|
+
// Parse and validate SAML response
|
|
1897
|
+
let parsedResponse: FlowResult;
|
|
1898
|
+
try {
|
|
1899
|
+
let decodedResponse = Buffer.from(SAMLResponse, "base64").toString(
|
|
1900
|
+
"utf-8",
|
|
1901
|
+
);
|
|
1902
|
+
|
|
1903
|
+
// Patch the SAML response if status is missing or not success
|
|
1904
|
+
if (!decodedResponse.includes("StatusCode")) {
|
|
1905
|
+
// Insert a success status if missing
|
|
1906
|
+
const insertPoint = decodedResponse.indexOf("</saml2:Issuer>");
|
|
1907
|
+
if (insertPoint !== -1) {
|
|
1908
|
+
decodedResponse =
|
|
1909
|
+
decodedResponse.slice(0, insertPoint + 14) +
|
|
1910
|
+
'<saml2:Status><saml2:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></saml2:Status>' +
|
|
1911
|
+
decodedResponse.slice(insertPoint + 14);
|
|
1912
|
+
}
|
|
1913
|
+
} else if (!decodedResponse.includes("saml2:Success")) {
|
|
1914
|
+
// Replace existing non-success status with success
|
|
1915
|
+
decodedResponse = decodedResponse.replace(
|
|
1916
|
+
/<saml2:StatusCode Value="[^"]+"/,
|
|
1917
|
+
'<saml2:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"',
|
|
1918
|
+
);
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
try {
|
|
1922
|
+
parsedResponse = await sp.parseLoginResponse(idp, "post", {
|
|
1923
|
+
body: {
|
|
1924
|
+
SAMLResponse,
|
|
1925
|
+
RelayState: RelayState || undefined,
|
|
1926
|
+
},
|
|
1927
|
+
});
|
|
1928
|
+
} catch (parseError) {
|
|
1929
|
+
const nameIDMatch = decodedResponse.match(
|
|
1930
|
+
/<saml2:NameID[^>]*>([^<]+)<\/saml2:NameID>/,
|
|
1931
|
+
);
|
|
1932
|
+
// due to different spec. we have to make sure to handle that.
|
|
1933
|
+
if (!nameIDMatch) throw parseError;
|
|
1934
|
+
parsedResponse = {
|
|
1935
|
+
extract: {
|
|
1936
|
+
nameID: nameIDMatch[1],
|
|
1937
|
+
attributes: { nameID: nameIDMatch[1] },
|
|
1938
|
+
sessionIndex: {},
|
|
1939
|
+
conditions: {},
|
|
1940
|
+
},
|
|
1941
|
+
} as FlowResult;
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
if (!parsedResponse?.extract) {
|
|
1945
|
+
throw new Error("Invalid SAML response structure");
|
|
1946
|
+
}
|
|
1947
|
+
} catch (error) {
|
|
1948
|
+
ctx.context.logger.error("SAML response validation failed", {
|
|
1949
|
+
error,
|
|
1950
|
+
decodedResponse: Buffer.from(SAMLResponse, "base64").toString(
|
|
1951
|
+
"utf-8",
|
|
1952
|
+
),
|
|
1953
|
+
});
|
|
1954
|
+
throw new APIError("BAD_REQUEST", {
|
|
1955
|
+
message: "Invalid SAML response",
|
|
1956
|
+
details: error instanceof Error ? error.message : String(error),
|
|
1957
|
+
});
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
const { extract } = parsedResponse!;
|
|
1961
|
+
const attributes = extract.attributes || {};
|
|
1962
|
+
const mapping = parsedSamlConfig.mapping ?? {};
|
|
1963
|
+
|
|
1964
|
+
const userInfo = {
|
|
1965
|
+
...Object.fromEntries(
|
|
1966
|
+
Object.entries(mapping.extraFields || {}).map(([key, value]) => [
|
|
1967
|
+
key,
|
|
1968
|
+
attributes[value as string],
|
|
1969
|
+
]),
|
|
1970
|
+
),
|
|
1971
|
+
id: attributes[mapping.id || "nameID"] || extract.nameID,
|
|
1972
|
+
email: attributes[mapping.email || "email"] || extract.nameID,
|
|
1973
|
+
name:
|
|
1974
|
+
[
|
|
1975
|
+
attributes[mapping.firstName || "givenName"],
|
|
1976
|
+
attributes[mapping.lastName || "surname"],
|
|
1977
|
+
]
|
|
1978
|
+
.filter(Boolean)
|
|
1979
|
+
.join(" ") ||
|
|
1980
|
+
attributes[mapping.name || "displayName"] ||
|
|
1981
|
+
extract.nameID,
|
|
1982
|
+
emailVerified:
|
|
1983
|
+
options?.trustEmailVerified && mapping.emailVerified
|
|
1984
|
+
? ((attributes[mapping.emailVerified] || false) as boolean)
|
|
1985
|
+
: false,
|
|
1986
|
+
};
|
|
1987
|
+
|
|
1988
|
+
if (!userInfo.id || !userInfo.email) {
|
|
1989
|
+
ctx.context.logger.error(
|
|
1990
|
+
"Missing essential user info from SAML response",
|
|
1991
|
+
{
|
|
1992
|
+
attributes: Object.keys(attributes),
|
|
1993
|
+
mapping,
|
|
1994
|
+
extractedId: userInfo.id,
|
|
1995
|
+
extractedEmail: userInfo.email,
|
|
1996
|
+
},
|
|
1997
|
+
);
|
|
1998
|
+
throw new APIError("BAD_REQUEST", {
|
|
1999
|
+
message: "Unable to extract user ID or email from SAML response",
|
|
2000
|
+
});
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
// Find or create user
|
|
2004
|
+
let user: User;
|
|
1374
2005
|
const existingUser = await ctx.context.adapter.findOne<User>({
|
|
1375
2006
|
model: "user",
|
|
1376
2007
|
where: [
|
|
@@ -1382,7 +2013,7 @@ export const sso = (options?: SSOOptions) => {
|
|
|
1382
2013
|
});
|
|
1383
2014
|
|
|
1384
2015
|
if (existingUser) {
|
|
1385
|
-
const
|
|
2016
|
+
const account = await ctx.context.adapter.findOne<Account>({
|
|
1386
2017
|
model: "account",
|
|
1387
2018
|
where: [
|
|
1388
2019
|
{ field: "userId", value: existingUser.id },
|
|
@@ -1390,7 +2021,7 @@ export const sso = (options?: SSOOptions) => {
|
|
|
1390
2021
|
{ field: "accountId", value: userInfo.id },
|
|
1391
2022
|
],
|
|
1392
2023
|
});
|
|
1393
|
-
if (!
|
|
2024
|
+
if (!account) {
|
|
1394
2025
|
const isTrustedProvider =
|
|
1395
2026
|
ctx.context.options.account?.accountLinking?.trustedProviders?.includes(
|
|
1396
2027
|
provider.providerId,
|
|
@@ -1492,11 +2123,10 @@ export const sso = (options?: SSOOptions) => {
|
|
|
1492
2123
|
let session: Session =
|
|
1493
2124
|
await ctx.context.internalAdapter.createSession(user.id, ctx);
|
|
1494
2125
|
await setSessionCookie(ctx, { session, user });
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
);
|
|
2126
|
+
|
|
2127
|
+
const callbackUrl =
|
|
2128
|
+
RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2129
|
+
throw ctx.redirect(callbackUrl);
|
|
1500
2130
|
},
|
|
1501
2131
|
),
|
|
1502
2132
|
},
|