@checkstack/auth-backend 0.4.8 → 0.4.10

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/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # @checkstack/auth-backend
2
2
 
3
+ ## 0.4.10
4
+
5
+ ### Patch Changes
6
+
7
+ - eb353a4: Fix TypeError in better-auth initialization when LDAP or SAML strategies are enabled. Non-social strategies are now correctly filtered out from the socialProviders configuration, and standard social providers (GitHub) are correctly initialized using their respective factory functions.
8
+
9
+ ## 0.4.9
10
+
11
+ ### Patch Changes
12
+
13
+ - 0ebbe56: Security Vulnerability Remediation completed:
14
+ - Refactored core authorization to Fail-Closed architecture with secure defaults.
15
+ - Implemented `assertTeamManagementAccess` to resolve BOLA in Teams Management.
16
+ - Protected internal S2S capabilities via explicit wildcard `serviceScope` definitions.
17
+ - Disarmed OS Command Injection in DiskCollector via strict regex validation and bash escaping.
18
+ - Re-architected inline script processing executing scripts in sandboxed Web Worker contexts.
19
+ - Isolated subprocess environment scopes in PingStrategy limiting variable leakage.
20
+ - Enforced strict token/API Key parsing with URLSearchParams checking.
21
+ - Explicitly fail-fast on missing DATABASE_URL configuration across independent backend clusters.
22
+ - Activated strict HTTP Security Headers (HSTS, CSP, X-Frame-Options) across the API automatically.
23
+ - Updated dependencies [0ebbe56]
24
+ - @checkstack/auth-common@0.5.6
25
+ - @checkstack/backend-api@0.8.1
26
+ - @checkstack/common@0.6.3
27
+ - @checkstack/command-backend@0.1.12
28
+ - @checkstack/notification-common@0.2.6
29
+
3
30
  ## 0.4.8
4
31
 
5
32
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/auth-backend",
3
- "version": "0.4.8",
3
+ "version": "0.4.10",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "scripts": {
@@ -12,9 +12,9 @@
12
12
  },
