@better-auth/sso 1.4.0-beta.1 → 1.4.0-beta.10
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 +23 -15
- package/dist/client.cjs +8 -6
- package/dist/client.d.cts +6 -8
- package/dist/client.d.ts +6 -8
- package/dist/client.js +12 -0
- package/dist/index-D0i1jHBp.d.cts +959 -0
- package/dist/index-Drgwy6ZL.d.ts +959 -0
- package/dist/index.cjs +2 -1161
- package/dist/index.d.cts +2 -812
- package/dist/index.d.ts +2 -812
- package/dist/index.js +3 -0
- package/dist/src-DLLHZrD9.cjs +1250 -0
- package/dist/src-DrA9mJEu.js +1212 -0
- package/package.json +13 -12
- package/src/index.ts +882 -149
- package/src/oidc.test.ts +153 -172
- package/src/saml.test.ts +212 -8
- package/tsconfig.json +4 -15
- package/tsdown.config.ts +8 -0
- package/CHANGELOG.md +0 -20
- package/build.config.ts +0 -12
- package/dist/client.d.mts +0 -11
- package/dist/client.mjs +0 -8
- package/dist/index.d.mts +0 -812
- package/dist/index.mjs +0 -1145
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,53 @@ const fastValidator = {
|
|
|
37
38
|
|
|
38
39
|
saml.setSchemaValidator(fastValidator);
|
|
39
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Safely parses a value that might be a JSON string or already a parsed object
|
|
43
|
+
* This handles cases where ORMs like Drizzle might return already parsed objects
|
|
44
|
+
* instead of JSON strings from TEXT/JSON columns
|
|
45
|
+
*/
|
|
46
|
+
function safeJsonParse<T>(value: string | T | null | undefined): T | null {
|
|
47
|
+
if (!value) return null;
|
|
48
|
+
|
|
49
|
+
// If it's already an object (not a string), return it as-is
|
|
50
|
+
if (typeof value === "object") {
|
|
51
|
+
return value as T;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// If it's a string, try to parse it
|
|
55
|
+
if (typeof value === "string") {
|
|
56
|
+
try {
|
|
57
|
+
return JSON.parse(value) as T;
|
|
58
|
+
} catch (error) {
|
|
59
|
+
// If parsing fails, this might indicate the string is not valid JSON
|
|
60
|
+
throw new Error(
|
|
61
|
+
`Failed to parse JSON: ${error instanceof Error ? error.message : "Unknown error"}`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface OIDCMapping {
|
|
70
|
+
id?: string;
|
|
71
|
+
email?: string;
|
|
72
|
+
emailVerified?: string;
|
|
73
|
+
name?: string;
|
|
74
|
+
image?: string;
|
|
75
|
+
extraFields?: Record<string, string>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface SAMLMapping {
|
|
79
|
+
id?: string;
|
|
80
|
+
email?: string;
|
|
81
|
+
emailVerified?: string;
|
|
82
|
+
name?: string;
|
|
83
|
+
firstName?: string;
|
|
84
|
+
lastName?: string;
|
|
85
|
+
extraFields?: Record<string, string>;
|
|
86
|
+
}
|
|
87
|
+
|
|
40
88
|
export interface OIDCConfig {
|
|
41
89
|
issuer: string;
|
|
42
90
|
pkce: boolean;
|
|
@@ -50,30 +98,49 @@ export interface OIDCConfig {
|
|
|
50
98
|
tokenEndpoint?: string;
|
|
51
99
|
tokenEndpointAuthentication?: "client_secret_post" | "client_secret_basic";
|
|
52
100
|
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
|
-
};
|
|
101
|
+
mapping?: OIDCMapping;
|
|
61
102
|
}
|
|
62
103
|
|
|
63
104
|
export interface SAMLConfig {
|
|
64
105
|
issuer: string;
|
|
65
106
|
entryPoint: string;
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
107
|
+
cert: string;
|
|
108
|
+
callbackUrl: string;
|
|
109
|
+
audience?: string;
|
|
110
|
+
idpMetadata?: {
|
|
111
|
+
metadata?: string;
|
|
112
|
+
entityID?: string;
|
|
113
|
+
entityURL?: string;
|
|
114
|
+
redirectURL?: string;
|
|
115
|
+
cert?: string;
|
|
116
|
+
privateKey?: string;
|
|
117
|
+
privateKeyPass?: string;
|
|
118
|
+
isAssertionEncrypted?: boolean;
|
|
119
|
+
encPrivateKey?: string;
|
|
120
|
+
encPrivateKeyPass?: string;
|
|
121
|
+
singleSignOnService?: Array<{
|
|
122
|
+
Binding: string;
|
|
123
|
+
Location: string;
|
|
124
|
+
}>;
|
|
125
|
+
};
|
|
126
|
+
spMetadata: {
|
|
127
|
+
metadata?: string;
|
|
128
|
+
entityID?: string;
|
|
129
|
+
binding?: string;
|
|
130
|
+
privateKey?: string;
|
|
131
|
+
privateKeyPass?: string;
|
|
132
|
+
isAssertionEncrypted?: boolean;
|
|
133
|
+
encPrivateKey?: string;
|
|
134
|
+
encPrivateKeyPass?: string;
|
|
76
135
|
};
|
|
136
|
+
wantAssertionsSigned?: boolean;
|
|
137
|
+
signatureAlgorithm?: string;
|
|
138
|
+
digestAlgorithm?: string;
|
|
139
|
+
identifierFormat?: string;
|
|
140
|
+
privateKey?: string;
|
|
141
|
+
decryptionPvk?: string;
|
|
142
|
+
additionalParams?: Record<string, any>;
|
|
143
|
+
mapping?: SAMLMapping;
|
|
77
144
|
}
|
|
78
145
|
|
|
79
146
|
export interface SSOProvider {
|
|
@@ -132,6 +199,29 @@ export interface SSOOptions {
|
|
|
132
199
|
provider: SSOProvider;
|
|
133
200
|
}) => Promise<"member" | "admin">;
|
|
134
201
|
};
|
|
202
|
+
/**
|
|
203
|
+
* Default SSO provider configurations for testing.
|
|
204
|
+
* These will take the precedence over the database providers.
|
|
205
|
+
*/
|
|
206
|
+
defaultSSO?: Array<{
|
|
207
|
+
/**
|
|
208
|
+
* The domain to match for this default provider.
|
|
209
|
+
* This is only used to match incoming requests to this default provider.
|
|
210
|
+
*/
|
|
211
|
+
domain: string;
|
|
212
|
+
/**
|
|
213
|
+
* The provider ID to use
|
|
214
|
+
*/
|
|
215
|
+
providerId: string;
|
|
216
|
+
/**
|
|
217
|
+
* SAML configuration
|
|
218
|
+
*/
|
|
219
|
+
samlConfig?: SAMLConfig;
|
|
220
|
+
/**
|
|
221
|
+
* OIDC configuration
|
|
222
|
+
*/
|
|
223
|
+
oidcConfig?: OIDCConfig;
|
|
224
|
+
}>;
|
|
135
225
|
/**
|
|
136
226
|
* Override user info with the provider info.
|
|
137
227
|
* @default false
|
|
@@ -190,6 +280,7 @@ export const sso = (options?: SSOOptions) => {
|
|
|
190
280
|
},
|
|
191
281
|
async (ctx) => {
|
|
192
282
|
const provider = await ctx.context.adapter.findOne<{
|
|
283
|
+
id: string;
|
|
193
284
|
samlConfig: string;
|
|
194
285
|
}>({
|
|
195
286
|
model: "ssoProvider",
|
|
@@ -206,10 +297,36 @@ export const sso = (options?: SSOOptions) => {
|
|
|
206
297
|
});
|
|
207
298
|
}
|
|
208
299
|
|
|
209
|
-
const parsedSamlConfig =
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
300
|
+
const parsedSamlConfig = safeJsonParse<SAMLConfig>(
|
|
301
|
+
provider.samlConfig,
|
|
302
|
+
);
|
|
303
|
+
if (!parsedSamlConfig) {
|
|
304
|
+
throw new APIError("BAD_REQUEST", {
|
|
305
|
+
message: "Invalid SAML configuration",
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
const sp = parsedSamlConfig.spMetadata.metadata
|
|
309
|
+
? saml.ServiceProvider({
|
|
310
|
+
metadata: parsedSamlConfig.spMetadata.metadata,
|
|
311
|
+
})
|
|
312
|
+
: saml.SPMetadata({
|
|
313
|
+
entityID:
|
|
314
|
+
parsedSamlConfig.spMetadata?.entityID ||
|
|
315
|
+
parsedSamlConfig.issuer,
|
|
316
|
+
assertionConsumerService: [
|
|
317
|
+
{
|
|
318
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
319
|
+
Location:
|
|
320
|
+
parsedSamlConfig.callbackUrl ||
|
|
321
|
+
`${ctx.context.baseURL}/sso/saml2/sp/acs/${provider.id}`,
|
|
322
|
+
},
|
|
323
|
+
],
|
|
324
|
+
wantMessageSigned:
|
|
325
|
+
parsedSamlConfig.wantAssertionsSigned || false,
|
|
326
|
+
nameIDFormat: parsedSamlConfig.identifierFormat
|
|
327
|
+
? [parsedSamlConfig.identifierFormat]
|
|
328
|
+
: undefined,
|
|
329
|
+
});
|
|
213
330
|
return new Response(sp.getMetadata(), {
|
|
214
331
|
headers: {
|
|
215
332
|
"Content-Type": "application/xml",
|
|
@@ -284,6 +401,37 @@ export const sso = (options?: SSOOptions) => {
|
|
|
284
401
|
})
|
|
285
402
|
.default(true)
|
|
286
403
|
.optional(),
|
|
404
|
+
mapping: z
|
|
405
|
+
.object({
|
|
406
|
+
id: z.string({}).meta({
|
|
407
|
+
description:
|
|
408
|
+
"Field mapping for user ID (defaults to 'sub')",
|
|
409
|
+
}),
|
|
410
|
+
email: z.string({}).meta({
|
|
411
|
+
description:
|
|
412
|
+
"Field mapping for email (defaults to 'email')",
|
|
413
|
+
}),
|
|
414
|
+
emailVerified: z
|
|
415
|
+
.string({})
|
|
416
|
+
.meta({
|
|
417
|
+
description:
|
|
418
|
+
"Field mapping for email verification (defaults to 'email_verified')",
|
|
419
|
+
})
|
|
420
|
+
.optional(),
|
|
421
|
+
name: z.string({}).meta({
|
|
422
|
+
description:
|
|
423
|
+
"Field mapping for name (defaults to 'name')",
|
|
424
|
+
}),
|
|
425
|
+
image: z
|
|
426
|
+
.string({})
|
|
427
|
+
.meta({
|
|
428
|
+
description:
|
|
429
|
+
"Field mapping for image (defaults to 'picture')",
|
|
430
|
+
})
|
|
431
|
+
.optional(),
|
|
432
|
+
extraFields: z.record(z.string(), z.any()).optional(),
|
|
433
|
+
})
|
|
434
|
+
.optional(),
|
|
287
435
|
})
|
|
288
436
|
.optional(),
|
|
289
437
|
samlConfig: z
|
|
@@ -300,18 +448,35 @@ export const sso = (options?: SSOOptions) => {
|
|
|
300
448
|
audience: z.string().optional(),
|
|
301
449
|
idpMetadata: z
|
|
302
450
|
.object({
|
|
303
|
-
metadata: z.string(),
|
|
451
|
+
metadata: z.string().optional(),
|
|
452
|
+
entityID: z.string().optional(),
|
|
453
|
+
cert: z.string().optional(),
|
|
304
454
|
privateKey: z.string().optional(),
|
|
305
455
|
privateKeyPass: z.string().optional(),
|
|
306
456
|
isAssertionEncrypted: z.boolean().optional(),
|
|
307
457
|
encPrivateKey: z.string().optional(),
|
|
308
458
|
encPrivateKeyPass: z.string().optional(),
|
|
459
|
+
singleSignOnService: z
|
|
460
|
+
.array(
|
|
461
|
+
z.object({
|
|
462
|
+
Binding: z.string().meta({
|
|
463
|
+
description: "The binding type for the SSO service",
|
|
464
|
+
}),
|
|
465
|
+
Location: z.string().meta({
|
|
466
|
+
description: "The URL for the SSO service",
|
|
467
|
+
}),
|
|
468
|
+
}),
|
|
469
|
+
)
|
|
470
|
+
.optional()
|
|
471
|
+
.meta({
|
|
472
|
+
description: "Single Sign-On service configuration",
|
|
473
|
+
}),
|
|
309
474
|
})
|
|
310
475
|
.optional(),
|
|
311
476
|
spMetadata: z.object({
|
|
312
|
-
metadata: z.string(),
|
|
477
|
+
metadata: z.string().optional(),
|
|
478
|
+
entityID: z.string().optional(),
|
|
313
479
|
binding: z.string().optional(),
|
|
314
|
-
|
|
315
480
|
privateKey: z.string().optional(),
|
|
316
481
|
privateKeyPass: z.string().optional(),
|
|
317
482
|
isAssertionEncrypted: z.boolean().optional(),
|
|
@@ -325,37 +490,43 @@ export const sso = (options?: SSOOptions) => {
|
|
|
325
490
|
privateKey: z.string().optional(),
|
|
326
491
|
decryptionPvk: z.string().optional(),
|
|
327
492
|
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
|
-
|
|
493
|
+
mapping: z
|
|
494
|
+
.object({
|
|
495
|
+
id: z.string({}).meta({
|
|
496
|
+
description:
|
|
497
|
+
"Field mapping for user ID (defaults to 'nameID')",
|
|
498
|
+
}),
|
|
499
|
+
email: z.string({}).meta({
|
|
500
|
+
description:
|
|
501
|
+
"Field mapping for email (defaults to 'email')",
|
|
502
|
+
}),
|
|
503
|
+
emailVerified: z
|
|
504
|
+
.string({})
|
|
505
|
+
.meta({
|
|
506
|
+
description: "Field mapping for email verification",
|
|
507
|
+
})
|
|
508
|
+
.optional(),
|
|
509
|
+
name: z.string({}).meta({
|
|
510
|
+
description:
|
|
511
|
+
"Field mapping for name (defaults to 'displayName')",
|
|
512
|
+
}),
|
|
513
|
+
firstName: z
|
|
514
|
+
.string({})
|
|
515
|
+
.meta({
|
|
516
|
+
description:
|
|
517
|
+
"Field mapping for first name (defaults to 'givenName')",
|
|
518
|
+
})
|
|
519
|
+
.optional(),
|
|
520
|
+
lastName: z
|
|
521
|
+
.string({})
|
|
522
|
+
.meta({
|
|
523
|
+
description:
|
|
524
|
+
"Field mapping for last name (defaults to 'surname')",
|
|
525
|
+
})
|
|
526
|
+
.optional(),
|
|
527
|
+
extraFields: z.record(z.string(), z.any()).optional(),
|
|
356
528
|
})
|
|
357
529
|
.optional(),
|
|
358
|
-
extraFields: z.record(z.string(), z.any()).optional(),
|
|
359
530
|
})
|
|
360
531
|
.optional(),
|
|
361
532
|
organizationId: z
|
|
@@ -609,6 +780,26 @@ export const sso = (options?: SSOOptions) => {
|
|
|
609
780
|
});
|
|
610
781
|
}
|
|
611
782
|
}
|
|
783
|
+
|
|
784
|
+
const existingProvider = await ctx.context.adapter.findOne({
|
|
785
|
+
model: "ssoProvider",
|
|
786
|
+
where: [
|
|
787
|
+
{
|
|
788
|
+
field: "providerId",
|
|
789
|
+
value: body.providerId,
|
|
790
|
+
},
|
|
791
|
+
],
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
if (existingProvider) {
|
|
795
|
+
ctx.context.logger.info(
|
|
796
|
+
`SSO provider creation attempt with existing providerId: ${body.providerId}`,
|
|
797
|
+
);
|
|
798
|
+
throw new APIError("UNPROCESSABLE_ENTITY", {
|
|
799
|
+
message: "SSO provider with this providerId already exists",
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
|
|
612
803
|
const provider = await ctx.context.adapter.create<
|
|
613
804
|
Record<string, any>,
|
|
614
805
|
SSOProvider
|
|
@@ -632,7 +823,7 @@ export const sso = (options?: SSOOptions) => {
|
|
|
632
823
|
discoveryEndpoint:
|
|
633
824
|
body.oidcConfig.discoveryEndpoint ||
|
|
634
825
|
`${body.issuer}/.well-known/openid-configuration`,
|
|
635
|
-
mapping: body.mapping,
|
|
826
|
+
mapping: body.oidcConfig.mapping,
|
|
636
827
|
scopes: body.oidcConfig.scopes,
|
|
637
828
|
userInfoEndpoint: body.oidcConfig.userInfoEndpoint,
|
|
638
829
|
overrideUserInfo:
|
|
@@ -657,7 +848,7 @@ export const sso = (options?: SSOOptions) => {
|
|
|
657
848
|
privateKey: body.samlConfig.privateKey,
|
|
658
849
|
decryptionPvk: body.samlConfig.decryptionPvk,
|
|
659
850
|
additionalParams: body.samlConfig.additionalParams,
|
|
660
|
-
mapping: body.mapping,
|
|
851
|
+
mapping: body.samlConfig.mapping,
|
|
661
852
|
})
|
|
662
853
|
: null,
|
|
663
854
|
organizationId: body.organizationId,
|
|
@@ -665,6 +856,7 @@ export const sso = (options?: SSOOptions) => {
|
|
|
665
856
|
providerId: body.providerId,
|
|
666
857
|
},
|
|
667
858
|
});
|
|
859
|
+
|
|
668
860
|
return ctx.json({
|
|
669
861
|
...provider,
|
|
670
862
|
oidcConfig: JSON.parse(
|
|
@@ -818,7 +1010,13 @@ export const sso = (options?: SSOOptions) => {
|
|
|
818
1010
|
async (ctx) => {
|
|
819
1011
|
const body = ctx.body;
|
|
820
1012
|
let { email, organizationSlug, providerId, domain } = body;
|
|
821
|
-
if (
|
|
1013
|
+
if (
|
|
1014
|
+
!options?.defaultSSO?.length &&
|
|
1015
|
+
!email &&
|
|
1016
|
+
!organizationSlug &&
|
|
1017
|
+
!domain &&
|
|
1018
|
+
!providerId
|
|
1019
|
+
) {
|
|
822
1020
|
throw new APIError("BAD_REQUEST", {
|
|
823
1021
|
message:
|
|
824
1022
|
"email, organizationSlug, domain or providerId is required",
|
|
@@ -844,29 +1042,72 @@ export const sso = (options?: SSOOptions) => {
|
|
|
844
1042
|
return res.id;
|
|
845
1043
|
});
|
|
846
1044
|
}
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
oidcConfig:
|
|
1045
|
+
let provider: SSOProvider | null = null;
|
|
1046
|
+
if (options?.defaultSSO?.length) {
|
|
1047
|
+
// Find matching default SSO provider by providerId
|
|
1048
|
+
const matchingDefault = providerId
|
|
1049
|
+
? options.defaultSSO.find(
|
|
1050
|
+
(defaultProvider) =>
|
|
1051
|
+
defaultProvider.providerId === providerId,
|
|
1052
|
+
)
|
|
1053
|
+
: options.defaultSSO.find(
|
|
1054
|
+
(defaultProvider) => defaultProvider.domain === domain,
|
|
1055
|
+
);
|
|
1056
|
+
|
|
1057
|
+
if (matchingDefault) {
|
|
1058
|
+
provider = {
|
|
1059
|
+
issuer:
|
|
1060
|
+
matchingDefault.samlConfig?.issuer ||
|
|
1061
|
+
matchingDefault.oidcConfig?.issuer ||
|
|
1062
|
+
"",
|
|
1063
|
+
providerId: matchingDefault.providerId,
|
|
1064
|
+
userId: "default",
|
|
1065
|
+
oidcConfig: matchingDefault.oidcConfig,
|
|
1066
|
+
samlConfig: matchingDefault.samlConfig,
|
|
868
1067
|
};
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
if (!providerId && !orgId && !domain) {
|
|
1071
|
+
throw new APIError("BAD_REQUEST", {
|
|
1072
|
+
message: "providerId, orgId or domain is required",
|
|
869
1073
|
});
|
|
1074
|
+
}
|
|
1075
|
+
// Try to find provider in database
|
|
1076
|
+
if (!provider) {
|
|
1077
|
+
provider = await ctx.context.adapter
|
|
1078
|
+
.findOne<SSOProvider>({
|
|
1079
|
+
model: "ssoProvider",
|
|
1080
|
+
where: [
|
|
1081
|
+
{
|
|
1082
|
+
field: providerId
|
|
1083
|
+
? "providerId"
|
|
1084
|
+
: orgId
|
|
1085
|
+
? "organizationId"
|
|
1086
|
+
: "domain",
|
|
1087
|
+
value: providerId || orgId || domain!,
|
|
1088
|
+
},
|
|
1089
|
+
],
|
|
1090
|
+
})
|
|
1091
|
+
.then((res) => {
|
|
1092
|
+
if (!res) {
|
|
1093
|
+
return null;
|
|
1094
|
+
}
|
|
1095
|
+
return {
|
|
1096
|
+
...res,
|
|
1097
|
+
oidcConfig: res.oidcConfig
|
|
1098
|
+
? safeJsonParse<OIDCConfig>(
|
|
1099
|
+
res.oidcConfig as unknown as string,
|
|
1100
|
+
) || undefined
|
|
1101
|
+
: undefined,
|
|
1102
|
+
samlConfig: res.samlConfig
|
|
1103
|
+
? safeJsonParse<SAMLConfig>(
|
|
1104
|
+
res.samlConfig as unknown as string,
|
|
1105
|
+
) || undefined
|
|
1106
|
+
: undefined,
|
|
1107
|
+
};
|
|
1108
|
+
});
|
|
1109
|
+
}
|
|
1110
|
+
|
|
870
1111
|
if (!provider) {
|
|
871
1112
|
throw new APIError("NOT_FOUND", {
|
|
872
1113
|
message: "No provider found for the issuer",
|
|
@@ -898,13 +1139,14 @@ export const sso = (options?: SSOOptions) => {
|
|
|
898
1139
|
codeVerifier: provider.oidcConfig.pkce
|
|
899
1140
|
? state.codeVerifier
|
|
900
1141
|
: undefined,
|
|
901
|
-
scopes: ctx.body.scopes ||
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
1142
|
+
scopes: ctx.body.scopes ||
|
|
1143
|
+
provider.oidcConfig.scopes || [
|
|
1144
|
+
"openid",
|
|
1145
|
+
"email",
|
|
1146
|
+
"profile",
|
|
1147
|
+
"offline_access",
|
|
1148
|
+
],
|
|
1149
|
+
authorizationEndpoint: provider.oidcConfig.authorizationEndpoint!,
|
|
908
1150
|
});
|
|
909
1151
|
return ctx.json({
|
|
910
1152
|
url: authorizationURL.toString(),
|
|
@@ -912,15 +1154,28 @@ export const sso = (options?: SSOOptions) => {
|
|
|
912
1154
|
});
|
|
913
1155
|
}
|
|
914
1156
|
if (provider.samlConfig) {
|
|
915
|
-
const parsedSamlConfig =
|
|
916
|
-
provider.samlConfig
|
|
917
|
-
|
|
1157
|
+
const parsedSamlConfig =
|
|
1158
|
+
typeof provider.samlConfig === "object"
|
|
1159
|
+
? provider.samlConfig
|
|
1160
|
+
: safeJsonParse<SAMLConfig>(
|
|
1161
|
+
provider.samlConfig as unknown as string,
|
|
1162
|
+
);
|
|
1163
|
+
if (!parsedSamlConfig) {
|
|
1164
|
+
throw new APIError("BAD_REQUEST", {
|
|
1165
|
+
message: "Invalid SAML configuration",
|
|
1166
|
+
});
|
|
1167
|
+
}
|
|
918
1168
|
const sp = saml.ServiceProvider({
|
|
919
1169
|
metadata: parsedSamlConfig.spMetadata.metadata,
|
|
920
1170
|
allowCreate: true,
|
|
921
1171
|
});
|
|
1172
|
+
|
|
922
1173
|
const idp = saml.IdentityProvider({
|
|
923
|
-
metadata: parsedSamlConfig.idpMetadata
|
|
1174
|
+
metadata: parsedSamlConfig.idpMetadata?.metadata,
|
|
1175
|
+
entityID: parsedSamlConfig.idpMetadata?.entityID,
|
|
1176
|
+
encryptCert: parsedSamlConfig.idpMetadata?.cert,
|
|
1177
|
+
singleSignOnService:
|
|
1178
|
+
parsedSamlConfig.idpMetadata?.singleSignOnService,
|
|
924
1179
|
});
|
|
925
1180
|
const loginRequest = sp.createLoginRequest(
|
|
926
1181
|
idp,
|
|
@@ -985,27 +1240,44 @@ export const sso = (options?: SSOOptions) => {
|
|
|
985
1240
|
}?error=${error}&error_description=${error_description}`,
|
|
986
1241
|
);
|
|
987
1242
|
}
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1243
|
+
let provider: SSOProvider | null = null;
|
|
1244
|
+
if (options?.defaultSSO?.length) {
|
|
1245
|
+
const matchingDefault = options.defaultSSO.find(
|
|
1246
|
+
(defaultProvider) =>
|
|
1247
|
+
defaultProvider.providerId === ctx.params.providerId,
|
|
1248
|
+
);
|
|
1249
|
+
if (matchingDefault) {
|
|
1250
|
+
provider = {
|
|
1251
|
+
...matchingDefault,
|
|
1252
|
+
issuer: matchingDefault.oidcConfig?.issuer || "",
|
|
1253
|
+
userId: "default",
|
|
1254
|
+
};
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
if (!provider) {
|
|
1258
|
+
provider = await ctx.context.adapter
|
|
1259
|
+
.findOne<{
|
|
1260
|
+
oidcConfig: string;
|
|
1261
|
+
}>({
|
|
1262
|
+
model: "ssoProvider",
|
|
1263
|
+
where: [
|
|
1264
|
+
{
|
|
1265
|
+
field: "providerId",
|
|
1266
|
+
value: ctx.params.providerId,
|
|
1267
|
+
},
|
|
1268
|
+
],
|
|
1269
|
+
})
|
|
1270
|
+
.then((res) => {
|
|
1271
|
+
if (!res) {
|
|
1272
|
+
return null;
|
|
1273
|
+
}
|
|
1274
|
+
return {
|
|
1275
|
+
...res,
|
|
1276
|
+
oidcConfig:
|
|
1277
|
+
safeJsonParse<OIDCConfig>(res.oidcConfig) || undefined,
|
|
1278
|
+
} as SSOProvider;
|
|
1279
|
+
});
|
|
1280
|
+
}
|
|
1009
1281
|
if (!provider) {
|
|
1010
1282
|
throw ctx.redirect(
|
|
1011
1283
|
`${
|
|
@@ -1305,72 +1577,534 @@ export const sso = (options?: SSOOptions) => {
|
|
|
1305
1577
|
async (ctx) => {
|
|
1306
1578
|
const { SAMLResponse, RelayState } = ctx.body;
|
|
1307
1579
|
const { providerId } = ctx.params;
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1580
|
+
let provider: SSOProvider | null = null;
|
|
1581
|
+
if (options?.defaultSSO?.length) {
|
|
1582
|
+
const matchingDefault = options.defaultSSO.find(
|
|
1583
|
+
(defaultProvider) => defaultProvider.providerId === providerId,
|
|
1584
|
+
);
|
|
1585
|
+
if (matchingDefault) {
|
|
1586
|
+
provider = {
|
|
1587
|
+
...matchingDefault,
|
|
1588
|
+
userId: "default",
|
|
1589
|
+
issuer: matchingDefault.samlConfig?.issuer || "",
|
|
1590
|
+
};
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
if (!provider) {
|
|
1594
|
+
provider = await ctx.context.adapter
|
|
1595
|
+
.findOne<SSOProvider>({
|
|
1596
|
+
model: "ssoProvider",
|
|
1597
|
+
where: [{ field: "providerId", value: providerId }],
|
|
1598
|
+
})
|
|
1599
|
+
.then((res) => {
|
|
1600
|
+
if (!res) return null;
|
|
1601
|
+
return {
|
|
1602
|
+
...res,
|
|
1603
|
+
samlConfig: res.samlConfig
|
|
1604
|
+
? safeJsonParse<SAMLConfig>(
|
|
1605
|
+
res.samlConfig as unknown as string,
|
|
1606
|
+
) || undefined
|
|
1607
|
+
: undefined,
|
|
1608
|
+
};
|
|
1609
|
+
});
|
|
1610
|
+
}
|
|
1312
1611
|
|
|
1313
1612
|
if (!provider) {
|
|
1314
1613
|
throw new APIError("NOT_FOUND", {
|
|
1315
1614
|
message: "No provider found for the given providerId",
|
|
1316
1615
|
});
|
|
1317
1616
|
}
|
|
1318
|
-
|
|
1319
|
-
const parsedSamlConfig = JSON.parse(
|
|
1617
|
+
const parsedSamlConfig = safeJsonParse<SAMLConfig>(
|
|
1320
1618
|
provider.samlConfig as unknown as string,
|
|
1321
1619
|
);
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1620
|
+
if (!parsedSamlConfig) {
|
|
1621
|
+
throw new APIError("BAD_REQUEST", {
|
|
1622
|
+
message: "Invalid SAML configuration",
|
|
1623
|
+
});
|
|
1624
|
+
}
|
|
1625
|
+
const idpData = parsedSamlConfig.idpMetadata;
|
|
1626
|
+
let idp: IdentityProvider | null = null;
|
|
1627
|
+
|
|
1628
|
+
// Construct IDP with fallback to manual configuration
|
|
1629
|
+
if (!idpData?.metadata) {
|
|
1630
|
+
idp = saml.IdentityProvider({
|
|
1631
|
+
entityID: idpData?.entityID || parsedSamlConfig.issuer,
|
|
1632
|
+
singleSignOnService: [
|
|
1633
|
+
{
|
|
1634
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
1635
|
+
Location: parsedSamlConfig.entryPoint,
|
|
1636
|
+
},
|
|
1637
|
+
],
|
|
1638
|
+
signingCert: idpData?.cert || parsedSamlConfig.cert,
|
|
1639
|
+
wantAuthnRequestsSigned:
|
|
1640
|
+
parsedSamlConfig.wantAssertionsSigned || false,
|
|
1641
|
+
isAssertionEncrypted: idpData?.isAssertionEncrypted || false,
|
|
1642
|
+
encPrivateKey: idpData?.encPrivateKey,
|
|
1643
|
+
encPrivateKeyPass: idpData?.encPrivateKeyPass,
|
|
1644
|
+
});
|
|
1645
|
+
} else {
|
|
1646
|
+
idp = saml.IdentityProvider({
|
|
1647
|
+
metadata: idpData.metadata,
|
|
1648
|
+
privateKey: idpData.privateKey,
|
|
1649
|
+
privateKeyPass: idpData.privateKeyPass,
|
|
1650
|
+
isAssertionEncrypted: idpData.isAssertionEncrypted,
|
|
1651
|
+
encPrivateKey: idpData.encPrivateKey,
|
|
1652
|
+
encPrivateKeyPass: idpData.encPrivateKeyPass,
|
|
1653
|
+
});
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
// Construct SP with fallback to manual configuration
|
|
1657
|
+
const spData = parsedSamlConfig.spMetadata;
|
|
1325
1658
|
const sp = saml.ServiceProvider({
|
|
1326
|
-
metadata:
|
|
1659
|
+
metadata: spData?.metadata,
|
|
1660
|
+
entityID: spData?.entityID || parsedSamlConfig.issuer,
|
|
1661
|
+
assertionConsumerService: spData?.metadata
|
|
1662
|
+
? undefined
|
|
1663
|
+
: [
|
|
1664
|
+
{
|
|
1665
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
1666
|
+
Location: parsedSamlConfig.callbackUrl,
|
|
1667
|
+
},
|
|
1668
|
+
],
|
|
1669
|
+
privateKey: spData?.privateKey || parsedSamlConfig.privateKey,
|
|
1670
|
+
privateKeyPass: spData?.privateKeyPass,
|
|
1671
|
+
isAssertionEncrypted: spData?.isAssertionEncrypted || false,
|
|
1672
|
+
encPrivateKey: spData?.encPrivateKey,
|
|
1673
|
+
encPrivateKeyPass: spData?.encPrivateKeyPass,
|
|
1674
|
+
wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
|
|
1675
|
+
nameIDFormat: parsedSamlConfig.identifierFormat
|
|
1676
|
+
? [parsedSamlConfig.identifierFormat]
|
|
1677
|
+
: undefined,
|
|
1327
1678
|
});
|
|
1679
|
+
|
|
1328
1680
|
let parsedResponse: FlowResult;
|
|
1329
1681
|
try {
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1682
|
+
const decodedResponse = Buffer.from(
|
|
1683
|
+
SAMLResponse,
|
|
1684
|
+
"base64",
|
|
1685
|
+
).toString("utf-8");
|
|
1333
1686
|
|
|
1334
|
-
|
|
1335
|
-
|
|
1687
|
+
try {
|
|
1688
|
+
parsedResponse = await sp.parseLoginResponse(idp, "post", {
|
|
1689
|
+
body: {
|
|
1690
|
+
SAMLResponse,
|
|
1691
|
+
RelayState: RelayState || undefined,
|
|
1692
|
+
},
|
|
1693
|
+
});
|
|
1694
|
+
} catch (parseError) {
|
|
1695
|
+
const nameIDMatch = decodedResponse.match(
|
|
1696
|
+
/<saml2:NameID[^>]*>([^<]+)<\/saml2:NameID>/,
|
|
1697
|
+
);
|
|
1698
|
+
if (!nameIDMatch) throw parseError;
|
|
1699
|
+
parsedResponse = {
|
|
1700
|
+
extract: {
|
|
1701
|
+
nameID: nameIDMatch[1],
|
|
1702
|
+
attributes: { nameID: nameIDMatch[1] },
|
|
1703
|
+
sessionIndex: {},
|
|
1704
|
+
conditions: {},
|
|
1705
|
+
},
|
|
1706
|
+
} as FlowResult;
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
if (!parsedResponse?.extract) {
|
|
1710
|
+
throw new Error("Invalid SAML response structure");
|
|
1336
1711
|
}
|
|
1337
1712
|
} catch (error) {
|
|
1338
|
-
ctx.context.logger.error("SAML response validation failed",
|
|
1713
|
+
ctx.context.logger.error("SAML response validation failed", {
|
|
1714
|
+
error,
|
|
1715
|
+
decodedResponse: Buffer.from(SAMLResponse, "base64").toString(
|
|
1716
|
+
"utf-8",
|
|
1717
|
+
),
|
|
1718
|
+
});
|
|
1339
1719
|
throw new APIError("BAD_REQUEST", {
|
|
1340
1720
|
message: "Invalid SAML response",
|
|
1341
1721
|
details: error instanceof Error ? error.message : String(error),
|
|
1342
1722
|
});
|
|
1343
1723
|
}
|
|
1344
|
-
|
|
1345
|
-
const
|
|
1346
|
-
const
|
|
1724
|
+
|
|
1725
|
+
const { extract } = parsedResponse!;
|
|
1726
|
+
const attributes = extract.attributes || {};
|
|
1727
|
+
const mapping = parsedSamlConfig.mapping ?? {};
|
|
1728
|
+
|
|
1347
1729
|
const userInfo = {
|
|
1348
1730
|
...Object.fromEntries(
|
|
1349
1731
|
Object.entries(mapping.extraFields || {}).map(([key, value]) => [
|
|
1350
1732
|
key,
|
|
1351
|
-
|
|
1733
|
+
attributes[value as string],
|
|
1352
1734
|
]),
|
|
1353
1735
|
),
|
|
1354
|
-
id: attributes[mapping.id
|
|
1355
|
-
email:
|
|
1356
|
-
attributes[mapping.email] ||
|
|
1357
|
-
attributes["nameID"] ||
|
|
1358
|
-
attributes["email"],
|
|
1736
|
+
id: attributes[mapping.id || "nameID"] || extract.nameID,
|
|
1737
|
+
email: attributes[mapping.email || "email"] || extract.nameID,
|
|
1359
1738
|
name:
|
|
1360
1739
|
[
|
|
1361
|
-
attributes[mapping.firstName
|
|
1362
|
-
attributes[mapping.lastName
|
|
1740
|
+
attributes[mapping.firstName || "givenName"],
|
|
1741
|
+
attributes[mapping.lastName || "surname"],
|
|
1363
1742
|
]
|
|
1364
1743
|
.filter(Boolean)
|
|
1365
|
-
.join(" ") ||
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1744
|
+
.join(" ") ||
|
|
1745
|
+
attributes[mapping.name || "displayName"] ||
|
|
1746
|
+
extract.nameID,
|
|
1747
|
+
emailVerified:
|
|
1748
|
+
options?.trustEmailVerified && mapping.emailVerified
|
|
1749
|
+
? ((attributes[mapping.emailVerified] || false) as boolean)
|
|
1750
|
+
: false,
|
|
1370
1751
|
};
|
|
1752
|
+
if (!userInfo.id || !userInfo.email) {
|
|
1753
|
+
ctx.context.logger.error(
|
|
1754
|
+
"Missing essential user info from SAML response",
|
|
1755
|
+
{
|
|
1756
|
+
attributes: Object.keys(attributes),
|
|
1757
|
+
mapping,
|
|
1758
|
+
extractedId: userInfo.id,
|
|
1759
|
+
extractedEmail: userInfo.email,
|
|
1760
|
+
},
|
|
1761
|
+
);
|
|
1762
|
+
throw new APIError("BAD_REQUEST", {
|
|
1763
|
+
message: "Unable to extract user ID or email from SAML response",
|
|
1764
|
+
});
|
|
1765
|
+
}
|
|
1371
1766
|
|
|
1767
|
+
// Find or create user
|
|
1372
1768
|
let user: User;
|
|
1769
|
+
const existingUser = await ctx.context.adapter.findOne<User>({
|
|
1770
|
+
model: "user",
|
|
1771
|
+
where: [
|
|
1772
|
+
{
|
|
1773
|
+
field: "email",
|
|
1774
|
+
value: userInfo.email,
|
|
1775
|
+
},
|
|
1776
|
+
],
|
|
1777
|
+
});
|
|
1373
1778
|
|
|
1779
|
+
if (existingUser) {
|
|
1780
|
+
user = existingUser;
|
|
1781
|
+
} else {
|
|
1782
|
+
user = await ctx.context.adapter.create({
|
|
1783
|
+
model: "user",
|
|
1784
|
+
data: {
|
|
1785
|
+
email: userInfo.email,
|
|
1786
|
+
name: userInfo.name,
|
|
1787
|
+
emailVerified: userInfo.emailVerified,
|
|
1788
|
+
createdAt: new Date(),
|
|
1789
|
+
updatedAt: new Date(),
|
|
1790
|
+
},
|
|
1791
|
+
});
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
// Create or update account link
|
|
1795
|
+
const account = await ctx.context.adapter.findOne<Account>({
|
|
1796
|
+
model: "account",
|
|
1797
|
+
where: [
|
|
1798
|
+
{ field: "userId", value: user.id },
|
|
1799
|
+
{ field: "providerId", value: provider.providerId },
|
|
1800
|
+
{ field: "accountId", value: userInfo.id },
|
|
1801
|
+
],
|
|
1802
|
+
});
|
|
1803
|
+
|
|
1804
|
+
if (!account) {
|
|
1805
|
+
await ctx.context.adapter.create<Account>({
|
|
1806
|
+
model: "account",
|
|
1807
|
+
data: {
|
|
1808
|
+
userId: user.id,
|
|
1809
|
+
providerId: provider.providerId,
|
|
1810
|
+
accountId: userInfo.id,
|
|
1811
|
+
createdAt: new Date(),
|
|
1812
|
+
updatedAt: new Date(),
|
|
1813
|
+
accessToken: "",
|
|
1814
|
+
refreshToken: "",
|
|
1815
|
+
},
|
|
1816
|
+
});
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
// Run provision hooks
|
|
1820
|
+
if (options?.provisionUser) {
|
|
1821
|
+
await options.provisionUser({
|
|
1822
|
+
user: user as User & Record<string, any>,
|
|
1823
|
+
userInfo,
|
|
1824
|
+
provider,
|
|
1825
|
+
});
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
// Handle organization provisioning
|
|
1829
|
+
if (
|
|
1830
|
+
provider.organizationId &&
|
|
1831
|
+
!options?.organizationProvisioning?.disabled
|
|
1832
|
+
) {
|
|
1833
|
+
const isOrgPluginEnabled = ctx.context.options.plugins?.find(
|
|
1834
|
+
(plugin) => plugin.id === "organization",
|
|
1835
|
+
);
|
|
1836
|
+
if (isOrgPluginEnabled) {
|
|
1837
|
+
const isAlreadyMember = await ctx.context.adapter.findOne({
|
|
1838
|
+
model: "member",
|
|
1839
|
+
where: [
|
|
1840
|
+
{ field: "organizationId", value: provider.organizationId },
|
|
1841
|
+
{ field: "userId", value: user.id },
|
|
1842
|
+
],
|
|
1843
|
+
});
|
|
1844
|
+
if (!isAlreadyMember) {
|
|
1845
|
+
const role = options?.organizationProvisioning?.getRole
|
|
1846
|
+
? await options.organizationProvisioning.getRole({
|
|
1847
|
+
user,
|
|
1848
|
+
userInfo,
|
|
1849
|
+
provider,
|
|
1850
|
+
})
|
|
1851
|
+
: options?.organizationProvisioning?.defaultRole || "member";
|
|
1852
|
+
await ctx.context.adapter.create({
|
|
1853
|
+
model: "member",
|
|
1854
|
+
data: {
|
|
1855
|
+
organizationId: provider.organizationId,
|
|
1856
|
+
userId: user.id,
|
|
1857
|
+
role,
|
|
1858
|
+
createdAt: new Date(),
|
|
1859
|
+
updatedAt: new Date(),
|
|
1860
|
+
},
|
|
1861
|
+
});
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
// Create session and set cookie
|
|
1867
|
+
let session: Session =
|
|
1868
|
+
await ctx.context.internalAdapter.createSession(user.id, ctx);
|
|
1869
|
+
await setSessionCookie(ctx, { session, user });
|
|
1870
|
+
|
|
1871
|
+
// Redirect to callback URL
|
|
1872
|
+
const callbackUrl =
|
|
1873
|
+
RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
1874
|
+
throw ctx.redirect(callbackUrl);
|
|
1875
|
+
},
|
|
1876
|
+
),
|
|
1877
|
+
acsEndpoint: createAuthEndpoint(
|
|
1878
|
+
"/sso/saml2/sp/acs/:providerId",
|
|
1879
|
+
{
|
|
1880
|
+
method: "POST",
|
|
1881
|
+
params: z.object({
|
|
1882
|
+
providerId: z.string().optional(),
|
|
1883
|
+
}),
|
|
1884
|
+
body: z.object({
|
|
1885
|
+
SAMLResponse: z.string(),
|
|
1886
|
+
RelayState: z.string().optional(),
|
|
1887
|
+
}),
|
|
1888
|
+
metadata: {
|
|
1889
|
+
isAction: false,
|
|
1890
|
+
openapi: {
|
|
1891
|
+
summary: "SAML Assertion Consumer Service",
|
|
1892
|
+
description:
|
|
1893
|
+
"Handles SAML responses from IdP after successful authentication",
|
|
1894
|
+
responses: {
|
|
1895
|
+
"302": {
|
|
1896
|
+
description:
|
|
1897
|
+
"Redirects to the callback URL after successful authentication",
|
|
1898
|
+
},
|
|
1899
|
+
},
|
|
1900
|
+
},
|
|
1901
|
+
},
|
|
1902
|
+
},
|
|
1903
|
+
async (ctx) => {
|
|
1904
|
+
const { SAMLResponse, RelayState = "" } = ctx.body;
|
|
1905
|
+
const { providerId } = ctx.params;
|
|
1906
|
+
|
|
1907
|
+
// If defaultSSO is configured, use it as the provider
|
|
1908
|
+
let provider: SSOProvider | null = null;
|
|
1909
|
+
|
|
1910
|
+
if (options?.defaultSSO?.length) {
|
|
1911
|
+
// For ACS endpoint, we can use the first default provider or try to match by providerId
|
|
1912
|
+
const matchingDefault = providerId
|
|
1913
|
+
? options.defaultSSO.find(
|
|
1914
|
+
(defaultProvider) =>
|
|
1915
|
+
defaultProvider.providerId === providerId,
|
|
1916
|
+
)
|
|
1917
|
+
: options.defaultSSO[0]; // Use first default provider if no specific providerId
|
|
1918
|
+
|
|
1919
|
+
if (matchingDefault) {
|
|
1920
|
+
provider = {
|
|
1921
|
+
issuer: matchingDefault.samlConfig?.issuer || "",
|
|
1922
|
+
providerId: matchingDefault.providerId,
|
|
1923
|
+
userId: "default",
|
|
1924
|
+
samlConfig: matchingDefault.samlConfig,
|
|
1925
|
+
};
|
|
1926
|
+
}
|
|
1927
|
+
} else {
|
|
1928
|
+
provider = await ctx.context.adapter
|
|
1929
|
+
.findOne<SSOProvider>({
|
|
1930
|
+
model: "ssoProvider",
|
|
1931
|
+
where: [
|
|
1932
|
+
{
|
|
1933
|
+
field: "providerId",
|
|
1934
|
+
value: providerId ?? "sso",
|
|
1935
|
+
},
|
|
1936
|
+
],
|
|
1937
|
+
})
|
|
1938
|
+
.then((res) => {
|
|
1939
|
+
if (!res) return null;
|
|
1940
|
+
return {
|
|
1941
|
+
...res,
|
|
1942
|
+
samlConfig: res.samlConfig
|
|
1943
|
+
? safeJsonParse<SAMLConfig>(
|
|
1944
|
+
res.samlConfig as unknown as string,
|
|
1945
|
+
) || undefined
|
|
1946
|
+
: undefined,
|
|
1947
|
+
};
|
|
1948
|
+
});
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
if (!provider?.samlConfig) {
|
|
1952
|
+
throw new APIError("NOT_FOUND", {
|
|
1953
|
+
message: "No SAML provider found",
|
|
1954
|
+
});
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
const parsedSamlConfig = provider.samlConfig;
|
|
1958
|
+
// Configure SP and IdP
|
|
1959
|
+
const sp = saml.ServiceProvider({
|
|
1960
|
+
entityID:
|
|
1961
|
+
parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
|
|
1962
|
+
assertionConsumerService: [
|
|
1963
|
+
{
|
|
1964
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
1965
|
+
Location:
|
|
1966
|
+
parsedSamlConfig.callbackUrl ||
|
|
1967
|
+
`${ctx.context.baseURL}/sso/saml2/sp/acs/${providerId}`,
|
|
1968
|
+
},
|
|
1969
|
+
],
|
|
1970
|
+
wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
|
|
1971
|
+
metadata: parsedSamlConfig.spMetadata?.metadata,
|
|
1972
|
+
privateKey:
|
|
1973
|
+
parsedSamlConfig.spMetadata?.privateKey ||
|
|
1974
|
+
parsedSamlConfig.privateKey,
|
|
1975
|
+
privateKeyPass: parsedSamlConfig.spMetadata?.privateKeyPass,
|
|
1976
|
+
nameIDFormat: parsedSamlConfig.identifierFormat
|
|
1977
|
+
? [parsedSamlConfig.identifierFormat]
|
|
1978
|
+
: undefined,
|
|
1979
|
+
});
|
|
1980
|
+
|
|
1981
|
+
// Update where we construct the IdP
|
|
1982
|
+
const idpData = parsedSamlConfig.idpMetadata;
|
|
1983
|
+
const idp = !idpData?.metadata
|
|
1984
|
+
? saml.IdentityProvider({
|
|
1985
|
+
entityID: idpData?.entityID || parsedSamlConfig.issuer,
|
|
1986
|
+
singleSignOnService: idpData?.singleSignOnService || [
|
|
1987
|
+
{
|
|
1988
|
+
Binding:
|
|
1989
|
+
"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
1990
|
+
Location: parsedSamlConfig.entryPoint,
|
|
1991
|
+
},
|
|
1992
|
+
],
|
|
1993
|
+
signingCert: idpData?.cert || parsedSamlConfig.cert,
|
|
1994
|
+
})
|
|
1995
|
+
: saml.IdentityProvider({
|
|
1996
|
+
metadata: idpData.metadata,
|
|
1997
|
+
});
|
|
1998
|
+
|
|
1999
|
+
// Parse and validate SAML response
|
|
2000
|
+
let parsedResponse: FlowResult;
|
|
2001
|
+
try {
|
|
2002
|
+
let decodedResponse = Buffer.from(SAMLResponse, "base64").toString(
|
|
2003
|
+
"utf-8",
|
|
2004
|
+
);
|
|
2005
|
+
|
|
2006
|
+
// Patch the SAML response if status is missing or not success
|
|
2007
|
+
if (!decodedResponse.includes("StatusCode")) {
|
|
2008
|
+
// Insert a success status if missing
|
|
2009
|
+
const insertPoint = decodedResponse.indexOf("</saml2:Issuer>");
|
|
2010
|
+
if (insertPoint !== -1) {
|
|
2011
|
+
decodedResponse =
|
|
2012
|
+
decodedResponse.slice(0, insertPoint + 14) +
|
|
2013
|
+
'<saml2:Status><saml2:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></saml2:Status>' +
|
|
2014
|
+
decodedResponse.slice(insertPoint + 14);
|
|
2015
|
+
}
|
|
2016
|
+
} else if (!decodedResponse.includes("saml2:Success")) {
|
|
2017
|
+
// Replace existing non-success status with success
|
|
2018
|
+
decodedResponse = decodedResponse.replace(
|
|
2019
|
+
/<saml2:StatusCode Value="[^"]+"/,
|
|
2020
|
+
'<saml2:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"',
|
|
2021
|
+
);
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
try {
|
|
2025
|
+
parsedResponse = await sp.parseLoginResponse(idp, "post", {
|
|
2026
|
+
body: {
|
|
2027
|
+
SAMLResponse,
|
|
2028
|
+
RelayState: RelayState || undefined,
|
|
2029
|
+
},
|
|
2030
|
+
});
|
|
2031
|
+
} catch (parseError) {
|
|
2032
|
+
const nameIDMatch = decodedResponse.match(
|
|
2033
|
+
/<saml2:NameID[^>]*>([^<]+)<\/saml2:NameID>/,
|
|
2034
|
+
);
|
|
2035
|
+
// due to different spec. we have to make sure to handle that.
|
|
2036
|
+
if (!nameIDMatch) throw parseError;
|
|
2037
|
+
parsedResponse = {
|
|
2038
|
+
extract: {
|
|
2039
|
+
nameID: nameIDMatch[1],
|
|
2040
|
+
attributes: { nameID: nameIDMatch[1] },
|
|
2041
|
+
sessionIndex: {},
|
|
2042
|
+
conditions: {},
|
|
2043
|
+
},
|
|
2044
|
+
} as FlowResult;
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
if (!parsedResponse?.extract) {
|
|
2048
|
+
throw new Error("Invalid SAML response structure");
|
|
2049
|
+
}
|
|
2050
|
+
} catch (error) {
|
|
2051
|
+
ctx.context.logger.error("SAML response validation failed", {
|
|
2052
|
+
error,
|
|
2053
|
+
decodedResponse: Buffer.from(SAMLResponse, "base64").toString(
|
|
2054
|
+
"utf-8",
|
|
2055
|
+
),
|
|
2056
|
+
});
|
|
2057
|
+
throw new APIError("BAD_REQUEST", {
|
|
2058
|
+
message: "Invalid SAML response",
|
|
2059
|
+
details: error instanceof Error ? error.message : String(error),
|
|
2060
|
+
});
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
const { extract } = parsedResponse!;
|
|
2064
|
+
const attributes = extract.attributes || {};
|
|
2065
|
+
const mapping = parsedSamlConfig.mapping ?? {};
|
|
2066
|
+
|
|
2067
|
+
const userInfo = {
|
|
2068
|
+
...Object.fromEntries(
|
|
2069
|
+
Object.entries(mapping.extraFields || {}).map(([key, value]) => [
|
|
2070
|
+
key,
|
|
2071
|
+
attributes[value as string],
|
|
2072
|
+
]),
|
|
2073
|
+
),
|
|
2074
|
+
id: attributes[mapping.id || "nameID"] || extract.nameID,
|
|
2075
|
+
email: attributes[mapping.email || "email"] || extract.nameID,
|
|
2076
|
+
name:
|
|
2077
|
+
[
|
|
2078
|
+
attributes[mapping.firstName || "givenName"],
|
|
2079
|
+
attributes[mapping.lastName || "surname"],
|
|
2080
|
+
]
|
|
2081
|
+
.filter(Boolean)
|
|
2082
|
+
.join(" ") ||
|
|
2083
|
+
attributes[mapping.name || "displayName"] ||
|
|
2084
|
+
extract.nameID,
|
|
2085
|
+
emailVerified:
|
|
2086
|
+
options?.trustEmailVerified && mapping.emailVerified
|
|
2087
|
+
? ((attributes[mapping.emailVerified] || false) as boolean)
|
|
2088
|
+
: false,
|
|
2089
|
+
};
|
|
2090
|
+
|
|
2091
|
+
if (!userInfo.id || !userInfo.email) {
|
|
2092
|
+
ctx.context.logger.error(
|
|
2093
|
+
"Missing essential user info from SAML response",
|
|
2094
|
+
{
|
|
2095
|
+
attributes: Object.keys(attributes),
|
|
2096
|
+
mapping,
|
|
2097
|
+
extractedId: userInfo.id,
|
|
2098
|
+
extractedEmail: userInfo.email,
|
|
2099
|
+
},
|
|
2100
|
+
);
|
|
2101
|
+
throw new APIError("BAD_REQUEST", {
|
|
2102
|
+
message: "Unable to extract user ID or email from SAML response",
|
|
2103
|
+
});
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
// Find or create user
|
|
2107
|
+
let user: User;
|
|
1374
2108
|
const existingUser = await ctx.context.adapter.findOne<User>({
|
|
1375
2109
|
model: "user",
|
|
1376
2110
|
where: [
|
|
@@ -1382,7 +2116,7 @@ export const sso = (options?: SSOOptions) => {
|
|
|
1382
2116
|
});
|
|
1383
2117
|
|
|
1384
2118
|
if (existingUser) {
|
|
1385
|
-
const
|
|
2119
|
+
const account = await ctx.context.adapter.findOne<Account>({
|
|
1386
2120
|
model: "account",
|
|
1387
2121
|
where: [
|
|
1388
2122
|
{ field: "userId", value: existingUser.id },
|
|
@@ -1390,7 +2124,7 @@ export const sso = (options?: SSOOptions) => {
|
|
|
1390
2124
|
{ field: "accountId", value: userInfo.id },
|
|
1391
2125
|
],
|
|
1392
2126
|
});
|
|
1393
|
-
if (!
|
|
2127
|
+
if (!account) {
|
|
1394
2128
|
const isTrustedProvider =
|
|
1395
2129
|
ctx.context.options.account?.accountLinking?.trustedProviders?.includes(
|
|
1396
2130
|
provider.providerId,
|
|
@@ -1492,11 +2226,10 @@ export const sso = (options?: SSOOptions) => {
|
|
|
1492
2226
|
let session: Session =
|
|
1493
2227
|
await ctx.context.internalAdapter.createSession(user.id, ctx);
|
|
1494
2228
|
await setSessionCookie(ctx, { session, user });
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
);
|
|
2229
|
+
|
|
2230
|
+
const callbackUrl =
|
|
2231
|
+
RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2232
|
+
throw ctx.redirect(callbackUrl);
|
|
1500
2233
|
},
|
|
1501
2234
|
),
|
|
1502
2235
|
},
|