@better-auth/sso 1.3.13 → 1.3.14

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