@better-auth/sso 1.3.18 → 1.4.0-beta.1

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