13
13
  "dependencies": {
14
14
  "@checkstack/auth-common": "0.5.5",
15
- "@checkstack/backend-api": "0.7.0",
15
+ "@checkstack/backend-api": "0.8.0",
16
16
  "@checkstack/notification-common": "0.2.5",
17
- "@checkstack/command-backend": "0.1.10",
17
+ "@checkstack/command-backend": "0.1.11",
18
18
  "better-auth": "^1.4.7",
19
19
  "drizzle-orm": "^0.45.1",
20
20
  "hono": "^4.0.0",
@@ -0,0 +1,26 @@
1
+ import { describe, it, expect } from "bun:test";
2
+
3
+ describe("API Key Parsing", () => {
4
+ it("should extract application ID and secret safely", () => {
5
+ // This is essentially simulating the logic added in Fix 8.A
6
+ const token = "ck_123e4567-e89b-12d3-a456-426614174000_sec_test_12345";
7
+ const tokenWithoutPrefix = token.slice(3); // Remove "ck_"
8
+ const separatorIndex = tokenWithoutPrefix.indexOf("_", 36);
9
+
10
+ expect(separatorIndex).not.toBe(-1);
11
+
12
+ const applicationId = tokenWithoutPrefix.slice(0, separatorIndex);
13
+ const secret = tokenWithoutPrefix.slice(separatorIndex + 1);
14
+
15
+ expect(applicationId).toBe("123e4567-e89b-12d3-a456-426614174000");
16
+ expect(secret).toBe("sec_test_12345");
17
+ });
18
+
19
+ it("should reject improperly formatted tokens without throwing", () => {
20
+ const malformedToken = "ck_short_bad";
21
+ const tokenWithoutPrefix = malformedToken.slice(3); // Remove "ck_"
22
+ const separatorIndex = tokenWithoutPrefix.indexOf("_", 36);
23
+
24
+ expect(separatorIndex).toBe(-1);
25
+ });
26
+ });
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { betterAuth } from "better-auth";
2
+ import * as socialProviderFactories from "better-auth/social-providers";
2
3
  import { drizzleAdapter } from "better-auth/adapters/drizzle";
3
4
  import { APIError } from "better-auth/api";
4
5
  import {
@@ -349,10 +350,15 @@ export default createBackendPlugin({
349
350
  // The UUID is parts[1] and potentially includes more parts if UUID has dashes
350
351
  // For a UUID like "abc-def-ghi", after "ck_", we get the rest split by _
351
352
  // Safer approach: find the application ID by parsing
353
+ // Token format: ck_<uuid>_<secret>
354
+ // Parse using the known ck_ prefix and structured delimiter
352
355
  const tokenWithoutPrefix = token.slice(3); // Remove "ck_"
353
- // UUID is 36 chars, secret is 32 chars
354
- const applicationId = tokenWithoutPrefix.slice(0, 36);
355
- const secret = tokenWithoutPrefix.slice(37); // Skip the _ separator
356
+ // Find the last underscore: UUID may contain dashes but not underscores
357
+ // UUID is always 36 chars (8-4-4-4-12 with dashes)
358
+ const separatorIndex = tokenWithoutPrefix.indexOf("_", 36);
359
+ if (separatorIndex === -1) return; // Malformed token
360
+ const applicationId = tokenWithoutPrefix.slice(0, separatorIndex);
361
+ const secret = tokenWithoutPrefix.slice(separatorIndex + 1);
356
362
 
357
363
  if (applicationId && secret) {
358
364
  // Look up application
@@ -420,7 +426,7 @@ export default createBackendPlugin({
420
426
  id: app.id,
421
427
  name: app.name,
422
428
  roles: roleIds,
423
- accessRulesArray,
429
+ accessRules: accessRulesArray,
424
430
  teamIds,
425
431
  };
426
432
  }
@@ -510,10 +516,23 @@ export default createBackendPlugin({
510
516
  strategy.id
511
517
  }: ${Object.keys(strategyConfig || {}).join(", ")}`,
512
518
  );
513
- socialProviders[strategy.id] = strategyConfig;
514
- logger.debug(
515
- `[auth-backend] -> Added ${strategy.id} to socialProviders`,
516
- );
519
+
520
+ const providerFactory = (
521
+ socialProviderFactories as Record<string, unknown>
522
+ )[strategy.id];
523
+
524
+ if (typeof providerFactory === "function") {
525
+ socialProviders[strategy.id] = (
526
+ providerFactory as (options: unknown) => unknown
527
+ )(strategyConfig);
528
+ logger.debug(
529
+ `[auth-backend] -> ✅ Added ${strategy.id} to socialProviders`,
530
+ );
531
+ } else {
532
+ logger.debug(
533
+ `[auth-backend] -> Strategy ${strategy.id} is not a standard social provider, skipping better-auth registration`,
534
+ );
535
+ }
517
536
  }
518
537
 
519
538
  // Check if credential strategy is enabled from meta config
@@ -562,9 +581,15 @@ export default createBackendPlugin({
562
581
  const notificationClient = rpcClient.forPlugin(NotificationApi);
563
582
  const frontendUrl =
564
583
  process.env.BASE_URL || "http://localhost:5173";
565
- const resetUrl = `${frontendUrl}/auth/reset-password?token=${
566
- url.split("token=")[1] ?? ""
567
- }`;
584
+ // SECURITY: Use URL parsing instead of brittle string splitting
585
+ const parsedUrl = new URL(url);
586
+ const resetToken = parsedUrl.searchParams.get("token");
587
+ if (!resetToken) {
588
+ throw new APIError("BAD_REQUEST", {
589
+ message: "Malformed password reset URL: missing token parameter",
590
+ });
591
+ }
592
+ const resetUrl = `${frontendUrl}/auth/reset-password?token=${encodeURIComponent(resetToken)}`;
568
593
 
569
594
  void notificationClient.sendTransactional({
570
595
  userId: user.id,
package/src/router.ts CHANGED
@@ -8,7 +8,8 @@ import {
8
8
  type ConfigService,
9
9
  toJsonSchema,
10
10
  } from "@checkstack/backend-api";
11
- import { authContract, passwordSchema } from "@checkstack/auth-common";
11
+ import { authContract, passwordSchema, authAccess, pluginMetadata } from "@checkstack/auth-common";
12
+ import { qualifyAccessRuleId } from "@checkstack/common";
12
13
  import { hashPassword } from "better-auth/crypto";
13
14
  import * as schema from "./schema";
14
15
  import { eq, inArray, and } from "drizzle-orm";
@@ -117,6 +118,45 @@ function generateSecret(): string {
117
118
  return Array.from(array, (byte) => chars[byte % chars.length]).join("");
118
119
  }
119
120
 
121
+ async function assertTeamManagementAccess({
122
+ user,
123
+ teamId,
124
+ internalDb,
125
+ }: {
126
+ user: AuthUser | undefined;
127
+ teamId: string;
128
+ internalDb: SafeDatabase<typeof schema>;
129
+ }): Promise<void> {
130
+ // Services are trusted via middleware
131
+ if (user?.type === "service") return;
132
+
133
+ const hasGlobalManage =
134
+ user?.accessRules?.includes("*") ||
135
+ user?.accessRules?.includes(qualifyAccessRuleId(pluginMetadata, authAccess.teams.manage));
136
+
137
+ if (hasGlobalManage) return; // Global manage allows all teams
138
+
139
+ // Check if user is a manager of this specific team
140
+ if (isRealUser(user)) {
141
+ const [managerRecord] = await internalDb
142
+ .select()
143
+ .from(schema.teamManager)
144
+ .where(
145
+ and(
146
+ eq(schema.teamManager.teamId, teamId),
147
+ eq(schema.teamManager.userId, user.id),
148
+ ),
149
+ )
150
+ .limit(1);
151
+
152
+ if (managerRecord) return; // Is team manager
153
+ }
154
+
155
+ throw new ORPCError("FORBIDDEN", {
156
+ message: "You do not have permission to manage this team",
157
+ });
158
+ }
159
+
120
160
  export const createAuthRouter = (
121
161
  internalDb: SafeDatabase<typeof schema>,
122
162
  strategyRegistry: { getStrategies: () => AuthStrategy<unknown>[] },
@@ -1385,7 +1425,13 @@ export const createAuthRouter = (
1385
1425
 
1386
1426
  const updateTeam = os.updateTeam.handler(async ({ input, context }) => {
1387
1427
  const { id, name, description } = input;
1388
- // TODO: Check if user is manager or has teamsManage access
1428
+
1429
+ await assertTeamManagementAccess({
1430
+ user: context.user,
1431
+ teamId: id,
1432
+ internalDb,
1433
+ });
1434
+
1389
1435
  const updates: {
1390
1436
  name?: string;
1391
1437
  description?: string | null;
@@ -1403,6 +1449,11 @@ export const createAuthRouter = (
1403
1449
  });
1404
1450
 
1405
1451
  const deleteTeam = os.deleteTeam.handler(async ({ input: id, context }) => {
1452
+ await assertTeamManagementAccess({
1453
+ user: context.user,
1454
+ teamId: id,
1455
+ internalDb,
1456
+ });
1406
1457
  await internalDb.transaction(async (tx) => {
1407
1458
  await tx.delete(schema.userTeam).where(eq(schema.userTeam.teamId, id));
1408
1459
  await tx
@@ -1419,7 +1470,12 @@ export const createAuthRouter = (
1419
1470
  context.logger.info(`[auth-backend] Deleted team: ${id}`);
1420
1471
  });
1421
1472
 
1422
- const addUserToTeam = os.addUserToTeam.handler(async ({ input }) => {
1473
+ const addUserToTeam = os.addUserToTeam.handler(async ({ input, context }) => {
1474
+ await assertTeamManagementAccess({
1475
+ user: context.user,
1476
+ teamId: input.teamId,
1477
+ internalDb,
1478
+ });
1423
1479
  await internalDb
1424
1480
  .insert(schema.userTeam)
1425
1481
  .values({ userId: input.userId, teamId: input.teamId })
@@ -1427,7 +1483,12 @@ export const createAuthRouter = (
1427
1483
  });
1428
1484
 
1429
1485
  const removeUserFromTeam = os.removeUserFromTeam.handler(
1430
- async ({ input }) => {
1486
+ async ({ input, context }) => {
1487
+ await assertTeamManagementAccess({
1488
+ user: context.user,
1489
+ teamId: input.teamId,
1490
+ internalDb,
1491
+ });
1431
1492
  await internalDb
1432
1493
  .delete(schema.userTeam)
1433
1494
  .where(
@@ -1439,14 +1500,24 @@ export const createAuthRouter = (
1439
1500
  },
1440
1501
  );
1441
1502
 
1442
- const addTeamManager = os.addTeamManager.handler(async ({ input }) => {
1503
+ const addTeamManager = os.addTeamManager.handler(async ({ input, context }) => {
1504
+ await assertTeamManagementAccess({
1505
+ user: context.user,
1506
+ teamId: input.teamId,
1507
+ internalDb,
1508
+ });
1443
1509
  await internalDb
1444
1510
  .insert(schema.teamManager)
1445
1511
  .values({ userId: input.userId, teamId: input.teamId })
1446
1512
  .onConflictDoNothing();
1447
1513
  });
1448
1514
 
1449
- const removeTeamManager = os.removeTeamManager.handler(async ({ input }) => {
1515
+ const removeTeamManager = os.removeTeamManager.handler(async ({ input, context }) => {
1516
+ await assertTeamManagementAccess({
1517
+ user: context.user,
1518
+ teamId: input.teamId,
1519
+ internalDb,
1520
+ });
1450
1521
  await internalDb
1451
1522
  .delete(schema.teamManager)
1452
1523
  .where(
@@ -1577,7 +1648,12 @@ export const createAuthRouter = (
1577
1648
  );
1578
1649
 
1579
1650
  // No grants = global access applies
1580
- if (grants.length === 0) return { hasAccess: hasGlobalAccess };
1651
+ if (grants.length === 0) {
1652
+ // SECURITY NOTE: No team grants configured for this resource.
1653
+ // Defaulting to global access check. If this resource should be restricted,
1654
+ // configure team grants or enable 'teamOnly' on the resource access settings.
1655
+ return { hasAccess: hasGlobalAccess };
1656
+ }
1581
1657
 
1582
1658
  // Check resource-level settings for teamOnly
1583
1659
  const settingsRows = await internalDb
@@ -1685,7 +1761,10 @@ export const createAuthRouter = (
1685
1761
 
1686
1762
  return resourceIds.filter((id) => {
1687
1763
  const resourceGrants = grantsByResource.get(id) || [];
1688
- if (resourceGrants.length === 0) return hasGlobalAccess;
1764
+ if (resourceGrants.length === 0) {
1765
+ // No team grants configured — fall through to global access
1766
+ return hasGlobalAccess;
1767
+ }
1689
1768
  const isTeamOnly = teamOnlyByResource.get(id) ?? false;
1690
1769
  if (!isTeamOnly && hasGlobalAccess) return true;
1691
1770
  return resourceGrants.some(