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