@checkstack/auth-backend 0.4.33 → 0.5.0
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 +92 -0
- package/drizzle/0005_ambitious_oracle.sql +49 -0
- package/drizzle/meta/0005_snapshot.json +1349 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +9 -7
- package/src/dcr-ratelimit.it.test.ts +133 -0
- package/src/index.ts +182 -43
- package/src/mcp-oauth-config.ts +53 -0
- package/src/migration-chain-contract.test.ts +108 -0
- package/src/oauth-branch.test.ts +79 -0
- package/src/oauth-branch.ts +99 -0
- package/src/rate-limit.test.ts +34 -0
- package/src/rate-limit.ts +73 -0
- package/src/router.ts +102 -0
- package/src/schema.ts +72 -0
- package/src/scope-narrowing.test.ts +157 -0
- package/src/scope-narrowing.ts +113 -0
- package/src/utils/user.ts +65 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/auth-backend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"license": "Elastic-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -16,24 +16,26 @@
|
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
18
|
"@checkstack/auth-common": "0.7.2",
|
|
19
|
-
"@checkstack/backend-api": "0.
|
|
19
|
+
"@checkstack/backend-api": "0.20.0",
|
|
20
20
|
"@checkstack/notification-common": "1.2.1",
|
|
21
|
-
"@checkstack/command-backend": "0.1.
|
|
22
|
-
"better-auth": "^1.
|
|
21
|
+
"@checkstack/command-backend": "0.1.33",
|
|
22
|
+
"better-auth": "^1.6.13",
|
|
23
23
|
"drizzle-orm": "^0.45.0",
|
|
24
|
-
"hono": "^4.12.
|
|
24
|
+
"hono": "^4.12.23",
|
|
25
25
|
"kysely": "^0.28.17",
|
|
26
|
-
"jose": "^6.
|
|
26
|
+
"jose": "^6.2.3",
|
|
27
27
|
"zod": "^4.2.1",
|
|
28
28
|
"@checkstack/common": "0.12.0",
|
|
29
|
-
"@orpc/server": "^1.
|
|
29
|
+
"@orpc/server": "^1.14.4"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
32
|
"@checkstack/drizzle-helper": "0.0.5",
|
|
33
33
|
"@checkstack/scripts": "0.3.4",
|
|
34
34
|
"@checkstack/tsconfig": "0.0.7",
|
|
35
35
|
"@types/node": "^20.0.0",
|
|
36
|
+
"@types/pg": "^8.20.0",
|
|
36
37
|
"drizzle-kit": "^0.31.10",
|
|
38
|
+
"pg": "^8.21.0",
|
|
37
39
|
"typescript": "^5.0.0"
|
|
38
40
|
}
|
|
39
41
|
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DCR rate-limit shared-Postgres conformance (matrix #10).
|
|
3
|
+
*
|
|
4
|
+
* The DCR endpoint throttle MUST hold across pods, because an in-memory per-pod
|
|
5
|
+
* limiter would let N pods each allow the cap = N x the intended limit. This
|
|
6
|
+
* test simulates TWO pods (two independent pools to the SAME schema) hammering
|
|
7
|
+
* the same key and asserts the combined count respects the single shared cap.
|
|
8
|
+
*
|
|
9
|
+
* Gated behind `CHECKSTACK_IT=1`; connection from `CHECKSTACK_IT_PG_URL`. Runs
|
|
10
|
+
* in a freshly created, self-cleaning schema.
|
|
11
|
+
*/
|
|
12
|
+
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
|
13
|
+
import { drizzle } from "drizzle-orm/node-postgres";
|
|
14
|
+
import { sql } from "drizzle-orm";
|
|
15
|
+
import { Pool } from "pg";
|
|
16
|
+
import type { SafeDatabase } from "@checkstack/backend-api";
|
|
17
|
+
import * as schema from "./schema";
|
|
18
|
+
import { checkRateLimit } from "./rate-limit";
|
|
19
|
+
|
|
20
|
+
const PG_URL =
|
|
21
|
+
process.env.CHECKSTACK_IT_PG_URL ??
|
|
22
|
+
"postgres://postgres:postgres@localhost:5432/postgres";
|
|
23
|
+
|
|
24
|
+
const SCHEMA = `it_dcr_ratelimit_${crypto.randomUUID().replace(/-/g, "")}`;
|
|
25
|
+
|
|
26
|
+
interface Pod {
|
|
27
|
+
pool: Pool;
|
|
28
|
+
db: SafeDatabase<typeof schema>;
|
|
29
|
+
end(): Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function makePod(): Pod {
|
|
33
|
+
const pool = new Pool({
|
|
34
|
+
connectionString: PG_URL,
|
|
35
|
+
options: `-c search_path=${SCHEMA}`,
|
|
36
|
+
});
|
|
37
|
+
const db = drizzle(pool, { schema }) as unknown as SafeDatabase<typeof schema>;
|
|
38
|
+
return { pool, db, end: () => pool.end() };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe.skipIf(!process.env.CHECKSTACK_IT)("DCR rate-limit (shared Postgres)", () => {
|
|
42
|
+
let admin: Pool;
|
|
43
|
+
let podA: Pod;
|
|
44
|
+
let podB: Pod;
|
|
45
|
+
|
|
46
|
+
beforeAll(async () => {
|
|
47
|
+
admin = new Pool({ connectionString: PG_URL });
|
|
48
|
+
await admin.query(`CREATE SCHEMA "${SCHEMA}"`);
|
|
49
|
+
await admin.query(
|
|
50
|
+
`CREATE TABLE "${SCHEMA}".ai_rate_limit (
|
|
51
|
+
key text NOT NULL,
|
|
52
|
+
window_start timestamp NOT NULL,
|
|
53
|
+
count integer NOT NULL DEFAULT 0,
|
|
54
|
+
PRIMARY KEY (key, window_start)
|
|
55
|
+
)`,
|
|
56
|
+
);
|
|
57
|
+
podA = makePod();
|
|
58
|
+
podB = makePod();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
afterAll(async () => {
|
|
62
|
+
await podA?.end();
|
|
63
|
+
await podB?.end();
|
|
64
|
+
await admin.query(`DROP SCHEMA IF EXISTS "${SCHEMA}" CASCADE`);
|
|
65
|
+
await admin.end();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("enforces ONE shared cap across two pods (no N x cap leak)", async () => {
|
|
69
|
+
const key = `dcr:203.0.113.5`;
|
|
70
|
+
const max = 5;
|
|
71
|
+
const windowSeconds = 3600;
|
|
72
|
+
const now = new Date("2026-06-02T12:00:00.000Z");
|
|
73
|
+
|
|
74
|
+
// Alternate pods, sharing the same window. After `max` calls the limit is
|
|
75
|
+
// hit regardless of WHICH pod served the call.
|
|
76
|
+
const results = [];
|
|
77
|
+
for (let i = 0; i < 8; i++) {
|
|
78
|
+
const pod = i % 2 === 0 ? podA : podB;
|
|
79
|
+
results.push(
|
|
80
|
+
await checkRateLimit({ db: pod.db, key, max, windowSeconds, now }),
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const allowedCount = results.filter((r) => r.allowed).length;
|
|
85
|
+
// Exactly `max` allowed across BOTH pods — not `max` per pod.
|
|
86
|
+
expect(allowedCount).toBe(max);
|
|
87
|
+
expect(results[results.length - 1].allowed).toBe(false);
|
|
88
|
+
expect(results[results.length - 1].count).toBe(8);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("resets in the next window", async () => {
|
|
92
|
+
const key = `dcr:198.51.100.9`;
|
|
93
|
+
const first = await checkRateLimit({
|
|
94
|
+
db: podA.db,
|
|
95
|
+
key,
|
|
96
|
+
max: 1,
|
|
97
|
+
windowSeconds: 60,
|
|
98
|
+
now: new Date("2026-06-02T12:00:30.000Z"),
|
|
99
|
+
});
|
|
100
|
+
expect(first.allowed).toBe(true);
|
|
101
|
+
const sameWindow = await checkRateLimit({
|
|
102
|
+
db: podB.db,
|
|
103
|
+
key,
|
|
104
|
+
max: 1,
|
|
105
|
+
windowSeconds: 60,
|
|
106
|
+
now: new Date("2026-06-02T12:00:45.000Z"),
|
|
107
|
+
});
|
|
108
|
+
expect(sameWindow.allowed).toBe(false);
|
|
109
|
+
const nextWindow = await checkRateLimit({
|
|
110
|
+
db: podA.db,
|
|
111
|
+
key,
|
|
112
|
+
max: 1,
|
|
113
|
+
windowSeconds: 60,
|
|
114
|
+
now: new Date("2026-06-02T12:01:05.000Z"),
|
|
115
|
+
});
|
|
116
|
+
expect(nextWindow.allowed).toBe(true); // fresh window, counter reset
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("verifies the counter is visible from a second pod (durable, not pod-local)", async () => {
|
|
120
|
+
const key = `dcr:192.0.2.1`;
|
|
121
|
+
const now = new Date("2026-06-02T13:00:00.000Z");
|
|
122
|
+
await checkRateLimit({ db: podA.db, key, max: 10, windowSeconds: 3600, now });
|
|
123
|
+
// Pod B reads the SAME counter row written by pod A.
|
|
124
|
+
const windowStart = new Date("2026-06-02T13:00:00.000Z");
|
|
125
|
+
const rows = await podB.db
|
|
126
|
+
.select({ count: schema.aiRateLimit.count })
|
|
127
|
+
.from(schema.aiRateLimit)
|
|
128
|
+
.where(
|
|
129
|
+
sql`${schema.aiRateLimit.key} = ${key} AND ${schema.aiRateLimit.windowStart} = ${windowStart}`,
|
|
130
|
+
);
|
|
131
|
+
expect(rows[0]?.count).toBe(1);
|
|
132
|
+
});
|
|
133
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -3,12 +3,14 @@ import * as socialProviderFactories from "better-auth/social-providers";
|
|
|
3
3
|
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
|
4
4
|
import { APIError, createAuthEndpoint } from "better-auth/api";
|
|
5
5
|
import { setSessionCookie } from "better-auth/cookies";
|
|
6
|
+
import { mcp } from "better-auth/plugins";
|
|
6
7
|
import { z } from "zod";
|
|
7
8
|
import {
|
|
8
9
|
createBackendPlugin,
|
|
9
10
|
coreServices,
|
|
10
11
|
coreHooks,
|
|
11
12
|
authenticationStrategyServiceRef,
|
|
13
|
+
assertMigrationChainFromV1,
|
|
12
14
|
type AuthStrategy,
|
|
13
15
|
} from "@checkstack/backend-api";
|
|
14
16
|
import {
|
|
@@ -20,12 +22,12 @@ import {
|
|
|
20
22
|
} from "@checkstack/auth-common";
|
|
21
23
|
import { NotificationApi } from "@checkstack/notification-common";
|
|
22
24
|
import * as schema from "./schema";
|
|
23
|
-
import { eq
|
|
25
|
+
import { eq } from "drizzle-orm";
|
|
24
26
|
import { SafeDatabase } from "@checkstack/backend-api";
|
|
25
27
|
import { BetterAuthOptions, User } from "better-auth/types";
|
|
26
28
|
import { verifyPassword } from "better-auth/crypto";
|
|
27
29
|
import { createExtensionPoint } from "@checkstack/backend-api";
|
|
28
|
-
import { enrichUser } from "./utils/user";
|
|
30
|
+
import { enrichUser, enrichApplicationPrincipal } from "./utils/user";
|
|
29
31
|
import { ADMIN_ROLE_ID, createAuthRouter } from "./router";
|
|
30
32
|
import { validateStrategySchema } from "./utils/validate-schema";
|
|
31
33
|
import {
|
|
@@ -37,9 +39,29 @@ import {
|
|
|
37
39
|
PLATFORM_REGISTRATION_CONFIG_VERSION,
|
|
38
40
|
PLATFORM_REGISTRATION_CONFIG_ID,
|
|
39
41
|
} from "./platform-registration-config";
|
|
42
|
+
import {
|
|
43
|
+
mcpOAuthConfigV1,
|
|
44
|
+
MCP_OAUTH_CONFIG_VERSION,
|
|
45
|
+
MCP_OAUTH_CONFIG_ID,
|
|
46
|
+
} from "./mcp-oauth-config";
|
|
47
|
+
import {
|
|
48
|
+
narrowedPrincipalFromSession,
|
|
49
|
+
introspectOpaqueToken,
|
|
50
|
+
opaqueBearerToken,
|
|
51
|
+
} from "./oauth-branch";
|
|
52
|
+
import { checkRateLimit } from "./rate-limit";
|
|
40
53
|
import { registerSearchProvider } from "@checkstack/command-backend";
|
|
41
54
|
import { resolveRoute, extractErrorMessage} from "@checkstack/common";
|
|
42
55
|
|
|
56
|
+
/** Best-effort client IP for the DCR rate-limit key (proxy headers first). */
|
|
57
|
+
function clientIpOf(req: Request): string {
|
|
58
|
+
return (
|
|
59
|
+
req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
|
60
|
+
req.headers.get("x-real-ip") ||
|
|
61
|
+
"unknown"
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
43
65
|
export interface BetterAuthExtensionPoint {
|
|
44
66
|
addStrategy(strategy: AuthStrategy<unknown>): void;
|
|
45
67
|
}
|
|
@@ -316,6 +338,19 @@ export default createBackendPlugin({
|
|
|
316
338
|
// Validate that the strategy schema doesn't have required fields without defaults
|
|
317
339
|
try {
|
|
318
340
|
validateStrategySchema(s.configSchema, s.id);
|
|
341
|
+
// Fail fast at registration (boot) if the strategy's
|
|
342
|
+
// v1->configVersion migration chain is incomplete or broken.
|
|
343
|
+
// Auth's read path migrates-then-validates via
|
|
344
|
+
// `configService.get(id, schema, configVersion, migrations)`, so a
|
|
345
|
+
// missing covering migration would otherwise only surface LAZILY
|
|
346
|
+
// on the first stale read. This guard surfaces it at boot for
|
|
347
|
+
// every registered strategy exactly once — `addStrategy` is the
|
|
348
|
+
// single canonical registration chokepoint (all read sites in
|
|
349
|
+
// index.ts/router.ts only consume the already-registered list).
|
|
350
|
+
assertMigrationChainFromV1({
|
|
351
|
+
version: s.configVersion,
|
|
352
|
+
migrations: s.migrations ?? [],
|
|
353
|
+
});
|
|
319
354
|
} catch (error) {
|
|
320
355
|
const message =
|
|
321
356
|
extractErrorMessage(error);
|
|
@@ -388,48 +423,22 @@ export default createBackendPlugin({
|
|
|
388
423
|
// Ignore errors from lastUsedAt update
|
|
389
424
|
});
|
|
390
425
|
|
|
391
|
-
//
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
const roleIds = appRoles.map((r) => r.roleId);
|
|
400
|
-
|
|
401
|
-
// Get access rules for these roles
|
|
402
|
-
let accessRulesArray: string[] = [];
|
|
403
|
-
if (roleIds.length > 0) {
|
|
404
|
-
const rolePerms = await db
|
|
405
|
-
.select({
|
|
406
|
-
accessRuleId: schema.roleAccessRule.accessRuleId,
|
|
407
|
-
})
|
|
408
|
-
.from(schema.roleAccessRule)
|
|
409
|
-
.where(inArray(schema.roleAccessRule.roleId, roleIds));
|
|
410
|
-
|
|
411
|
-
accessRulesArray = [
|
|
412
|
-
...new Set(rolePerms.map((rp) => rp.accessRuleId)),
|
|
413
|
-
];
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
// Get team memberships for this application
|
|
417
|
-
const appTeams = await db
|
|
418
|
-
.select({ teamId: schema.applicationTeam.teamId })
|
|
419
|
-
.from(schema.applicationTeam)
|
|
420
|
-
.where(
|
|
421
|
-
eq(schema.applicationTeam.applicationId, applicationId),
|
|
422
|
-
);
|
|
423
|
-
const teamIds = appTeams.map((t) => t.teamId);
|
|
426
|
+
// Resolve roles, access rules, and teams via the shared
|
|
427
|
+
// helper (same path as the app-principal token branch).
|
|
428
|
+
const enriched = await enrichApplicationPrincipal(
|
|
429
|
+
applicationId,
|
|
430
|
+
db,
|
|
431
|
+
);
|
|
432
|
+
if (!enriched) return;
|
|
424
433
|
|
|
425
434
|
// Return ApplicationUser
|
|
426
435
|
return {
|
|
427
436
|
type: "application" as const,
|
|
428
|
-
id:
|
|
429
|
-
name:
|
|
430
|
-
roles:
|
|
431
|
-
accessRules:
|
|
432
|
-
teamIds,
|
|
437
|
+
id: enriched.id,
|
|
438
|
+
name: enriched.name,
|
|
439
|
+
roles: enriched.roles,
|
|
440
|
+
accessRules: enriched.accessRules,
|
|
441
|
+
teamIds: enriched.teamIds,
|
|
433
442
|
};
|
|
434
443
|
}
|
|
435
444
|
}
|
|
@@ -438,6 +447,40 @@ export default createBackendPlugin({
|
|
|
438
447
|
return; // Invalid API key
|
|
439
448
|
}
|
|
440
449
|
|
|
450
|
+
// Bearer OAuth-access-token branch (AI platform MCP / OAuth AS).
|
|
451
|
+
//
|
|
452
|
+
// Tokens are OPAQUE (decision §11): introspect the token against the
|
|
453
|
+
// oidcProvider-owned token table, then build a principal whose access
|
|
454
|
+
// rules are the token's GRANTED scopes intersected with the bound
|
|
455
|
+
// user's LIVE access rules. Narrow-only, re-evaluated live every call.
|
|
456
|
+
// autoAuthMiddleware remains the single enforcement point; this branch
|
|
457
|
+
// only PRODUCES the narrowed principal. A miss falls through to session.
|
|
458
|
+
const opaqueToken = opaqueBearerToken(request);
|
|
459
|
+
if (opaqueToken) {
|
|
460
|
+
const session = await introspectOpaqueToken({
|
|
461
|
+
db,
|
|
462
|
+
token: opaqueToken,
|
|
463
|
+
});
|
|
464
|
+
if (session) {
|
|
465
|
+
const userRow = await db
|
|
466
|
+
.select()
|
|
467
|
+
.from(schema.user)
|
|
468
|
+
.where(eq(schema.user.id, session.userId))
|
|
469
|
+
.limit(1);
|
|
470
|
+
if (userRow.length > 0) {
|
|
471
|
+
const base = await enrichUser(userRow[0], db);
|
|
472
|
+
const catalogRows = await db
|
|
473
|
+
.select({ id: schema.accessRule.id })
|
|
474
|
+
.from(schema.accessRule);
|
|
475
|
+
const narrowed = narrowedPrincipalFromSession({
|
|
476
|
+
session,
|
|
477
|
+
principal: { base, catalog: catalogRows.map((r) => r.id) },
|
|
478
|
+
});
|
|
479
|
+
if (narrowed) return narrowed;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
441
484
|
// Fall back to session-based authentication (better-auth)
|
|
442
485
|
if (!auth) {
|
|
443
486
|
return; // Not initialized yet
|
|
@@ -628,6 +671,39 @@ export default createBackendPlugin({
|
|
|
628
671
|
const registrationAllowed =
|
|
629
672
|
platformRegistrationConfig?.allowRegistration ?? true;
|
|
630
673
|
|
|
674
|
+
// AI platform OAuth AS + MCP server settings (off by default).
|
|
675
|
+
const mcpOAuthConfig = await config.get(
|
|
676
|
+
MCP_OAUTH_CONFIG_ID,
|
|
677
|
+
mcpOAuthConfigV1,
|
|
678
|
+
MCP_OAUTH_CONFIG_VERSION,
|
|
679
|
+
);
|
|
680
|
+
const mcpEnabled = mcpOAuthConfig?.enabled ?? false;
|
|
681
|
+
|
|
682
|
+
// The OAuth AS + MCP plugin. Enabled only when an operator opts in.
|
|
683
|
+
//
|
|
684
|
+
// The `mcp` plugin internally instantiates `oidcProvider` from its
|
|
685
|
+
// `oidcConfig`, so we add ONLY `mcp` here (adding `oidcProvider`
|
|
686
|
+
// separately would double-register its endpoints). oidcProvider
|
|
687
|
+
// issues OPAQUE access tokens and owns the token / client / consent
|
|
688
|
+
// tables (added to the Drizzle schema). The DCR endpoint
|
|
689
|
+
// (`/mcp/register`) is gated by `allowDynamicClientRegistration`; the
|
|
690
|
+
// per-IP DCR rate-limit is a separate shared-Postgres counter
|
|
691
|
+
// enforced in the API route handler below.
|
|
692
|
+
const aiOAuthPlugins = mcpEnabled
|
|
693
|
+
? [
|
|
694
|
+
mcp({
|
|
695
|
+
loginPage: "/auth/login",
|
|
696
|
+
resource: `${baseUrl}/api/ai/mcp`,
|
|
697
|
+
oidcConfig: {
|
|
698
|
+
loginPage: "/auth/login",
|
|
699
|
+
consentPage: "/auth/oauth-consent",
|
|
700
|
+
allowDynamicClientRegistration:
|
|
701
|
+
mcpOAuthConfig?.allowDynamicClientRegistration ?? false,
|
|
702
|
+
},
|
|
703
|
+
}),
|
|
704
|
+
]
|
|
705
|
+
: [];
|
|
706
|
+
|
|
631
707
|
logger.debug(
|
|
632
708
|
`[auth-backend] Initializing Better Auth with ${
|
|
633
709
|
Object.keys(socialProviders).length
|
|
@@ -716,7 +792,7 @@ export default createBackendPlugin({
|
|
|
716
792
|
},
|
|
717
793
|
},
|
|
718
794
|
},
|
|
719
|
-
plugins: [checkstackBridge],
|
|
795
|
+
plugins: [checkstackBridge, ...aiOAuthPlugins],
|
|
720
796
|
};
|
|
721
797
|
|
|
722
798
|
return betterAuth(authOptions);
|
|
@@ -808,8 +884,44 @@ export default createBackendPlugin({
|
|
|
808
884
|
);
|
|
809
885
|
rpc.registerRouter(authRouter, authContract);
|
|
810
886
|
|
|
811
|
-
// 5. Register Better Auth native handler
|
|
812
|
-
|
|
887
|
+
// 5. Register Better Auth native handler.
|
|
888
|
+
//
|
|
889
|
+
// The Dynamic Client Registration endpoint (`/api/auth/mcp/register`)
|
|
890
|
+
// is throttled per client IP by a SHARED-POSTGRES fixed-window counter
|
|
891
|
+
// (state-and-scale §14.5 — never in-memory, so the cap holds across all
|
|
892
|
+
// pods) BEFORE delegating to better-auth. Every other auth route passes
|
|
893
|
+
// straight through.
|
|
894
|
+
rpc.registerHttpHandler(async (req: Request) => {
|
|
895
|
+
const url = new URL(req.url);
|
|
896
|
+
if (
|
|
897
|
+
req.method === "POST" &&
|
|
898
|
+
url.pathname.endsWith("/mcp/register")
|
|
899
|
+
) {
|
|
900
|
+
const cfg = await config.get(
|
|
901
|
+
MCP_OAUTH_CONFIG_ID,
|
|
902
|
+
mcpOAuthConfigV1,
|
|
903
|
+
MCP_OAUTH_CONFIG_VERSION,
|
|
904
|
+
);
|
|
905
|
+
const ip = clientIpOf(req);
|
|
906
|
+
const result = await checkRateLimit({
|
|
907
|
+
db: database as SafeDatabase<typeof schema>,
|
|
908
|
+
key: `dcr:${ip}`,
|
|
909
|
+
max: cfg?.dcrRateLimitMax ?? 5,
|
|
910
|
+
windowSeconds: cfg?.dcrRateLimitWindowSeconds ?? 3600,
|
|
911
|
+
});
|
|
912
|
+
if (!result.allowed) {
|
|
913
|
+
return Response.json(
|
|
914
|
+
{
|
|
915
|
+
error: "rate_limit_exceeded",
|
|
916
|
+
error_description:
|
|
917
|
+
"Too many client registrations from this IP. Try again later.",
|
|
918
|
+
},
|
|
919
|
+
{ status: 429 },
|
|
920
|
+
);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
return auth!.handler(req);
|
|
924
|
+
});
|
|
813
925
|
|
|
814
926
|
// All auth management endpoints are now via oRPC (see ./router.ts)
|
|
815
927
|
// Note: Admin user seeding removed - handled via onboarding flow
|
|
@@ -943,3 +1055,30 @@ export * from "./utils/auth-error-redirect";
|
|
|
943
1055
|
|
|
944
1056
|
// Re-export hooks for cross-plugin communication
|
|
945
1057
|
export { authHooks } from "./hooks";
|
|
1058
|
+
|
|
1059
|
+
// AI platform OAuth AS surface: scope narrowing, the introspect-time branch,
|
|
1060
|
+
// and the shared-Postgres rate limiter (reused/tested by ai-backend + docs).
|
|
1061
|
+
export {
|
|
1062
|
+
narrowScopes,
|
|
1063
|
+
expandBundles,
|
|
1064
|
+
SCOPE_BUNDLE,
|
|
1065
|
+
type ScopeBundle,
|
|
1066
|
+
} from "./scope-narrowing";
|
|
1067
|
+
export {
|
|
1068
|
+
narrowedPrincipalFromSession,
|
|
1069
|
+
introspectOpaqueToken,
|
|
1070
|
+
opaqueBearerToken,
|
|
1071
|
+
type IntrospectedOAuthSession,
|
|
1072
|
+
type LivePrincipal,
|
|
1073
|
+
} from "./oauth-branch";
|
|
1074
|
+
export {
|
|
1075
|
+
checkRateLimit,
|
|
1076
|
+
windowStartFor,
|
|
1077
|
+
type RateLimitResult,
|
|
1078
|
+
} from "./rate-limit";
|
|
1079
|
+
export {
|
|
1080
|
+
mcpOAuthConfigV1,
|
|
1081
|
+
MCP_OAUTH_CONFIG_ID,
|
|
1082
|
+
MCP_OAUTH_CONFIG_VERSION,
|
|
1083
|
+
type McpOAuthConfig,
|
|
1084
|
+
} from "./mcp-oauth-config";
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Platform-level configuration for the AI platform OAuth Authorization Server
|
|
5
|
+
* and MCP server (Phase 2). Controls Dynamic Client Registration (DCR) and the
|
|
6
|
+
* per-IP DCR rate-limit. Token/consent/client state itself is owned by the
|
|
7
|
+
* better-auth `oidcProvider` tables; this is only the operator-facing toggle.
|
|
8
|
+
*/
|
|
9
|
+
export const mcpOAuthConfigV1 = z.object({
|
|
10
|
+
/**
|
|
11
|
+
* Whether the OAuth Authorization Server + MCP server are enabled at all.
|
|
12
|
+
* Off by default so the AS surface only exists once an operator opts in.
|
|
13
|
+
*/
|
|
14
|
+
enabled: z
|
|
15
|
+
.boolean()
|
|
16
|
+
.default(false)
|
|
17
|
+
.describe(
|
|
18
|
+
"When enabled, Checkstack acts as an OAuth Authorization Server and serves the MCP endpoint.",
|
|
19
|
+
),
|
|
20
|
+
/**
|
|
21
|
+
* Whether Dynamic Client Registration (`POST /mcp/register`) is open. When
|
|
22
|
+
* false, MCP clients must be registered out-of-band by an admin.
|
|
23
|
+
*/
|
|
24
|
+
allowDynamicClientRegistration: z
|
|
25
|
+
.boolean()
|
|
26
|
+
.default(false)
|
|
27
|
+
.describe(
|
|
28
|
+
"When enabled, MCP clients may self-register via Dynamic Client Registration.",
|
|
29
|
+
),
|
|
30
|
+
/**
|
|
31
|
+
* Max DCR registrations allowed per client IP within the fixed window.
|
|
32
|
+
* Enforced by a shared-Postgres counter (never in-memory) so the cap holds
|
|
33
|
+
* across all pods.
|
|
34
|
+
*/
|
|
35
|
+
dcrRateLimitMax: z
|
|
36
|
+
.number()
|
|
37
|
+
.int()
|
|
38
|
+
.positive()
|
|
39
|
+
.default(5)
|
|
40
|
+
.describe("Maximum DCR registrations per IP per window."),
|
|
41
|
+
/** DCR rate-limit window length, in seconds. */
|
|
42
|
+
dcrRateLimitWindowSeconds: z
|
|
43
|
+
.number()
|
|
44
|
+
.int()
|
|
45
|
+
.positive()
|
|
46
|
+
.default(3600)
|
|
47
|
+
.describe("DCR rate-limit fixed-window length in seconds."),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
export type McpOAuthConfig = z.infer<typeof mcpOAuthConfigV1>;
|
|
51
|
+
|
|
52
|
+
export const MCP_OAUTH_CONFIG_VERSION = 1;
|
|
53
|
+
export const MCP_OAUTH_CONFIG_ID = "ai.mcp-oauth";
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract test: every auth strategy registered via the
|
|
3
|
+
* `betterAuthExtensionPoint.addStrategy` chokepoint MUST have a COMPLETE,
|
|
4
|
+
* contiguous migration chain from version 1 to its current `configVersion`.
|
|
5
|
+
*
|
|
6
|
+
* Auth strategies do NOT use `Versioned`; they expose a bespoke shape
|
|
7
|
+
* `{ configVersion, configSchema, migrations }` and the read path migrates-
|
|
8
|
+
* then-validates via `configService.get(id, schema, configVersion, migrations)`.
|
|
9
|
+
* A missing covering migration would therefore only surface LAZILY on the
|
|
10
|
+
* first stale read. The boot guard wired into `addStrategy` (see
|
|
11
|
+
* `index.ts`, `assertMigrationChainFromV1`) turns that into a registration-
|
|
12
|
+
* time (boot) failure, and this test pins the contract:
|
|
13
|
+
*
|
|
14
|
+
* - the structural guard accepts a representative strategy whose chain is
|
|
15
|
+
* complete (the happy path every concrete strategy must satisfy), and
|
|
16
|
+
* - the guard BITES on a deliberately-broken chain (so it can never be
|
|
17
|
+
* silently no-op'd).
|
|
18
|
+
*
|
|
19
|
+
* The concrete core strategies live in their own plugin packages
|
|
20
|
+
* (`auth-github-backend`, `auth-ldap-backend`, `auth-saml-backend`,
|
|
21
|
+
* `auth-credential-backend`) — auth-backend does not depend on them, so they
|
|
22
|
+
* are guarded at THEIR registration via the same shared `addStrategy` boot
|
|
23
|
+
* guard rather than re-enumerated here. This test owns the guard's contract;
|
|
24
|
+
* a pure STRUCTURAL check (no `migrate()` runs), so it carries zero per-
|
|
25
|
+
* strategy upkeep.
|
|
26
|
+
*/
|
|
27
|
+
import { describe, expect, it } from "bun:test";
|
|
28
|
+
import { z } from "zod";
|
|
29
|
+
import {
|
|
30
|
+
assertMigrationChainFromV1,
|
|
31
|
+
type AuthStrategy,
|
|
32
|
+
type Migration,
|
|
33
|
+
} from "@checkstack/backend-api";
|
|
34
|
+
|
|
35
|
+
const passThrough = (fromVersion: number, toVersion: number): Migration => ({
|
|
36
|
+
fromVersion,
|
|
37
|
+
toVersion,
|
|
38
|
+
description: `pass-through ${fromVersion}->${toVersion}`,
|
|
39
|
+
migrate: (data) => data,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Representative strategies mirroring the real shapes: a v1 no-migration
|
|
43
|
+
// strategy (like `credential`) and a v>1 strategy with a complete chain
|
|
44
|
+
// (like `ldap` at v3).
|
|
45
|
+
const v1Strategy: AuthStrategy<{ token: string }> = {
|
|
46
|
+
id: "fixture-v1",
|
|
47
|
+
displayName: "Fixture v1",
|
|
48
|
+
configVersion: 1,
|
|
49
|
+
configSchema: z.object({ token: z.string().default("") }),
|
|
50
|
+
requiresManualRegistration: true,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const v3Strategy: AuthStrategy<{ host: string }> = {
|
|
54
|
+
id: "fixture-v3",
|
|
55
|
+
displayName: "Fixture v3",
|
|
56
|
+
configVersion: 3,
|
|
57
|
+
configSchema: z.object({ host: z.string().default("") }),
|
|
58
|
+
requiresManualRegistration: false,
|
|
59
|
+
migrations: [passThrough(1, 2), passThrough(2, 3)],
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
describe("auth strategy migration-chain contract", () => {
|
|
63
|
+
it("the registration guard accepts strategies with a complete v1->version chain", () => {
|
|
64
|
+
for (const strategy of [v1Strategy, v3Strategy]) {
|
|
65
|
+
expect(
|
|
66
|
+
() =>
|
|
67
|
+
assertMigrationChainFromV1({
|
|
68
|
+
version: strategy.configVersion,
|
|
69
|
+
migrations: strategy.migrations ?? [],
|
|
70
|
+
}),
|
|
71
|
+
`Strategy "${strategy.id}" (configVersion ${strategy.configVersion}) should have a complete chain`,
|
|
72
|
+
).not.toThrow();
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("the registration guard rejects a v>1 strategy missing its migrations", () => {
|
|
77
|
+
const broken: AuthStrategy<{ host: string }> = {
|
|
78
|
+
id: "fixture-broken-empty",
|
|
79
|
+
displayName: "Fixture broken",
|
|
80
|
+
configVersion: 3,
|
|
81
|
+
configSchema: z.object({ host: z.string().default("") }),
|
|
82
|
+
requiresManualRegistration: false,
|
|
83
|
+
};
|
|
84
|
+
expect(() =>
|
|
85
|
+
assertMigrationChainFromV1({
|
|
86
|
+
version: broken.configVersion,
|
|
87
|
+
migrations: broken.migrations ?? [],
|
|
88
|
+
}),
|
|
89
|
+
).toThrow(/incomplete/);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("the registration guard rejects a gapped chain", () => {
|
|
93
|
+
const gapped: AuthStrategy<{ host: string }> = {
|
|
94
|
+
id: "fixture-broken-gap",
|
|
95
|
+
displayName: "Fixture gapped",
|
|
96
|
+
configVersion: 3,
|
|
97
|
+
configSchema: z.object({ host: z.string().default("") }),
|
|
98
|
+
requiresManualRegistration: false,
|
|
99
|
+
migrations: [passThrough(1, 2)],
|
|
100
|
+
};
|
|
101
|
+
expect(() =>
|
|
102
|
+
assertMigrationChainFromV1({
|
|
103
|
+
version: gapped.configVersion,
|
|
104
|
+
migrations: gapped.migrations ?? [],
|
|
105
|
+
}),
|
|
106
|
+
).toThrow(/incomplete: reaches version 2/);
|
|
107
|
+
});
|
|
108
|
+
});
|