@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 +27 -0
- package/package.json +3 -3
- package/src/api-key-parsing.test.ts +26 -0
- package/src/index.ts +36 -11
- package/src/router.ts +87 -8
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.
|
|
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.
|
|
15
|
+
"@checkstack/backend-api": "0.8.0",
|
|
16
16
|
"@checkstack/notification-common": "0.2.5",
|
|
17
|
-
"@checkstack/command-backend": "0.1.
|
|
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
|
|
354
|
-
|
|
355
|
-
const
|
|
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
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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
|
-
|
|
566
|
-
|
|
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
|
-
|
|
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)
|
|
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)
|
|
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(
|