@better-auth/sso 1.4.6-beta.2 → 1.4.6

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/saml.test.ts CHANGED
@@ -1182,6 +1182,171 @@ describe("SAML SSO", async () => {
1182
1182
  },
1183
1183
  });
1184
1184
  });
1185
+
1186
+ it("should deny account linking when provider is not trusted and domain is not verified", async () => {
1187
+ const {
1188
+ auth: authUntrusted,
1189
+ signInWithTestUser,
1190
+ client,
1191
+ } = await getTestInstance({
1192
+ account: {
1193
+ accountLinking: {
1194
+ enabled: true,
1195
+ trustedProviders: [],
1196
+ },
1197
+ },
1198
+ plugins: [sso()],
1199
+ });
1200
+
1201
+ const { headers } = await signInWithTestUser();
1202
+
1203
+ await authUntrusted.api.registerSSOProvider({
1204
+ body: {
1205
+ providerId: "untrusted-saml-provider",
1206
+ issuer: "http://localhost:8081",
1207
+ domain: "http://localhost:8081",
1208
+ samlConfig: {
1209
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
1210
+ cert: certificate,
1211
+ callbackUrl: "http://localhost:3000/dashboard",
1212
+ wantAssertionsSigned: false,
1213
+ signatureAlgorithm: "sha256",
1214
+ digestAlgorithm: "sha256",
1215
+ idpMetadata: {
1216
+ metadata: idpMetadata,
1217
+ },
1218
+ spMetadata: {
1219
+ metadata: spMetadata,
1220
+ },
1221
+ identifierFormat:
1222
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
1223
+ },
1224
+ },
1225
+ headers,
1226
+ });
1227
+
1228
+ const ctx = await authUntrusted.$context;
1229
+ await ctx.adapter.create({
1230
+ model: "user",
1231
+ data: {
1232
+ id: "existing-user-id",
1233
+ email: "test@email.com",
1234
+ name: "Existing User",
1235
+ emailVerified: true,
1236
+ createdAt: new Date(),
1237
+ updatedAt: new Date(),
1238
+ },
1239
+ });
1240
+
1241
+ let samlResponse: any;
1242
+ await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
1243
+ onSuccess: async (context) => {
1244
+ samlResponse = await context.data;
1245
+ },
1246
+ });
1247
+
1248
+ const response = await authUntrusted.handler(
1249
+ new Request(
1250
+ "http://localhost:3000/api/auth/sso/saml2/callback/untrusted-saml-provider",
1251
+ {
1252
+ method: "POST",
1253
+ headers: {
1254
+ "Content-Type": "application/x-www-form-urlencoded",
1255
+ },
1256
+ body: new URLSearchParams({
1257
+ SAMLResponse: samlResponse.samlResponse,
1258
+ RelayState: "http://localhost:3000/dashboard",
1259
+ }),
1260
+ },
1261
+ ),
1262
+ );
1263
+
1264
+ expect(response.status).toBe(302);
1265
+ const redirectLocation = response.headers.get("location") || "";
1266
+ expect(redirectLocation).toContain("error=account_not_linked");
1267
+ });
1268
+
1269
+ it("should allow account linking when provider is in trustedProviders", async () => {
1270
+ const { auth: authWithTrusted, signInWithTestUser } = await getTestInstance(
1271
+ {
1272
+ account: {
1273
+ accountLinking: {
1274
+ enabled: true,
1275
+ trustedProviders: ["trusted-saml-provider"],
1276
+ },
1277
+ },
1278
+ plugins: [sso()],
1279
+ },
1280
+ );
1281
+
1282
+ const { headers } = await signInWithTestUser();
1283
+
1284
+ await authWithTrusted.api.registerSSOProvider({
1285
+ body: {
1286
+ providerId: "trusted-saml-provider",
1287
+ issuer: "http://localhost:8081",
1288
+ domain: "http://localhost:8081",
1289
+ samlConfig: {
1290
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
1291
+ cert: certificate,
1292
+ callbackUrl: "http://localhost:3000/dashboard",
1293
+ wantAssertionsSigned: false,
1294
+ signatureAlgorithm: "sha256",
1295
+ digestAlgorithm: "sha256",
1296
+ idpMetadata: {
1297
+ metadata: idpMetadata,
1298
+ },
1299
+ spMetadata: {
1300
+ metadata: spMetadata,
1301
+ },
1302
+ identifierFormat:
1303
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
1304
+ },
1305
+ },
1306
+ headers,
1307
+ });
1308
+
1309
+ const ctx = await authWithTrusted.$context;
1310
+ await ctx.adapter.create({
1311
+ model: "user",
1312
+ data: {
1313
+ id: "existing-user-id-2",
1314
+ email: "test@email.com",
1315
+ name: "Existing User",
1316
+ emailVerified: true,
1317
+ createdAt: new Date(),
1318
+ updatedAt: new Date(),
1319
+ },
1320
+ });
1321
+
1322
+ let samlResponse: any;
1323
+ await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
1324
+ onSuccess: async (context) => {
1325
+ samlResponse = await context.data;
1326
+ },
1327
+ });
1328
+
1329
+ const response = await authWithTrusted.handler(
1330
+ new Request(
1331
+ "http://localhost:3000/api/auth/sso/saml2/callback/trusted-saml-provider",
1332
+ {
1333
+ method: "POST",
1334
+ headers: {
1335
+ "Content-Type": "application/x-www-form-urlencoded",
1336
+ },
1337
+ body: new URLSearchParams({
1338
+ SAMLResponse: samlResponse.samlResponse,
1339
+ RelayState: "http://localhost:3000/dashboard",
1340
+ }),
1341
+ },
1342
+ ),
1343
+ );
1344
+
1345
+ expect(response.status).toBe(302);
1346
+ const redirectLocation = response.headers.get("location") || "";
1347
+ expect(redirectLocation).not.toContain("error");
1348
+ expect(redirectLocation).toContain("dashboard");
1349
+ });
1185
1350
  });
