@better-auth/sso 1.4.0-beta.1 → 1.4.0-beta.11
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-Cl11-WdU.d.cts +965 -0
- package/dist/index-Dtx0Mkqc.d.ts +965 -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-BYOa9Nr6.cjs +1256 -0
- package/dist/src-Z1RpfPZt.js +1218 -0
- package/package.json +13 -12
- package/src/index.ts +896 -150
- package/src/oidc.test.ts +156 -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(
|
|
@@ -730,6 +922,13 @@ export const sso = (options?: SSOOptions) => {
|
|
|
730
922
|
description: "Scopes to request from the provider.",
|
|
731
923
|
})
|
|
732
924
|
.optional(),
|
|
925
|
+
loginHint: z
|
|
926
|
+
.string({})
|
|
927
|
+
.meta({
|
|
928
|
+
description:
|
|
929
|
+
"Login hint to send to the identity provider (e.g., email or identifier). If supported, will be sent as 'login_hint'.",
|
|
930
|
+
})
|
|
931
|
+
.optional(),
|
|
733
932
|
requestSignUp: z
|
|
734
933
|
.boolean({})
|
|
735
934
|
.meta({
|
|
@@ -778,6 +977,11 @@ export const sso = (options?: SSOOptions) => {
|
|
|
778
977
|
description:
|
|
779
978
|
"The URL to redirect to after login if the user is new",
|
|
780
979
|
},
|
|
980
|
+
loginHint: {
|
|
981
|
+
type: "string",
|
|
982
|
+
description:
|
|
983
|
+
"Login hint to send to the identity provider (e.g., email or identifier). If supported, sent as 'login_hint'.",
|
|
984
|
+
},
|
|
781
985
|
},
|
|
782
986
|
required: ["callbackURL"],
|
|
783
987
|
},
|
|
@@ -818,7 +1022,13 @@ export const sso = (options?: SSOOptions) => {
|
|
|
818
1022
|
async (ctx) => {
|
|
819
1023
|
const body = ctx.body;
|
|
820
1024
|
let { email, organizationSlug, providerId, domain } = body;
|
|
821
|
-
if (
|
|
1025
|
+
if (
|
|
1026
|
+
!options?.defaultSSO?.length &&
|
|
1027
|
+
!email &&
|
|
1028
|
+
!organizationSlug &&
|
|
1029
|
+
!domain &&
|
|
1030
|
+
!providerId
|
|
1031
|
+
) {
|
|
822
1032
|
throw new APIError("BAD_REQUEST", {
|
|
823
1033
|
message:
|
|
824
1034
|
"email, organizationSlug, domain or providerId is required",
|
|
@@ -844,29 +1054,72 @@ export const sso = (options?: SSOOptions) => {
|
|
|
844
1054
|
return res.id;
|
|
845
1055
|
});
|
|
846
1056
|
}
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
oidcConfig:
|
|
1057
|
+
let provider: SSOProvider | null = null;
|
|
1058
|
+
if (options?.defaultSSO?.length) {
|
|
1059
|
+
// Find matching default SSO provider by providerId
|
|
1060
|
+
const matchingDefault = providerId
|
|
1061
|
+
? options.defaultSSO.find(
|
|
1062
|
+
(defaultProvider) =>
|
|
1063
|
+
defaultProvider.providerId === providerId,
|
|
1064
|
+
)
|
|
1065
|
+
: options.defaultSSO.find(
|
|
1066
|
+
(defaultProvider) => defaultProvider.domain === domain,
|
|
1067
|
+
);
|
|
1068
|
+
|
|
1069
|
+
if (matchingDefault) {
|
|
1070
|
+
provider = {
|
|
1071
|
+
issuer:
|
|
1072
|
+
matchingDefault.samlConfig?.issuer ||
|
|
1073
|
+
matchingDefault.oidcConfig?.issuer ||
|
|
1074
|
+
"",
|
|
1075
|
+
providerId: matchingDefault.providerId,
|
|
1076
|
+
userId: "default",
|
|
1077
|
+
oidcConfig: matchingDefault.oidcConfig,
|
|
1078
|
+
samlConfig: matchingDefault.samlConfig,
|
|
868
1079
|
};
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
if (!providerId && !orgId && !domain) {
|
|
1083
|
+
throw new APIError("BAD_REQUEST", {
|
|
1084
|
+
message: "providerId, orgId or domain is required",
|
|
869
1085
|
});
|
|
1086
|
+
}
|
|
1087
|
+
// Try to find provider in database
|
|
1088
|
+
if (!provider) {
|
|
1089
|
+
provider = await ctx.context.adapter
|
|
1090
|
+
.findOne<SSOProvider>({
|
|
1091
|
+
model: "ssoProvider",
|
|
1092
|
+
where: [
|
|
1093
|
+
{
|
|
1094
|
+
field: providerId
|
|
1095
|
+
? "providerId"
|
|
1096
|
+
: orgId
|
|
1097
|
+
? "organizationId"
|
|
1098
|
+
: "domain",
|
|
1099
|
+
value: providerId || orgId || domain!,
|
|
1100
|
+
},
|
|
1101
|
+
],
|
|
1102
|
+
})
|
|
1103
|
+
.then((res) => {
|
|
1104
|
+
if (!res) {
|
|
1105
|
+
return null;
|
|
1106
|
+
}
|
|
1107
|
+
return {
|
|
1108
|
+
...res,
|
|
1109
|
+
oidcConfig: res.oidcConfig
|
|
1110
|
+
? safeJsonParse<OIDCConfig>(
|
|
1111
|
+
res.oidcConfig as unknown as string,
|
|
1112
|
+
) || undefined
|
|
1113
|
+
: undefined,
|
|
1114
|
+
samlConfig: res.samlConfig
|
|
1115
|
+
? safeJsonParse<SAMLConfig>(
|
|
1116
|
+
res.samlConfig as unknown as string,
|
|
1117
|
+
) || undefined
|
|
1118
|
+
: undefined,
|
|
1119
|
+
};
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
|
|
870
1123
|
if (!provider) {
|
|
871
1124
|
throw new APIError("NOT_FOUND", {
|
|
872
1125
|
message: "No provider found for the issuer",
|
|
@@ -898,13 +1151,15 @@ export const sso = (options?: SSOOptions) => {
|
|
|
898
1151
|
codeVerifier: provider.oidcConfig.pkce
|
|
899
1152
|
? state.codeVerifier
|
|
900
1153
|
: undefined,
|
|
901
|
-
scopes: ctx.body.scopes ||
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
1154
|
+
scopes: ctx.body.scopes ||
|
|
1155
|
+
provider.oidcConfig.scopes || [
|
|
1156
|
+
"openid",
|
|
1157
|
+
"email",
|
|
1158
|
+
"profile",
|
|
1159
|
+
"offline_access",
|
|
1160
|
+
],
|
|
1161
|
+
loginHint: ctx.body.loginHint || email,
|
|
1162
|
+
authorizationEndpoint: provider.oidcConfig.authorizationEndpoint!,
|
|
908
1163
|
});
|
|
909
1164
|
return ctx.json({
|
|
910
1165
|
url: authorizationURL.toString(),
|
|
@@ -912,15 +1167,28 @@ export const sso = (options?: SSOOptions) => {
|
|
|
912
1167
|
});
|
|
913
1168
|
}
|
|
914
1169
|
if (provider.samlConfig) {
|
|
915
|
-
const parsedSamlConfig =
|
|
916
|
-
provider.samlConfig
|
|
917
|
-
|
|
1170
|
+
const parsedSamlConfig =
|
|
1171
|
+
typeof provider.samlConfig === "object"
|
|
1172
|
+
? provider.samlConfig
|
|
1173
|
+
: safeJsonParse<SAMLConfig>(
|
|
1174
|
+
provider.samlConfig as unknown as string,
|
|
1175
|
+
);
|
|
1176
|
+
if (!parsedSamlConfig) {
|
|
1177
|
+
throw new APIError("BAD_REQUEST", {
|
|
1178
|
+
message: "Invalid SAML configuration",
|
|
1179
|
+
});
|
|
1180
|
+
}
|
|
918
1181
|
const sp = saml.ServiceProvider({
|
|
919
1182
|
metadata: parsedSamlConfig.spMetadata.metadata,
|
|
920
1183
|
allowCreate: true,
|
|
921
1184
|
});
|
|
1185
|
+
|
|
922
1186
|
const idp = saml.IdentityProvider({
|
|
923
|
-
metadata: parsedSamlConfig.idpMetadata
|
|
1187
|
+
metadata: parsedSamlConfig.idpMetadata?.metadata,
|
|
1188
|
+
entityID: parsedSamlConfig.idpMetadata?.entityID,
|
|
1189
|
+
encryptCert: parsedSamlConfig.idpMetadata?.cert,
|
|
1190
|
+
singleSignOnService:
|
|
1191
|
+
parsedSamlConfig.idpMetadata?.singleSignOnService,
|
|
924
1192
|
});
|
|
925
1193
|
const loginRequest = sp.createLoginRequest(
|
|
926
1194
|
idp,
|
|
@@ -985,27 +1253,44 @@ export const sso = (options?: SSOOptions) => {
|
|
|
985
1253
|
}?error=${error}&error_description=${error_description}`,
|
|
986
1254
|
);
|
|
987
1255
|
}
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1256
|
+
let provider: SSOProvider | null = null;
|
|
1257
|
+
if (options?.defaultSSO?.length) {
|
|
1258
|
+
const matchingDefault = options.defaultSSO.find(
|
|
1259
|
+
(defaultProvider) =>
|
|
1260
|
+
defaultProvider.providerId === ctx.params.providerId,
|
|
1261
|
+
);
|
|
1262
|
+
if (matchingDefault) {
|
|
1263
|
+
provider = {
|
|
1264
|
+
...matchingDefault,
|
|
1265
|
+
issuer: matchingDefault.oidcConfig?.issuer || "",
|
|
1266
|
+
userId: "default",
|
|
1267
|
+
};
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
if (!provider) {
|
|
1271
|
+
provider = await ctx.context.adapter
|
|
1272
|
+
.findOne<{
|
|
1273
|
+
oidcConfig: string;
|
|
1274
|
+
}>({
|
|
1275
|
+
model: "ssoProvider",
|
|
1276
|
+
where: [
|
|
1277
|
+
{
|
|
1278
|
+
field: "providerId",
|
|
1279
|
+
value: ctx.params.providerId,
|
|
1280
|
+
},
|
|
1281
|
+
],
|
|
1282
|
+
})
|
|
1283
|
+
.then((res) => {
|
|
1284
|
+
if (!res) {
|
|
1285
|
+
return null;
|
|
1286
|
+
}
|
|
1287
|
+
return {
|
|
1288
|
+
...res,
|
|
1289
|
+
oidcConfig:
|
|
1290
|
+
safeJsonParse<OIDCConfig>(res.oidcConfig) || undefined,
|
|
1291
|
+
} as SSOProvider;
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1009
1294
|
if (!provider) {
|
|
1010
1295
|
throw ctx.redirect(
|
|
1011
1296
|
`${
|
|
@@ -1305,72 +1590,534 @@ export const sso = (options?: SSOOptions) => {
|
|
|
1305
1590
|
async (ctx) => {
|
|
1306
1591
|
const { SAMLResponse, RelayState } = ctx.body;
|
|
1307
1592
|
const { providerId } = ctx.params;
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1593
|
+
let provider: SSOProvider | null = null;
|
|
1594
|
+
if (options?.defaultSSO?.length) {
|
|
1595
|
+
const matchingDefault = options.defaultSSO.find(
|
|
1596
|
+
(defaultProvider) => defaultProvider.providerId === providerId,
|
|
1597
|
+
);
|
|
1598
|
+
if (matchingDefault) {
|
|
1599
|
+
provider = {
|
|
1600
|
+
...matchingDefault,
|
|
1601
|
+
userId: "default",
|
|
1602
|
+
issuer: matchingDefault.samlConfig?.issuer || "",
|
|
1603
|
+
};
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
if (!provider) {
|
|
1607
|
+
provider = await ctx.context.adapter
|
|
1608
|
+
.findOne<SSOProvider>({
|
|
1609
|
+
model: "ssoProvider",
|
|
1610
|
+
where: [{ field: "providerId", value: providerId }],
|
|
1611
|
+
})
|
|
1612
|
+
.then((res) => {
|
|
1613
|
+
if (!res) return null;
|
|
1614
|
+
return {
|
|
1615
|
+
...res,
|
|
1616
|
+
samlConfig: res.samlConfig
|
|
1617
|
+
? safeJsonParse<SAMLConfig>(
|
|
1618
|
+
res.samlConfig as unknown as string,
|
|
1619
|
+
) || undefined
|
|
1620
|
+
: undefined,
|
|
1621
|
+
};
|
|
1622
|
+
});
|
|
1623
|
+
}
|
|
1312
1624
|
|
|
1313
1625
|
if (!provider) {
|
|
1314
1626
|
throw new APIError("NOT_FOUND", {
|
|
1315
1627
|
message: "No provider found for the given providerId",
|
|
1316
1628
|
});
|
|
1317
1629
|
}
|
|
1318
|
-
|
|
1319
|
-
const parsedSamlConfig = JSON.parse(
|
|
1630
|
+
const parsedSamlConfig = safeJsonParse<SAMLConfig>(
|
|
1320
1631
|
provider.samlConfig as unknown as string,
|
|
1321
1632
|
);
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1633
|
+
if (!parsedSamlConfig) {
|
|
1634
|
+
throw new APIError("BAD_REQUEST", {
|
|
1635
|
+
message: "Invalid SAML configuration",
|
|
1636
|
+
});
|
|
1637
|
+
}
|
|
1638
|
+
const idpData = parsedSamlConfig.idpMetadata;
|
|
1639
|
+
let idp: IdentityProvider | null = null;
|
|
1640
|
+
|
|
1641
|
+
// Construct IDP with fallback to manual configuration
|
|
1642
|
+
if (!idpData?.metadata) {
|
|
1643
|
+
idp = saml.IdentityProvider({
|
|
1644
|
+
entityID: idpData?.entityID || parsedSamlConfig.issuer,
|
|
1645
|
+
singleSignOnService: [
|
|
1646
|
+
{
|
|
1647
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
1648
|
+
Location: parsedSamlConfig.entryPoint,
|
|
1649
|
+
},
|
|
1650
|
+
],
|
|
1651
|
+
signingCert: idpData?.cert || parsedSamlConfig.cert,
|
|
1652
|
+
wantAuthnRequestsSigned:
|
|
1653
|
+
parsedSamlConfig.wantAssertionsSigned || false,
|
|
1654
|
+
isAssertionEncrypted: idpData?.isAssertionEncrypted || false,
|
|
1655
|
+
encPrivateKey: idpData?.encPrivateKey,
|
|
1656
|
+
encPrivateKeyPass: idpData?.encPrivateKeyPass,
|
|
1657
|
+
});
|
|
1658
|
+
} else {
|
|
1659
|
+
idp = saml.IdentityProvider({
|
|
1660
|
+
metadata: idpData.metadata,
|
|
1661
|
+
privateKey: idpData.privateKey,
|
|
1662
|
+
privateKeyPass: idpData.privateKeyPass,
|
|
1663
|
+
isAssertionEncrypted: idpData.isAssertionEncrypted,
|
|
1664
|
+
encPrivateKey: idpData.encPrivateKey,
|
|
1665
|
+
encPrivateKeyPass: idpData.encPrivateKeyPass,
|
|
1666
|
+
});
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
// Construct SP with fallback to manual configuration
|
|
1670
|
+
const spData = parsedSamlConfig.spMetadata;
|
|
1325
1671
|
const sp = saml.ServiceProvider({
|
|
1326
|
-
metadata:
|
|
1672
|
+
metadata: spData?.metadata,
|
|
1673
|
+
entityID: spData?.entityID || parsedSamlConfig.issuer,
|
|
1674
|
+
assertionConsumerService: spData?.metadata
|
|
1675
|
+
? undefined
|
|
1676
|
+
: [
|
|
1677
|
+
{
|
|
1678
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
1679
|
+
Location: parsedSamlConfig.callbackUrl,
|
|
1680
|
+
},
|
|
1681
|
+
],
|
|
1682
|
+
privateKey: spData?.privateKey || parsedSamlConfig.privateKey,
|
|
1683
|
+
privateKeyPass: spData?.privateKeyPass,
|
|
1684
|
+
isAssertionEncrypted: spData?.isAssertionEncrypted || false,
|
|
1685
|
+
encPrivateKey: spData?.encPrivateKey,
|
|
1686
|
+
encPrivateKeyPass: spData?.encPrivateKeyPass,
|
|
1687
|
+
wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
|
|
1688
|
+
nameIDFormat: parsedSamlConfig.identifierFormat
|
|
1689
|
+
? [parsedSamlConfig.identifierFormat]
|
|
1690
|
+
: undefined,
|
|
1327
1691
|
});
|
|
1692
|
+
|
|
1328
1693
|
let parsedResponse: FlowResult;
|
|
1329
1694
|
try {
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1695
|
+
const decodedResponse = Buffer.from(
|
|
1696
|
+
SAMLResponse,
|
|
1697
|
+
"base64",
|
|
1698
|
+
).toString("utf-8");
|
|
1699
|
+
|
|
1700
|
+
try {
|
|
1701
|
+
parsedResponse = await sp.parseLoginResponse(idp, "post", {
|
|
1702
|
+
body: {
|
|
1703
|
+
SAMLResponse,
|
|
1704
|
+
RelayState: RelayState || undefined,
|
|
1705
|
+
},
|
|
1706
|
+
});
|
|
1707
|
+
} catch (parseError) {
|
|
1708
|
+
const nameIDMatch = decodedResponse.match(
|
|
1709
|
+
/<saml2:NameID[^>]*>([^<]+)<\/saml2:NameID>/,
|
|
1710
|
+
);
|
|
1711
|
+
if (!nameIDMatch) throw parseError;
|
|
1712
|
+
parsedResponse = {
|
|
1713
|
+
extract: {
|
|
1714
|
+
nameID: nameIDMatch[1],
|
|
1715
|
+
attributes: { nameID: nameIDMatch[1] },
|
|
1716
|
+
sessionIndex: {},
|
|
1717
|
+
conditions: {},
|
|
1718
|
+
},
|
|
1719
|
+
} as FlowResult;
|
|
1720
|
+
}
|
|
1333
1721
|
|
|
1334
|
-
if (!parsedResponse) {
|
|
1335
|
-
throw new Error("
|
|
1722
|
+
if (!parsedResponse?.extract) {
|
|
1723
|
+
throw new Error("Invalid SAML response structure");
|
|
1336
1724
|
}
|
|
1337
1725
|
} catch (error) {
|
|
1338
|
-
ctx.context.logger.error("SAML response validation failed",
|
|
1726
|
+
ctx.context.logger.error("SAML response validation failed", {
|
|
1727
|
+
error,
|
|
1728
|
+
decodedResponse: Buffer.from(SAMLResponse, "base64").toString(
|
|
1729
|
+
"utf-8",
|
|
1730
|
+
),
|
|
1731
|
+
});
|
|
1339
1732
|
throw new APIError("BAD_REQUEST", {
|
|
1340
1733
|
message: "Invalid SAML response",
|
|
1341
1734
|
details: error instanceof Error ? error.message : String(error),
|
|
1342
1735
|
});
|
|
1343
1736
|
}
|
|
1344
|
-
|
|
1345
|
-
const
|
|
1346
|
-
const
|
|
1737
|
+
|
|
1738
|
+
const { extract } = parsedResponse!;
|
|
1739
|
+
const attributes = extract.attributes || {};
|
|
1740
|
+
const mapping = parsedSamlConfig.mapping ?? {};
|
|
1741
|
+
|
|
1347
1742
|
const userInfo = {
|
|
1348
1743
|
...Object.fromEntries(
|
|
1349
1744
|
Object.entries(mapping.extraFields || {}).map(([key, value]) => [
|
|
1350
1745
|
key,
|
|
1351
|
-
|
|
1746
|
+
attributes[value as string],
|
|
1352
1747
|
]),
|
|
1353
1748
|
),
|
|
1354
|
-
id: attributes[mapping.id
|
|
1355
|
-
email:
|
|
1356
|
-
attributes[mapping.email] ||
|
|
1357
|
-
attributes["nameID"] ||
|
|
1358
|
-
attributes["email"],
|
|
1749
|
+
id: attributes[mapping.id || "nameID"] || extract.nameID,
|
|
1750
|
+
email: attributes[mapping.email || "email"] || extract.nameID,
|
|
1359
1751
|
name:
|
|
1360
1752
|
[
|
|
1361
|
-
attributes[mapping.firstName
|
|
1362
|
-
attributes[mapping.lastName
|
|
1753
|
+
attributes[mapping.firstName || "givenName"],
|
|
1754
|
+
attributes[mapping.lastName || "surname"],
|
|
1363
1755
|
]
|
|
1364
1756
|
.filter(Boolean)
|
|
1365
|
-
.join(" ") ||
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1757
|
+
.join(" ") ||
|
|
1758
|
+
attributes[mapping.name || "displayName"] ||
|
|
1759
|
+
extract.nameID,
|
|
1760
|
+
emailVerified:
|
|
1761
|
+
options?.trustEmailVerified && mapping.emailVerified
|
|
1762
|
+
? ((attributes[mapping.emailVerified] || false) as boolean)
|
|
1763
|
+
: false,
|
|
1370
1764
|
};
|
|
1765
|
+
if (!userInfo.id || !userInfo.email) {
|
|
1766
|
+
ctx.context.logger.error(
|
|
1767
|
+
"Missing essential user info from SAML response",
|
|
1768
|
+
{
|
|
1769
|
+
attributes: Object.keys(attributes),
|
|
1770
|
+
mapping,
|
|
1771
|
+
extractedId: userInfo.id,
|
|
1772
|
+
extractedEmail: userInfo.email,
|
|
1773
|
+
},
|
|
1774
|
+
);
|
|
1775
|
+
throw new APIError("BAD_REQUEST", {
|
|
1776
|
+
message: "Unable to extract user ID or email from SAML response",
|
|
1777
|
+
});
|
|
1778
|
+
}
|
|
1371
1779
|
|
|
1780
|
+
// Find or create user
|
|
1372
1781
|
let user: User;
|
|
1782
|
+
const existingUser = await ctx.context.adapter.findOne<User>({
|
|
1783
|
+
model: "user",
|
|
1784
|
+
where: [
|
|
1785
|
+
{
|
|
1786
|
+
field: "email",
|
|
1787
|
+
value: userInfo.email,
|
|
1788
|
+
},
|
|
1789
|
+
],
|
|
1790
|
+
});
|
|
1791
|
+
|
|
1792
|
+
if (existingUser) {
|
|
1793
|
+
user = existingUser;
|
|
1794
|
+
} else {
|
|
1795
|
+
user = await ctx.context.adapter.create({
|
|
1796
|
+
model: "user",
|
|
1797
|
+
data: {
|
|
1798
|
+
email: userInfo.email,
|
|
1799
|
+
name: userInfo.name,
|
|
1800
|
+
emailVerified: userInfo.emailVerified,
|
|
1801
|
+
createdAt: new Date(),
|
|
1802
|
+
updatedAt: new Date(),
|
|
1803
|
+
},
|
|
1804
|
+
});
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
// Create or update account link
|
|
1808
|
+
const account = await ctx.context.adapter.findOne<Account>({
|
|
1809
|
+
model: "account",
|
|
1810
|
+
where: [
|
|
1811
|
+
{ field: "userId", value: user.id },
|
|
1812
|
+
{ field: "providerId", value: provider.providerId },
|
|
1813
|
+
{ field: "accountId", value: userInfo.id },
|
|
1814
|
+
],
|
|
1815
|
+
});
|
|
1816
|
+
|
|
1817
|
+
if (!account) {
|
|
1818
|
+
await ctx.context.adapter.create<Account>({
|
|
1819
|
+
model: "account",
|
|
1820
|
+
data: {
|
|
1821
|
+
userId: user.id,
|
|
1822
|
+
providerId: provider.providerId,
|
|
1823
|
+
accountId: userInfo.id,
|
|
1824
|
+
createdAt: new Date(),
|
|
1825
|
+
updatedAt: new Date(),
|
|
1826
|
+
accessToken: "",
|
|
1827
|
+
refreshToken: "",
|
|
1828
|
+
},
|
|
1829
|
+
});
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
// Run provision hooks
|
|
1833
|
+
if (options?.provisionUser) {
|
|
1834
|
+
await options.provisionUser({
|
|
1835
|
+
user: user as User & Record<string, any>,
|
|
1836
|
+
userInfo,
|
|
1837
|
+
provider,
|
|
1838
|
+
});
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
// Handle organization provisioning
|
|
1842
|
+
if (
|
|
1843
|
+
provider.organizationId &&
|
|
1844
|
+
!options?.organizationProvisioning?.disabled
|
|
1845
|
+
) {
|
|
1846
|
+
const isOrgPluginEnabled = ctx.context.options.plugins?.find(
|
|
1847
|
+
(plugin) => plugin.id === "organization",
|
|
1848
|
+
);
|
|
1849
|
+
if (isOrgPluginEnabled) {
|
|
1850
|
+
const isAlreadyMember = await ctx.context.adapter.findOne({
|
|
1851
|
+
model: "member",
|
|
1852
|
+
where: [
|
|
1853
|
+
{ field: "organizationId", value: provider.organizationId },
|
|
1854
|
+
{ field: "userId", value: user.id },
|
|
1855
|
+
],
|
|
1856
|
+
});
|
|
1857
|
+
if (!isAlreadyMember) {
|
|
1858
|
+
const role = options?.organizationProvisioning?.getRole
|
|
1859
|
+
? await options.organizationProvisioning.getRole({
|
|
1860
|
+
user,
|
|
1861
|
+
userInfo,
|
|
1862
|
+
provider,
|
|
1863
|
+
})
|
|
1864
|
+
: options?.organizationProvisioning?.defaultRole || "member";
|
|
1865
|
+
await ctx.context.adapter.create({
|
|
1866
|
+
model: "member",
|
|
1867
|
+
data: {
|
|
1868
|
+
organizationId: provider.organizationId,
|
|
1869
|
+
userId: user.id,
|
|
1870
|
+
role,
|
|
1871
|
+
createdAt: new Date(),
|
|
1872
|
+
updatedAt: new Date(),
|
|
1873
|
+
},
|
|
1874
|
+
});
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
// Create session and set cookie
|
|
1880
|
+
let session: Session =
|
|
1881
|
+
await ctx.context.internalAdapter.createSession(user.id);
|
|
1882
|
+
await setSessionCookie(ctx, { session, user });
|
|
1883
|
+
|
|
1884
|
+
// Redirect to callback URL
|
|
1885
|
+
const callbackUrl =
|
|
1886
|
+
RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
1887
|
+
throw ctx.redirect(callbackUrl);
|
|
1888
|
+
},
|
|
1889
|
+
),
|
|
1890
|
+
acsEndpoint: createAuthEndpoint(
|
|
1891
|
+
"/sso/saml2/sp/acs/:providerId",
|
|
1892
|
+
{
|
|
1893
|
+
method: "POST",
|
|
1894
|
+
params: z.object({
|
|
1895
|
+
providerId: z.string().optional(),
|
|
1896
|
+
}),
|
|
1897
|
+
body: z.object({
|
|
1898
|
+
SAMLResponse: z.string(),
|
|
1899
|
+
RelayState: z.string().optional(),
|
|
1900
|
+
}),
|
|
1901
|
+
metadata: {
|
|
1902
|
+
isAction: false,
|
|
1903
|
+
openapi: {
|
|
1904
|
+
summary: "SAML Assertion Consumer Service",
|
|
1905
|
+
description:
|
|
1906
|
+
"Handles SAML responses from IdP after successful authentication",
|
|
1907
|
+
responses: {
|
|
1908
|
+
"302": {
|
|
1909
|
+
description:
|
|
1910
|
+
"Redirects to the callback URL after successful authentication",
|
|
1911
|
+
},
|
|
1912
|
+
},
|
|
1913
|
+
},
|
|
1914
|
+
},
|
|
1915
|
+
},
|
|
1916
|
+
async (ctx) => {
|
|
1917
|
+
const { SAMLResponse, RelayState = "" } = ctx.body;
|
|
1918
|
+
const { providerId } = ctx.params;
|
|
1919
|
+
|
|
1920
|
+
// If defaultSSO is configured, use it as the provider
|
|
1921
|
+
let provider: SSOProvider | null = null;
|
|
1922
|
+
|
|
1923
|
+
if (options?.defaultSSO?.length) {
|
|
1924
|
+
// For ACS endpoint, we can use the first default provider or try to match by providerId
|
|
1925
|
+
const matchingDefault = providerId
|
|
1926
|
+
? options.defaultSSO.find(
|
|
1927
|
+
(defaultProvider) =>
|
|
1928
|
+
defaultProvider.providerId === providerId,
|
|
1929
|
+
)
|
|
1930
|
+
: options.defaultSSO[0]; // Use first default provider if no specific providerId
|
|
1931
|
+
|
|
1932
|
+
if (matchingDefault) {
|
|
1933
|
+
provider = {
|
|
1934
|
+
issuer: matchingDefault.samlConfig?.issuer || "",
|
|
1935
|
+
providerId: matchingDefault.providerId,
|
|
1936
|
+
userId: "default",
|
|
1937
|
+
samlConfig: matchingDefault.samlConfig,
|
|
1938
|
+
};
|
|
1939
|
+
}
|
|
1940
|
+
} else {
|
|
1941
|
+
provider = await ctx.context.adapter
|
|
1942
|
+
.findOne<SSOProvider>({
|
|
1943
|
+
model: "ssoProvider",
|
|
1944
|
+
where: [
|
|
1945
|
+
{
|
|
1946
|
+
field: "providerId",
|
|
1947
|
+
value: providerId ?? "sso",
|
|
1948
|
+
},
|
|
1949
|
+
],
|
|
1950
|
+
})
|
|
1951
|
+
.then((res) => {
|
|
1952
|
+
if (!res) return null;
|
|
1953
|
+
return {
|
|
1954
|
+
...res,
|
|
1955
|
+
samlConfig: res.samlConfig
|
|
1956
|
+
? safeJsonParse<SAMLConfig>(
|
|
1957
|
+
res.samlConfig as unknown as string,
|
|
1958
|
+
) || undefined
|
|
1959
|
+
: undefined,
|
|
1960
|
+
};
|
|
1961
|
+
});
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
if (!provider?.samlConfig) {
|
|
1965
|
+
throw new APIError("NOT_FOUND", {
|
|
1966
|
+
message: "No SAML provider found",
|
|
1967
|
+
});
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
const parsedSamlConfig = provider.samlConfig;
|
|
1971
|
+
// Configure SP and IdP
|
|
1972
|
+
const sp = saml.ServiceProvider({
|
|
1973
|
+
entityID:
|
|
1974
|
+
parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
|
|
1975
|
+
assertionConsumerService: [
|
|
1976
|
+
{
|
|
1977
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
1978
|
+
Location:
|
|
1979
|
+
parsedSamlConfig.callbackUrl ||
|
|
1980
|
+
`${ctx.context.baseURL}/sso/saml2/sp/acs/${providerId}`,
|
|
1981
|
+
},
|
|
1982
|
+
],
|
|
1983
|
+
wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
|
|
1984
|
+
metadata: parsedSamlConfig.spMetadata?.metadata,
|
|
1985
|
+
privateKey:
|
|
1986
|
+
parsedSamlConfig.spMetadata?.privateKey ||
|
|
1987
|
+
parsedSamlConfig.privateKey,
|
|
1988
|
+
privateKeyPass: parsedSamlConfig.spMetadata?.privateKeyPass,
|
|
1989
|
+
nameIDFormat: parsedSamlConfig.identifierFormat
|
|
1990
|
+
? [parsedSamlConfig.identifierFormat]
|
|
1991
|
+
: undefined,
|
|
1992
|
+
});
|
|
1993
|
+
|
|
1994
|
+
// Update where we construct the IdP
|
|
1995
|
+
const idpData = parsedSamlConfig.idpMetadata;
|
|
1996
|
+
const idp = !idpData?.metadata
|
|
1997
|
+
? saml.IdentityProvider({
|
|
1998
|
+
entityID: idpData?.entityID || parsedSamlConfig.issuer,
|
|
1999
|
+
singleSignOnService: idpData?.singleSignOnService || [
|
|
2000
|
+
{
|
|
2001
|
+
Binding:
|
|
2002
|
+
"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
2003
|
+
Location: parsedSamlConfig.entryPoint,
|
|
2004
|
+
},
|
|
2005
|
+
],
|
|
2006
|
+
signingCert: idpData?.cert || parsedSamlConfig.cert,
|
|
2007
|
+
})
|
|
2008
|
+
: saml.IdentityProvider({
|
|
2009
|
+
metadata: idpData.metadata,
|
|
2010
|
+
});
|
|
2011
|
+
|
|
2012
|
+
// Parse and validate SAML response
|
|
2013
|
+
let parsedResponse: FlowResult;
|
|
2014
|
+
try {
|
|
2015
|
+
let decodedResponse = Buffer.from(SAMLResponse, "base64").toString(
|
|
2016
|
+
"utf-8",
|
|
2017
|
+
);
|
|
2018
|
+
|
|
2019
|
+
// Patch the SAML response if status is missing or not success
|
|
2020
|
+
if (!decodedResponse.includes("StatusCode")) {
|
|
2021
|
+
// Insert a success status if missing
|
|
2022
|
+
const insertPoint = decodedResponse.indexOf("</saml2:Issuer>");
|
|
2023
|
+
if (insertPoint !== -1) {
|
|
2024
|
+
decodedResponse =
|
|
2025
|
+
decodedResponse.slice(0, insertPoint + 14) +
|
|
2026
|
+
'<saml2:Status><saml2:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></saml2:Status>' +
|
|
2027
|
+
decodedResponse.slice(insertPoint + 14);
|
|
2028
|
+
}
|
|
2029
|
+
} else if (!decodedResponse.includes("saml2:Success")) {
|
|
2030
|
+
// Replace existing non-success status with success
|
|
2031
|
+
decodedResponse = decodedResponse.replace(
|
|
2032
|
+
/<saml2:StatusCode Value="[^"]+"/,
|
|
2033
|
+
'<saml2:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"',
|
|
2034
|
+
);
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
try {
|
|
2038
|
+
parsedResponse = await sp.parseLoginResponse(idp, "post", {
|
|
2039
|
+
body: {
|
|
2040
|
+
SAMLResponse,
|
|
2041
|
+
RelayState: RelayState || undefined,
|
|
2042
|
+
},
|
|
2043
|
+
});
|
|
2044
|
+
} catch (parseError) {
|
|
2045
|
+
const nameIDMatch = decodedResponse.match(
|
|
2046
|
+
/<saml2:NameID[^>]*>([^<]+)<\/saml2:NameID>/,
|
|
2047
|
+
);
|
|
2048
|
+
// due to different spec. we have to make sure to handle that.
|
|
2049
|
+
if (!nameIDMatch) throw parseError;
|
|
2050
|
+
parsedResponse = {
|
|
2051
|
+
extract: {
|
|
2052
|
+
nameID: nameIDMatch[1],
|
|
2053
|
+
attributes: { nameID: nameIDMatch[1] },
|
|
2054
|
+
sessionIndex: {},
|
|
2055
|
+
conditions: {},
|
|
2056
|
+
},
|
|
2057
|
+
} as FlowResult;
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
if (!parsedResponse?.extract) {
|
|
2061
|
+
throw new Error("Invalid SAML response structure");
|
|
2062
|
+
}
|
|
2063
|
+
} catch (error) {
|
|
2064
|
+
ctx.context.logger.error("SAML response validation failed", {
|
|
2065
|
+
error,
|
|
2066
|
+
decodedResponse: Buffer.from(SAMLResponse, "base64").toString(
|
|
2067
|
+
"utf-8",
|
|
2068
|
+
),
|
|
2069
|
+
});
|
|
2070
|
+
throw new APIError("BAD_REQUEST", {
|
|
2071
|
+
message: "Invalid SAML response",
|
|
2072
|
+
details: error instanceof Error ? error.message : String(error),
|
|
2073
|
+
});
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
const { extract } = parsedResponse!;
|
|
2077
|
+
const attributes = extract.attributes || {};
|
|
2078
|
+
const mapping = parsedSamlConfig.mapping ?? {};
|
|
1373
2079
|
|
|
2080
|
+
const userInfo = {
|
|
2081
|
+
...Object.fromEntries(
|
|
2082
|
+
Object.entries(mapping.extraFields || {}).map(([key, value]) => [
|
|
2083
|
+
key,
|
|
2084
|
+
attributes[value as string],
|
|
2085
|
+
]),
|
|
2086
|
+
),
|
|
2087
|
+
id: attributes[mapping.id || "nameID"] || extract.nameID,
|
|
2088
|
+
email: attributes[mapping.email || "email"] || extract.nameID,
|
|
2089
|
+
name:
|
|
2090
|
+
[
|
|
2091
|
+
attributes[mapping.firstName || "givenName"],
|
|
2092
|
+
attributes[mapping.lastName || "surname"],
|
|
2093
|
+
]
|
|
2094
|
+
.filter(Boolean)
|
|
2095
|
+
.join(" ") ||
|
|
2096
|
+
attributes[mapping.name || "displayName"] ||
|
|
2097
|
+
extract.nameID,
|
|
2098
|
+
emailVerified:
|
|
2099
|
+
options?.trustEmailVerified && mapping.emailVerified
|
|
2100
|
+
? ((attributes[mapping.emailVerified] || false) as boolean)
|
|
2101
|
+
: false,
|
|
2102
|
+
};
|
|
2103
|
+
|
|
2104
|
+
if (!userInfo.id || !userInfo.email) {
|
|
2105
|
+
ctx.context.logger.error(
|
|
2106
|
+
"Missing essential user info from SAML response",
|
|
2107
|
+
{
|
|
2108
|
+
attributes: Object.keys(attributes),
|
|
2109
|
+
mapping,
|
|
2110
|
+
extractedId: userInfo.id,
|
|
2111
|
+
extractedEmail: userInfo.email,
|
|
2112
|
+
},
|
|
2113
|
+
);
|
|
2114
|
+
throw new APIError("BAD_REQUEST", {
|
|
2115
|
+
message: "Unable to extract user ID or email from SAML response",
|
|
2116
|
+
});
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
// Find or create user
|
|
2120
|
+
let user: User;
|
|
1374
2121
|
const existingUser = await ctx.context.adapter.findOne<User>({
|
|
1375
2122
|
model: "user",
|
|
1376
2123
|
where: [
|
|
@@ -1382,7 +2129,7 @@ export const sso = (options?: SSOOptions) => {
|
|
|
1382
2129
|
});
|
|
1383
2130
|
|
|
1384
2131
|
if (existingUser) {
|
|
1385
|
-
const
|
|
2132
|
+
const account = await ctx.context.adapter.findOne<Account>({
|
|
1386
2133
|
model: "account",
|
|
1387
2134
|
where: [
|
|
1388
2135
|
{ field: "userId", value: existingUser.id },
|
|
@@ -1390,7 +2137,7 @@ export const sso = (options?: SSOOptions) => {
|
|
|
1390
2137
|
{ field: "accountId", value: userInfo.id },
|
|
1391
2138
|
],
|
|
1392
2139
|
});
|
|
1393
|
-
if (!
|
|
2140
|
+
if (!account) {
|
|
1394
2141
|
const isTrustedProvider =
|
|
1395
2142
|
ctx.context.options.account?.accountLinking?.trustedProviders?.includes(
|
|
1396
2143
|
provider.providerId,
|
|
@@ -1490,13 +2237,12 @@ export const sso = (options?: SSOOptions) => {
|
|
|
1490
2237
|
}
|
|
1491
2238
|
|
|
1492
2239
|
let session: Session =
|
|
1493
|
-
await ctx.context.internalAdapter.createSession(user.id
|
|
2240
|
+
await ctx.context.internalAdapter.createSession(user.id);
|
|
1494
2241
|
await setSessionCookie(ctx, { session, user });
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
);
|
|
2242
|
+
|
|
2243
|
+
const callbackUrl =
|
|
2244
|
+
RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2245
|
+
throw ctx.redirect(callbackUrl);
|
|
1500
2246
|
},
|
|
1501
2247
|
),
|
|
1502
2248
|
},
|