@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
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { RealUser } from "@checkstack/backend-api";
|
|
3
|
+
import { narrowedPrincipalFromSession } from "./oauth-branch";
|
|
4
|
+
|
|
5
|
+
const CATALOG = [
|
|
6
|
+
"incident.incident.read",
|
|
7
|
+
"incident.incident.manage",
|
|
8
|
+
"anomaly.anomaly.read",
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
function liveUser(accessRules: string[]): RealUser {
|
|
12
|
+
return {
|
|
13
|
+
type: "user",
|
|
14
|
+
id: "u1",
|
|
15
|
+
email: "u1@example.com",
|
|
16
|
+
name: "User One",
|
|
17
|
+
roles: ["users"],
|
|
18
|
+
accessRules,
|
|
19
|
+
teamIds: ["team-a", "team-b"],
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("narrowedPrincipalFromSession (§6.3)", () => {
|
|
24
|
+
test("accessRules = granted ∩ liveRules (NOT the full live rules)", () => {
|
|
25
|
+
const principal = narrowedPrincipalFromSession({
|
|
26
|
+
session: {
|
|
27
|
+
userId: "u1",
|
|
28
|
+
scopes: "incident.incident.read incident.incident.manage",
|
|
29
|
+
},
|
|
30
|
+
principal: {
|
|
31
|
+
base: liveUser(["incident.incident.read", "anomaly.anomaly.read"]),
|
|
32
|
+
catalog: CATALOG,
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
// manage granted but not held -> dropped; anomaly held but not granted -> absent.
|
|
36
|
+
expect(principal?.accessRules).toEqual(["incident.incident.read"]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("teamIds are inherited verbatim from the live enrichment", () => {
|
|
40
|
+
const principal = narrowedPrincipalFromSession({
|
|
41
|
+
session: { userId: "u1", scopes: "incident.incident.read" },
|
|
42
|
+
principal: {
|
|
43
|
+
base: liveUser(["incident.incident.read"]),
|
|
44
|
+
catalog: CATALOG,
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
expect(principal?.teamIds).toEqual(["team-a", "team-b"]);
|
|
48
|
+
expect(principal?.type).toBe("user");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("a rule the principal has since LOST is dropped (live narrowing)", () => {
|
|
52
|
+
const principal = narrowedPrincipalFromSession({
|
|
53
|
+
session: { userId: "u1", scopes: "incident.incident.manage" },
|
|
54
|
+
principal: {
|
|
55
|
+
// The live user no longer has manage.
|
|
56
|
+
base: liveUser(["incident.incident.read"]),
|
|
57
|
+
catalog: CATALOG,
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
expect(principal?.accessRules).toEqual([]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("a token with no granted scopes is treated as unauthenticated", () => {
|
|
64
|
+
const principal = narrowedPrincipalFromSession({
|
|
65
|
+
session: { userId: "u1", scopes: "" },
|
|
66
|
+
principal: { base: liveUser(["incident.incident.read"]), catalog: CATALOG },
|
|
67
|
+
});
|
|
68
|
+
expect(principal).toBeUndefined();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("admin token carries the concrete granted set, never the bare wildcard", () => {
|
|
72
|
+
const principal = narrowedPrincipalFromSession({
|
|
73
|
+
session: { userId: "u1", scopes: "checkstack:write" },
|
|
74
|
+
principal: { base: liveUser(["*"]), catalog: CATALOG },
|
|
75
|
+
});
|
|
76
|
+
expect(principal?.accessRules?.sort()).toEqual([...CATALOG].sort());
|
|
77
|
+
expect(principal?.accessRules).not.toContain("*");
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { eq } from "drizzle-orm";
|
|
2
|
+
import type { RealUser, SafeDatabase } from "@checkstack/backend-api";
|
|
3
|
+
import { narrowScopes } from "./scope-narrowing";
|
|
4
|
+
import * as schema from "./schema";
|
|
5
|
+
|
|
6
|
+
// Re-export the shared opaque-bearer extractor so existing import sites in this
|
|
7
|
+
// plugin (index.ts) keep working without changing their import path.
|
|
8
|
+
export { opaqueBearerToken } from "@checkstack/backend-api";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The introspected OAuth session — the subset of better-auth's
|
|
12
|
+
* `OAuthAccessToken` row this branch needs. Tokens are OPAQUE (decision §11):
|
|
13
|
+
* this is the result of a DB introspection, not a decoded JWT.
|
|
14
|
+
*/
|
|
15
|
+
export interface IntrospectedOAuthSession {
|
|
16
|
+
/** The bound principal's user id (`OAuthAccessToken.userId`). */
|
|
17
|
+
userId: string;
|
|
18
|
+
/** Space-delimited granted scopes (`OAuthAccessToken.scopes`). */
|
|
19
|
+
scopes: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Introspect an OPAQUE OAuth access token against the oidcProvider-owned
|
|
24
|
+
* `oauthAccessToken` table (decision §11 — tokens are not JWTs, so this is a DB
|
|
25
|
+
* lookup, the same `findOne` the mcp plugin's `getMcpSession` performs, plus an
|
|
26
|
+
* explicit expiry check which `getMcpSession` does NOT do). Returns the session
|
|
27
|
+
* subset, or `undefined` when the token is unknown or expired.
|
|
28
|
+
*/
|
|
29
|
+
export async function introspectOpaqueToken({
|
|
30
|
+
db,
|
|
31
|
+
token,
|
|
32
|
+
now = new Date(),
|
|
33
|
+
}: {
|
|
34
|
+
db: SafeDatabase<typeof schema>;
|
|
35
|
+
token: string;
|
|
36
|
+
now?: Date;
|
|
37
|
+
}): Promise<IntrospectedOAuthSession | undefined> {
|
|
38
|
+
const rows = await db
|
|
39
|
+
.select({
|
|
40
|
+
userId: schema.oauthAccessToken.userId,
|
|
41
|
+
scopes: schema.oauthAccessToken.scopes,
|
|
42
|
+
expiresAt: schema.oauthAccessToken.accessTokenExpiresAt,
|
|
43
|
+
})
|
|
44
|
+
.from(schema.oauthAccessToken)
|
|
45
|
+
.where(eq(schema.oauthAccessToken.accessToken, token))
|
|
46
|
+
.limit(1);
|
|
47
|
+
|
|
48
|
+
const row = rows[0];
|
|
49
|
+
if (!row || !row.userId || !row.scopes) return undefined;
|
|
50
|
+
if (row.expiresAt && new Date(row.expiresAt).getTime() <= now.getTime()) {
|
|
51
|
+
return undefined; // expired
|
|
52
|
+
}
|
|
53
|
+
return { userId: row.userId, scopes: row.scopes };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The principal's LIVE rules + teams, resolved by the same machinery the UI
|
|
58
|
+
* uses (`enrichUser`). Passed in so this module stays pure and DB-agnostic.
|
|
59
|
+
*/
|
|
60
|
+
export interface LivePrincipal {
|
|
61
|
+
base: RealUser;
|
|
62
|
+
/** All currently-registered qualified access-rule IDs (for bundle/admin expansion). */
|
|
63
|
+
catalog: string[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Build a NARROWED principal from an introspected opaque OAuth token.
|
|
68
|
+
*
|
|
69
|
+
* The crux (decision 4, §6.3): enrich the bound user to their CURRENT full
|
|
70
|
+
* access rules, then intersect with the token's granted scopes. Narrowing runs
|
|
71
|
+
* live on every call, so a rule the principal has since lost is dropped on the
|
|
72
|
+
* next call — strictly stronger than freezing claims into a JWT at mint.
|
|
73
|
+
*
|
|
74
|
+
* Returns `undefined` when the token has no granted scopes (it can do nothing,
|
|
75
|
+
* so it is treated as unauthenticated rather than as a zero-rule principal).
|
|
76
|
+
*/
|
|
77
|
+
export function narrowedPrincipalFromSession({
|
|
78
|
+
session,
|
|
79
|
+
principal,
|
|
80
|
+
}: {
|
|
81
|
+
session: IntrospectedOAuthSession;
|
|
82
|
+
principal: LivePrincipal;
|
|
83
|
+
}): RealUser | undefined {
|
|
84
|
+
const granted = session.scopes.split(" ").filter(Boolean);
|
|
85
|
+
if (granted.length === 0) return undefined;
|
|
86
|
+
|
|
87
|
+
const narrowedRules = narrowScopes({
|
|
88
|
+
requested: granted,
|
|
89
|
+
principalRules: principal.base.accessRules ?? [],
|
|
90
|
+
catalog: principal.catalog,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Narrow-only: accessRules is replaced by the (subset) narrowed set; teamIds
|
|
94
|
+
// are inherited verbatim from the live enrichment.
|
|
95
|
+
return {
|
|
96
|
+
...principal.base,
|
|
97
|
+
accessRules: narrowedRules,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { windowStartFor } from "./rate-limit";
|
|
3
|
+
|
|
4
|
+
describe("windowStartFor", () => {
|
|
5
|
+
test("floors a timestamp to the start of its fixed window", () => {
|
|
6
|
+
const now = new Date("2026-06-02T10:17:42.000Z");
|
|
7
|
+
const start = windowStartFor({ now, windowSeconds: 3600 });
|
|
8
|
+
expect(start.toISOString()).toBe("2026-06-02T10:00:00.000Z");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("two timestamps in the same window share a window start (one counter)", () => {
|
|
12
|
+
const a = windowStartFor({
|
|
13
|
+
now: new Date("2026-06-02T10:00:01.000Z"),
|
|
14
|
+
windowSeconds: 3600,
|
|
15
|
+
});
|
|
16
|
+
const b = windowStartFor({
|
|
17
|
+
now: new Date("2026-06-02T10:59:59.000Z"),
|
|
18
|
+
windowSeconds: 3600,
|
|
19
|
+
});
|
|
20
|
+
expect(a.getTime()).toBe(b.getTime());
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("the next window gets a distinct start (counter resets)", () => {
|
|
24
|
+
const a = windowStartFor({
|
|
25
|
+
now: new Date("2026-06-02T10:59:59.000Z"),
|
|
26
|
+
windowSeconds: 3600,
|
|
27
|
+
});
|
|
28
|
+
const b = windowStartFor({
|
|
29
|
+
now: new Date("2026-06-02T11:00:00.000Z"),
|
|
30
|
+
windowSeconds: 3600,
|
|
31
|
+
});
|
|
32
|
+
expect(b.getTime()).toBeGreaterThan(a.getTime());
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { sql } from "drizzle-orm";
|
|
2
|
+
import type { SafeDatabase } from "@checkstack/backend-api";
|
|
3
|
+
import * as schema from "./schema";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Shared-Postgres fixed-window rate limiter (state-and-scale §14.5).
|
|
7
|
+
*
|
|
8
|
+
* The counter lives in the `ai_rate_limit` table, so the cap holds across ALL
|
|
9
|
+
* pods — an in-memory per-pod limiter would let N pods each allow the cap, i.e.
|
|
10
|
+
* N x the intended limit, which a single-process test would never catch. This
|
|
11
|
+
* is LOCKED: the limiter MUST be Postgres-backed, never in-memory.
|
|
12
|
+
*
|
|
13
|
+
* Each call atomically bumps the counter for `(key, windowStart)` and returns
|
|
14
|
+
* the post-increment count, so the increment + read is a single round-trip with
|
|
15
|
+
* no read-modify-write race between pods.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Floor a timestamp to the start of its fixed window. */
|
|
19
|
+
export function windowStartFor({
|
|
20
|
+
now,
|
|
21
|
+
windowSeconds,
|
|
22
|
+
}: {
|
|
23
|
+
now: Date;
|
|
24
|
+
windowSeconds: number;
|
|
25
|
+
}): Date {
|
|
26
|
+
const windowMs = windowSeconds * 1000;
|
|
27
|
+
return new Date(Math.floor(now.getTime() / windowMs) * windowMs);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface RateLimitResult {
|
|
31
|
+
/** Count AFTER this call's increment. */
|
|
32
|
+
count: number;
|
|
33
|
+
/** Whether this call is within the limit (`count <= max`). */
|
|
34
|
+
allowed: boolean;
|
|
35
|
+
/** The window this call was counted against. */
|
|
36
|
+
windowStart: Date;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Atomically increment and check a fixed-window counter.
|
|
41
|
+
*
|
|
42
|
+
* Returns `{ allowed: false }` when the post-increment count exceeds `max`.
|
|
43
|
+
* Callers decide the response (e.g. 429 for the DCR endpoint).
|
|
44
|
+
*/
|
|
45
|
+
export async function checkRateLimit({
|
|
46
|
+
db,
|
|
47
|
+
key,
|
|
48
|
+
max,
|
|
49
|
+
windowSeconds,
|
|
50
|
+
now = new Date(),
|
|
51
|
+
}: {
|
|
52
|
+
db: SafeDatabase<typeof schema>;
|
|
53
|
+
key: string;
|
|
54
|
+
max: number;
|
|
55
|
+
windowSeconds: number;
|
|
56
|
+
now?: Date;
|
|
57
|
+
}): Promise<RateLimitResult> {
|
|
58
|
+
const windowStart = windowStartFor({ now, windowSeconds });
|
|
59
|
+
|
|
60
|
+
// INSERT ... ON CONFLICT (key, window_start) DO UPDATE SET count = count + 1
|
|
61
|
+
// RETURNING count. Single atomic statement: no read-modify-write race.
|
|
62
|
+
const rows = await db
|
|
63
|
+
.insert(schema.aiRateLimit)
|
|
64
|
+
.values({ key, windowStart, count: 1 })
|
|
65
|
+
.onConflictDoUpdate({
|
|
66
|
+
target: [schema.aiRateLimit.key, schema.aiRateLimit.windowStart],
|
|
67
|
+
set: { count: sql`${schema.aiRateLimit.count} + 1` },
|
|
68
|
+
})
|
|
69
|
+
.returning({ count: schema.aiRateLimit.count });
|
|
70
|
+
|
|
71
|
+
const count = rows[0]?.count ?? 1;
|
|
72
|
+
return { count, allowed: count <= max, windowStart };
|
|
73
|
+
}
|
package/src/router.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
passwordSchema,
|
|
15
15
|
authAccess,
|
|
16
16
|
pluginMetadata,
|
|
17
|
+
isApplicationBindable,
|
|
17
18
|
} from "@checkstack/auth-common";
|
|
18
19
|
import { qualifyAccessRuleId } from "@checkstack/common";
|
|
19
20
|
import { hashPassword } from "better-auth/crypto";
|
|
@@ -21,6 +22,7 @@ import * as schema from "./schema";
|
|
|
21
22
|
import { eq, inArray, and } from "drizzle-orm";
|
|
22
23
|
import type { SafeDatabase } from "@checkstack/backend-api";
|
|
23
24
|
import { authHooks } from "./hooks";
|
|
25
|
+
import { enrichApplicationPrincipal as resolveApplicationPrincipal } from "./utils/user";
|
|
24
26
|
|
|
25
27
|
/**
|
|
26
28
|
* Type guard to check if user is a RealUser (not a service).
|
|
@@ -37,6 +39,11 @@ 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";
|
|
40
47
|
|
|
41
48
|
export const ADMIN_ROLE_ID = "admin";
|
|
42
49
|
export const USERS_ROLE_ID = "users";
|
|
@@ -663,6 +670,41 @@ export const createAuthRouter = (
|
|
|
663
670
|
},
|
|
664
671
|
);
|
|
665
672
|
|
|
673
|
+
const getMcpOAuthSettings = os.getMcpOAuthSettings.handler(async () => {
|
|
674
|
+
const cfg = await configService.get(
|
|
675
|
+
MCP_OAUTH_CONFIG_ID,
|
|
676
|
+
mcpOAuthConfigV1,
|
|
677
|
+
MCP_OAUTH_CONFIG_VERSION,
|
|
678
|
+
);
|
|
679
|
+
// Defaults mirror the schema (off by default).
|
|
680
|
+
return {
|
|
681
|
+
enabled: cfg?.enabled ?? false,
|
|
682
|
+
allowDynamicClientRegistration:
|
|
683
|
+
cfg?.allowDynamicClientRegistration ?? false,
|
|
684
|
+
dcrRateLimitMax: cfg?.dcrRateLimitMax ?? 5,
|
|
685
|
+
dcrRateLimitWindowSeconds: cfg?.dcrRateLimitWindowSeconds ?? 3600,
|
|
686
|
+
};
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
const setMcpOAuthSettings = os.setMcpOAuthSettings.handler(
|
|
690
|
+
async ({ input }) => {
|
|
691
|
+
await configService.set(
|
|
692
|
+
MCP_OAUTH_CONFIG_ID,
|
|
693
|
+
mcpOAuthConfigV1,
|
|
694
|
+
MCP_OAUTH_CONFIG_VERSION,
|
|
695
|
+
{
|
|
696
|
+
enabled: input.enabled,
|
|
697
|
+
allowDynamicClientRegistration: input.allowDynamicClientRegistration,
|
|
698
|
+
dcrRateLimitMax: input.dcrRateLimitMax,
|
|
699
|
+
dcrRateLimitWindowSeconds: input.dcrRateLimitWindowSeconds,
|
|
700
|
+
},
|
|
701
|
+
);
|
|
702
|
+
// Enabling/disabling the plugins requires re-initializing better-auth.
|
|
703
|
+
await reloadAuthFn();
|
|
704
|
+
return { success: true };
|
|
705
|
+
},
|
|
706
|
+
);
|
|
707
|
+
|
|
666
708
|
// ==========================================================================
|
|
667
709
|
// ONBOARDING ENDPOINTS
|
|
668
710
|
// ==========================================================================
|
|
@@ -1441,6 +1483,62 @@ export const createAuthRouter = (
|
|
|
1441
1483
|
},
|
|
1442
1484
|
);
|
|
1443
1485
|
|
|
1486
|
+
// S2S: resolve an application principal live for the app-principal token path.
|
|
1487
|
+
const enrichApplicationPrincipal =
|
|
1488
|
+
os.enrichApplicationPrincipal.handler(async ({ input }) => {
|
|
1489
|
+
const enriched = await resolveApplicationPrincipal(
|
|
1490
|
+
input.applicationId,
|
|
1491
|
+
internalDb,
|
|
1492
|
+
);
|
|
1493
|
+
return enriched ?? null;
|
|
1494
|
+
});
|
|
1495
|
+
|
|
1496
|
+
// List applications the caller may bind as an automation's service account.
|
|
1497
|
+
// An app is bindable only when its access rules are a subset of the caller's
|
|
1498
|
+
// (no privilege escalation); `*`-holders may bind anything.
|
|
1499
|
+
const getBindableApplications = os.getBindableApplications.handler(
|
|
1500
|
+
async ({ context }) => {
|
|
1501
|
+
const callerRules = isRealUser(context.user)
|
|
1502
|
+
? (context.user.accessRules ?? [])
|
|
1503
|
+
: [];
|
|
1504
|
+
|
|
1505
|
+
const apps = await internalDb.select().from(schema.application);
|
|
1506
|
+
const bindable: {
|
|
1507
|
+
id: string;
|
|
1508
|
+
name: string;
|
|
1509
|
+
description: string | null;
|
|
1510
|
+
}[] = [];
|
|
1511
|
+
|
|
1512
|
+
const callerIsAdmin = callerRules.includes("*");
|
|
1513
|
+
for (const app of apps) {
|
|
1514
|
+
// Admins bind anything without resolving rules; others need the
|
|
1515
|
+
// per-app subset check.
|
|
1516
|
+
if (!callerIsAdmin) {
|
|
1517
|
+
const enriched = await resolveApplicationPrincipal(
|
|
1518
|
+
app.id,
|
|
1519
|
+
internalDb,
|
|
1520
|
+
);
|
|
1521
|
+
if (!enriched) continue;
|
|
1522
|
+
if (
|
|
1523
|
+
!isApplicationBindable({
|
|
1524
|
+
appAccessRules: enriched.accessRules,
|
|
1525
|
+
callerAccessRules: callerRules,
|
|
1526
|
+
})
|
|
1527
|
+
) {
|
|
1528
|
+
continue;
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
bindable.push({
|
|
1532
|
+
id: app.id,
|
|
1533
|
+
name: app.name,
|
|
1534
|
+
description: app.description,
|
|
1535
|
+
});
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
return bindable;
|
|
1539
|
+
},
|
|
1540
|
+
);
|
|
1541
|
+
|
|
1444
1542
|
// ==========================================================================
|
|
1445
1543
|
// TEAM MANAGEMENT HANDLERS
|
|
1446
1544
|
// ==========================================================================
|
|
@@ -1962,6 +2060,8 @@ export const createAuthRouter = (
|
|
|
1962
2060
|
getRegistrationSchema,
|
|
1963
2061
|
getRegistrationStatus,
|
|
1964
2062
|
setRegistrationStatus,
|
|
2063
|
+
getMcpOAuthSettings,
|
|
2064
|
+
setMcpOAuthSettings,
|
|
1965
2065
|
getOnboardingStatus,
|
|
1966
2066
|
completeOnboarding,
|
|
1967
2067
|
validateResetToken,
|
|
@@ -1979,6 +2079,8 @@ export const createAuthRouter = (
|
|
|
1979
2079
|
updateApplication,
|
|
1980
2080
|
deleteApplication,
|
|
1981
2081
|
regenerateApplicationSecret,
|
|
2082
|
+
enrichApplicationPrincipal,
|
|
2083
|
+
getBindableApplications,
|
|
1982
2084
|
getOwnStrategyConfig,
|
|
1983
2085
|
// Teams
|
|
1984
2086
|
getTeams,
|
package/src/schema.ts
CHANGED
|
@@ -4,6 +4,8 @@ import {
|
|
|
4
4
|
boolean,
|
|
5
5
|
timestamp,
|
|
6
6
|
primaryKey,
|
|
7
|
+
integer,
|
|
8
|
+
index,
|
|
7
9
|
} from "drizzle-orm/pg-core";
|
|
8
10
|
|
|
9
11
|
// --- Better Auth Schema ---
|
|
@@ -278,3 +280,73 @@ export const resourceTeamAccess = pgTable(
|
|
|
278
280
|
pk: primaryKey({ columns: [t.resourceType, t.resourceId, t.teamId] }),
|
|
279
281
|
})
|
|
280
282
|
);
|
|
283
|
+
|
|
284
|
+
// --- AI platform: OAuth Authorization Server (better-auth oidcProvider/mcp) ---
|
|
285
|
+
//
|
|
286
|
+
// These three tables are OWNED by the better-auth `oidcProvider` + `mcp`
|
|
287
|
+
// plugins (Phase 2). The plugins' Drizzle adapter resolves them by the camelCase
|
|
288
|
+
// model keys below (`oauthApplication`, `oauthAccessToken`, `oauthConsent`) and
|
|
289
|
+
// the camelCase field keys; column names are snake_case to match repo
|
|
290
|
+
// convention. Field shapes mirror the plugin schema exactly (see
|
|
291
|
+
// SPIKE-findings.md). Access tokens are OPAQUE random strings persisted here —
|
|
292
|
+
// validation is an introspection lookup, not a JWKS verify (decision §11).
|
|
293
|
+
|
|
294
|
+
/** Registered OAuth clients (incl. dynamically-registered MCP clients). */
|
|
295
|
+
export const oauthApplication = pgTable("oauth_application", {
|
|
296
|
+
id: text("id").primaryKey(),
|
|
297
|
+
name: text("name"),
|
|
298
|
+
icon: text("icon"),
|
|
299
|
+
metadata: text("metadata"),
|
|
300
|
+
clientId: text("client_id").unique(),
|
|
301
|
+
clientSecret: text("client_secret"),
|
|
302
|
+
redirectUrls: text("redirect_urls"),
|
|
303
|
+
type: text("type"),
|
|
304
|
+
disabled: boolean("disabled").default(false),
|
|
305
|
+
userId: text("user_id"),
|
|
306
|
+
createdAt: timestamp("created_at"),
|
|
307
|
+
updatedAt: timestamp("updated_at"),
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
/** Issued opaque access/refresh tokens + their granted scopes. */
|
|
311
|
+
export const oauthAccessToken = pgTable("oauth_access_token", {
|
|
312
|
+
id: text("id").primaryKey(),
|
|
313
|
+
accessToken: text("access_token").unique(),
|
|
314
|
+
refreshToken: text("refresh_token").unique(),
|
|
315
|
+
accessTokenExpiresAt: timestamp("access_token_expires_at"),
|
|
316
|
+
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
|
|
317
|
+
clientId: text("client_id"),
|
|
318
|
+
userId: text("user_id"),
|
|
319
|
+
scopes: text("scopes"),
|
|
320
|
+
createdAt: timestamp("created_at"),
|
|
321
|
+
updatedAt: timestamp("updated_at"),
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
/** Per-(client,user) consent record for the consent screen. */
|
|
325
|
+
export const oauthConsent = pgTable("oauth_consent", {
|
|
326
|
+
id: text("id").primaryKey(),
|
|
327
|
+
clientId: text("client_id"),
|
|
328
|
+
userId: text("user_id"),
|
|
329
|
+
scopes: text("scopes"),
|
|
330
|
+
createdAt: timestamp("created_at"),
|
|
331
|
+
updatedAt: timestamp("updated_at"),
|
|
332
|
+
consentGiven: boolean("consent_given"),
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// --- AI platform: shared-Postgres rate-limit counter (state-and-scale §14.5) ---
|
|
336
|
+
//
|
|
337
|
+
// Fixed-window counter so a limit holds across ALL pods (an in-memory per-pod
|
|
338
|
+
// limiter would let N pods each allow the cap = N x the intended limit). Used
|
|
339
|
+
// today for the DCR endpoint throttle (`dcr:<ip>`); per-principal tool budgets
|
|
340
|
+
// (Phase 3) reuse the same table with a different key.
|
|
341
|
+
export const aiRateLimit = pgTable(
|
|
342
|
+
"ai_rate_limit",
|
|
343
|
+
{
|
|
344
|
+
key: text("key").notNull(),
|
|
345
|
+
windowStart: timestamp("window_start").notNull(),
|
|
346
|
+
count: integer("count").notNull().default(0),
|
|
347
|
+
},
|
|
348
|
+
(t) => ({
|
|
349
|
+
pk: primaryKey({ columns: [t.key, t.windowStart] }),
|
|
350
|
+
keyIdx: index("ai_rate_limit_key_idx").on(t.key, t.windowStart),
|
|
351
|
+
}),
|
|
352
|
+
);
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
SCOPE_BUNDLE,
|
|
4
|
+
expandBundles,
|
|
5
|
+
narrowScopes,
|
|
6
|
+
} from "./scope-narrowing";
|
|
7
|
+
|
|
8
|
+
const CATALOG = [
|
|
9
|
+
"incident.incident.read",
|
|
10
|
+
"incident.incident.manage",
|
|
11
|
+
"healthcheck.config.read",
|
|
12
|
+
"healthcheck.config.manage",
|
|
13
|
+
"anomaly.anomaly.read",
|
|
14
|
+
"ai.tools.manage",
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
describe("expandBundles", () => {
|
|
18
|
+
test("checkstack:read expands to every *.read rule", () => {
|
|
19
|
+
expect(
|
|
20
|
+
expandBundles({ requested: [SCOPE_BUNDLE.read], catalog: CATALOG }).sort(),
|
|
21
|
+
).toEqual(
|
|
22
|
+
[
|
|
23
|
+
"incident.incident.read",
|
|
24
|
+
"healthcheck.config.read",
|
|
25
|
+
"anomaly.anomaly.read",
|
|
26
|
+
].sort(),
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("checkstack:write expands to every *.read + *.manage rule", () => {
|
|
31
|
+
expect(
|
|
32
|
+
expandBundles({
|
|
33
|
+
requested: [SCOPE_BUNDLE.write],
|
|
34
|
+
catalog: CATALOG,
|
|
35
|
+
}).sort(),
|
|
36
|
+
).toEqual([...CATALOG].sort());
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("raw rule IDs pass through unchanged", () => {
|
|
40
|
+
expect(
|
|
41
|
+
expandBundles({
|
|
42
|
+
requested: ["incident.incident.read"],
|
|
43
|
+
catalog: CATALOG,
|
|
44
|
+
}),
|
|
45
|
+
).toEqual(["incident.incident.read"]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("unknown bundle-like strings are kept verbatim (intersection drops them)", () => {
|
|
49
|
+
// Not a known bundle: kept as a raw id, then dropped by narrowScopes.
|
|
50
|
+
expect(
|
|
51
|
+
expandBundles({ requested: ["checkstack:everything"], catalog: CATALOG }),
|
|
52
|
+
).toEqual(["checkstack:everything"]);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("narrowScopes", () => {
|
|
57
|
+
test("intersects granted scopes with the principal's live rules", () => {
|
|
58
|
+
const narrowed = narrowScopes({
|
|
59
|
+
requested: ["incident.incident.read", "incident.incident.manage"],
|
|
60
|
+
principalRules: ["incident.incident.read", "anomaly.anomaly.read"],
|
|
61
|
+
catalog: CATALOG,
|
|
62
|
+
});
|
|
63
|
+
// manage was granted but the principal lacks it -> dropped.
|
|
64
|
+
expect(narrowed).toEqual(["incident.incident.read"]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("checkstack:write narrows to only what the principal actually has", () => {
|
|
68
|
+
const narrowed = narrowScopes({
|
|
69
|
+
requested: [SCOPE_BUNDLE.write],
|
|
70
|
+
principalRules: ["incident.incident.read"],
|
|
71
|
+
catalog: CATALOG,
|
|
72
|
+
});
|
|
73
|
+
expect(narrowed).toEqual(["incident.incident.read"]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("admin (*) can carry any granted rule but NEVER the bare wildcard", () => {
|
|
77
|
+
const narrowed = narrowScopes({
|
|
78
|
+
requested: [SCOPE_BUNDLE.write],
|
|
79
|
+
principalRules: ["*"],
|
|
80
|
+
catalog: CATALOG,
|
|
81
|
+
});
|
|
82
|
+
expect(narrowed.sort()).toEqual([...CATALOG].sort());
|
|
83
|
+
expect(narrowed).not.toContain("*");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("a rule the principal has since LOST is dropped (live narrowing)", () => {
|
|
87
|
+
// Token granted incident.manage, but the principal no longer holds it.
|
|
88
|
+
const narrowed = narrowScopes({
|
|
89
|
+
requested: ["incident.incident.manage"],
|
|
90
|
+
principalRules: ["incident.incident.read"],
|
|
91
|
+
catalog: CATALOG,
|
|
92
|
+
});
|
|
93
|
+
expect(narrowed).toEqual([]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Matrix #5 (Phase 5 hardening, named): the narrow-only invariant is the
|
|
97
|
+
// single most important OAuth property — a token can only ever narrow, never
|
|
98
|
+
// widen, what its bound principal could already do. We restate it as an
|
|
99
|
+
// explicit, named hardening assertion (the fuzz below is the exhaustive
|
|
100
|
+
// backstop) so the security suite carries it as a first-class regression
|
|
101
|
+
// guard, not buried in a generic property test.
|
|
102
|
+
test("HARDENING: scope narrowing can NEVER widen a principal", () => {
|
|
103
|
+
// A token that requests MORE than the principal holds gains nothing extra.
|
|
104
|
+
const escalation = narrowScopes({
|
|
105
|
+
requested: [
|
|
106
|
+
"incident.incident.read",
|
|
107
|
+
"incident.incident.manage", // not held
|
|
108
|
+
"healthcheck.config.manage", // not held
|
|
109
|
+
SCOPE_BUNDLE.write, // a bundle that would expand to manage rules
|
|
110
|
+
],
|
|
111
|
+
principalRules: ["incident.incident.read"],
|
|
112
|
+
catalog: CATALOG,
|
|
113
|
+
});
|
|
114
|
+
expect(escalation).toEqual(["incident.incident.read"]);
|
|
115
|
+
// A leaked admin token is NOT a god-token: it carries only the granted set,
|
|
116
|
+
// never the bare wildcard.
|
|
117
|
+
const adminGranted = narrowScopes({
|
|
118
|
+
requested: ["incident.incident.read"],
|
|
119
|
+
principalRules: ["*"],
|
|
120
|
+
catalog: CATALOG,
|
|
121
|
+
});
|
|
122
|
+
expect(adminGranted).toEqual(["incident.incident.read"]);
|
|
123
|
+
expect(adminGranted).not.toContain("*");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Matrix #5: property/fuzz — narrowing can NEVER widen.
|
|
127
|
+
test("PROPERTY: narrowed is always a subset of the principal's effective rules", () => {
|
|
128
|
+
const universe = [...CATALOG, "*", SCOPE_BUNDLE.read, SCOPE_BUNDLE.write];
|
|
129
|
+
const rand = (n: number) => Math.floor(Math.random() * n);
|
|
130
|
+
const pick = (pool: string[]) =>
|
|
131
|
+
pool.filter(() => Math.random() < 0.5);
|
|
132
|
+
|
|
133
|
+
for (let i = 0; i < 1000; i++) {
|
|
134
|
+
const requested = pick(universe);
|
|
135
|
+
const principalRules = pick(universe).filter(
|
|
136
|
+
(r) => r !== SCOPE_BUNDLE.read && r !== SCOPE_BUNDLE.write,
|
|
137
|
+
);
|
|
138
|
+
// Occasionally include the admin wildcard.
|
|
139
|
+
if (rand(5) === 0) principalRules.push("*");
|
|
140
|
+
|
|
141
|
+
const narrowed = narrowScopes({
|
|
142
|
+
requested,
|
|
143
|
+
principalRules,
|
|
144
|
+
catalog: CATALOG,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const isAdmin = principalRules.includes("*");
|
|
148
|
+
const effective = new Set(isAdmin ? CATALOG : principalRules);
|
|
149
|
+
for (const rule of narrowed) {
|
|
150
|
+
// Never widens: every narrowed rule is within the effective ceiling.
|
|
151
|
+
expect(effective.has(rule)).toBe(true);
|
|
152
|
+
// Never the bare god-scope.
|
|
153
|
+
expect(rule).not.toBe("*");
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
});
|