1186
1351
 
1187
1352
  describe("SAML SSO with custom fields", () => {
@@ -1325,3 +1490,184 @@ describe("SAML SSO with custom fields", () => {
1325
1490
  });
1326
1491
  });
1327
1492
  });
1493
+
1494
+ import { safeJsonParse } from "./utils";
1495
+
1496
+ describe("safeJsonParse", () => {
1497
+ it("returns object as-is when value is already an object", () => {
1498
+ const obj = { a: 1, nested: { b: 2 } };
1499
+ const result = safeJsonParse<typeof obj>(obj);
1500
+ expect(result).toBe(obj); // same reference
1501
+ expect(result).toEqual({ a: 1, nested: { b: 2 } });
1502
+ });
1503
+
1504
+ it("parses stringified JSON when value is a string", () => {
1505
+ const json = '{"a":1,"nested":{"b":2}}';
1506
+ const result = safeJsonParse<{ a: number; nested: { b: number } }>(json);
1507
+ expect(result).toEqual({ a: 1, nested: { b: 2 } });
1508
+ });
1509
+
1510
+ it("returns null for null input", () => {
1511
+ const result = safeJsonParse<{ a: number }>(null);
1512
+ expect(result).toBeNull();
1513
+ });
1514
+
1515
+ it("returns null for undefined input", () => {
1516
+ const result = safeJsonParse<{ a: number }>(undefined);
1517
+ expect(result).toBeNull();
1518
+ });
1519
+
1520
+ it("throws error for invalid JSON string", () => {
1521
+ expect(() => safeJsonParse<{ a: number }>("not valid json")).toThrow(
1522
+ "Failed to parse JSON",
1523
+ );
1524
+ });
1525
+
1526
+ it("handles empty object", () => {
1527
+ const obj = {};
1528
+ const result = safeJsonParse<typeof obj>(obj);
1529
+ expect(result).toBe(obj);
1530
+ });
1531
+
1532
+ it("handles empty string JSON", () => {
1533
+ const result = safeJsonParse<Record<string, never>>("{}");
1534
+ expect(result).toEqual({});
1535
+ });
1536
+ });
1537
+
1538
+ describe("SSO Provider Config Parsing", () => {
1539
+ it("returns parsed SAML config and avoids [object Object] in response", async () => {
1540
+ const data = {
1541
+ user: [] as any[],
1542
+ session: [] as any[],
1543
+ verification: [] as any[],
1544
+ account: [] as any[],
1545
+ ssoProvider: [] as any[],
1546
+ };
1547
+
1548
+ const memory = memoryAdapter(data);
1549
+
1550
+ const auth = betterAuth({
1551
+ database: memory,
1552
+ baseURL: "http://localhost:3000",
1553
+ emailAndPassword: { enabled: true },
1554
+ plugins: [sso()],
1555
+ });
1556
+
1557
+ const authClient = createAuthClient({
1558
+ baseURL: "http://localhost:3000",
1559
+ plugins: [bearer(), ssoClient()],
1560
+ fetchOptions: {
1561
+ customFetchImpl: async (url, init) =>
1562
+ auth.handler(new Request(url, init)),
1563
+ },
1564
+ });
1565
+
1566
+ const headers = new Headers();
1567
+ await authClient.signUp.email({
1568
+ email: "test@example.com",
1569
+ password: "password123",
1570
+ name: "Test User",
1571
+ });
1572
+ await authClient.signIn.email(
1573
+ { email: "test@example.com", password: "password123" },
1574
+ { onSuccess: setCookieToHeader(headers) },
1575
+ );
1576
+
1577
+ const provider = await auth.api.registerSSOProvider({
1578
+ body: {
1579
+ providerId: "saml-config-provider",
1580
+ issuer: "http://localhost:8081",
1581
+ domain: "example.com",
1582
+ samlConfig: {
1583
+ entryPoint: "http://localhost:8081/sso",
1584
+ cert: "test-cert",
1585
+ callbackUrl: "http://localhost:3000/callback",
1586
+ spMetadata: {
1587
+ entityID: "test-entity",
1588
+ },
1589
+ },
1590
+ },
1591
+ headers,
1592
+ });
1593
+
1594
+ expect(provider.samlConfig).toBeDefined();
1595
+ expect(typeof provider.samlConfig).toBe("object");
1596
+ expect(provider.samlConfig?.entryPoint).toBe("http://localhost:8081/sso");
1597
+ expect(provider.samlConfig?.cert).toBe("test-cert");
1598
+
1599
+ const serialized = JSON.stringify(provider.samlConfig);
1600
+ expect(serialized).not.toContain("[object Object]");
1601
+
1602
+ expect(provider.samlConfig?.spMetadata?.entityID).toBe("test-entity");
1603
+ });
1604
+
1605
+ it("returns parsed OIDC config and avoids [object Object] in response", async () => {
1606
+ const data = {
1607
+ user: [] as any[],
1608
+ session: [] as any[],
1609
+ verification: [] as any[],
1610
+ account: [] as any[],
1611
+ ssoProvider: [] as any[],
1612
+ };
1613
+
1614
+ const memory = memoryAdapter(data);
1615
+
1616
+ const auth = betterAuth({
1617
+ database: memory,
1618
+ baseURL: "http://localhost:3000",
1619
+ emailAndPassword: { enabled: true },
1620
+ plugins: [sso()],
1621
+ });
1622
+
1623
+ const authClient = createAuthClient({
1624
+ baseURL: "http://localhost:3000",
1625
+ plugins: [bearer(), ssoClient()],
1626
+ fetchOptions: {
1627
+ customFetchImpl: async (url, init) =>
1628
+ auth.handler(new Request(url, init)),
1629
+ },
1630
+ });
1631
+
1632
+ const headers = new Headers();
1633
+ await authClient.signUp.email({
1634
+ email: "test@example.com",
1635
+ password: "password123",
1636
+ name: "Test User",
1637
+ });
1638
+ await authClient.signIn.email(
1639
+ { email: "test@example.com", password: "password123" },
1640
+ { onSuccess: setCookieToHeader(headers) },
1641
+ );
1642
+
1643
+ const provider = await auth.api.registerSSOProvider({
1644
+ body: {
1645
+ providerId: "oidc-config-provider",
1646
+ issuer: "http://localhost:8080",
1647
+ domain: "example.com",
1648
+ oidcConfig: {
1649
+ clientId: "test-client",
1650
+ clientSecret: "test-secret",
1651
+ discoveryEndpoint:
1652
+ "http://localhost:8080/.well-known/openid-configuration",
1653
+ mapping: {
1654
+ id: "sub",
1655
+ email: "email",
1656
+ name: "name",
1657
+ },
1658
+ },
1659
+ },
1660
+ headers,
1661
+ });
1662
+
1663
+ expect(provider.oidcConfig).toBeDefined();
1664
+ expect(typeof provider.oidcConfig).toBe("object");
1665
+ expect(provider.oidcConfig?.clientId).toBe("test-client");
1666
+ expect(provider.oidcConfig?.clientSecret).toBe("test-secret");
1667
+
1668
+ const serialized = JSON.stringify(provider.oidcConfig);
1669
+ expect(serialized).not.toContain("[object Object]");
1670
+
1671
+ expect(provider.oidcConfig?.mapping?.id).toBe("sub");
1672
+ });
1673
+ });
package/src/utils.ts CHANGED
@@ -1,3 +1,34 @@
1
+ /**
2
+ * Safely parses a value that might be a JSON string or already a parsed object.
3
+ * This handles cases where ORMs like Drizzle might return already parsed objects
4
+ * instead of JSON strings from TEXT/JSON columns.
5
+ *
6
+ * @param value - The value to parse (string, object, null, or undefined)
7
+ * @returns The parsed object or null
8
+ * @throws Error if string parsing fails
9
+ */
10
+ export function safeJsonParse<T>(
11
+ value: string | T | null | undefined,
12
+ ): T | null {
13
+ if (!value) return null;
14
+
15
+ if (typeof value === "object") {
16
+ return value as T;
17
+ }
18
+
19
+ if (typeof value === "string") {
20
+ try {
21
+ return JSON.parse(value) as T;
22
+ } catch (error) {
23
+ throw new Error(
24
+ `Failed to parse JSON: ${error instanceof Error ? error.message : "Unknown error"}`,
25
+ );
26
+ }
27
+ }
28
+
29
+ return null;
30
+ }
31
+
1
32
  export const validateEmailDomain = (email: string, domain: string) => {
2
33
  const emailDomain = email.split("@")[1]?.toLowerCase();
3
34
  const providerDomain = domain.toLowerCase();