project-auth.ts164 lines · main
1import { ForbiddenError, UnauthorizedError } from '@briven/shared';
2import { eq } from 'drizzle-orm';
3import type { MiddlewareHandler } from 'hono';
4
5import { getDb } from '../db/client.js';
6import { users as usersTable } from '../db/schema.js';
7import { verifyCliToken } from '../lib/cli-jwt.js';
8import { log } from '../lib/logger.js';
9import { hasRoleAtLeast } from '../services/access.js';
10import { resolveApiKey } from '../services/api-keys.js';
11import { getProjectAccessForUser } from '../services/projects.js';
12import type { MemberRole } from '../db/schema.js';
13import type { Session, User } from './session.js';
14
15/**
16 * Authorise a request scoped to a project id path param either by:
17 * 1. A valid session whose user has access to the project (via either an
18 * `orgMembers` row for the project's org, OR a direct `projectMembers`
19 * row), OR
20 * 2. An `Authorization: Bearer brk_...` header whose key matches the
21 * project id, OR
22 * 3. An `Authorization: Bearer <jwt>` minted by `/v1/auth/cli-token`
23 * (scope=cli), resolved against the same project-access lookup as the
24 * cookie/session branch — so the CLI wizard and dashboard see the same
25 * effective role on the same project, OR
26 * 4. An `Authorization: Bearer <jwt>` minted by
27 * `/v1/auth-core/oauth/token` (scope=m2m, client_credentials) for this
28 * project — role comes from the M2M client (viewer/developer/admin).
29 *
30 * `paramName` defaults to "id" (v1/projects/{id}/...). Pass "ref" for the
31 * Supabase-compat platform/{ref}/... surface. Hono only resolves params for
32 * the matched route, so platform routes must mount this middleware per-route
33 * with "ref" (a wildcard /platform/* use() never sees the ref param and used
34 * to 403 every request with "missing project id").
35 *
36 * On success this middleware populates:
37 * - `c.var.apiKeyId` — non-null when authed via API key, null for session
38 * - `c.var.projectRole` — the effective `MemberRole`. For session auth this
39 * is `max(orgRole, projectRole)`. For api-key auth it is the role the key
40 * was minted at (defaults to 'admin' for back-compat with keys created
41 * before per-key role scoping landed; new keys may be issued at any of
42 * viewer / developer / admin).
43 *
44 * Routes that need stricter gating chain `requireProjectRole(min)` after
45 * this middleware.
46 */
47export const requireProjectAuth =
48 (paramName: string = 'id'): MiddlewareHandler =>
49 async (c, next) => {
50 const projectId = c.req.param(paramName);
51 if (!projectId) throw new ForbiddenError('missing project id');
52
53 const user = c.get('user') as User | null;
54 if (user) {
55 const access = await getProjectAccessForUser(projectId, user.id);
56 c.set('apiKeyId', null);
57 c.set('projectRole', access.role);
58 await next();
59 return;
60 }
61
62 const auth = c.req.header('authorization');
63 const token = auth?.startsWith('Bearer ') ? auth.slice('Bearer '.length).trim() : null;
64 if (!token) {
65 throw new UnauthorizedError();
66 }
67
68 // Non-brk bearer: try M2M JWT first, then CLI JWT.
69 if (!token.startsWith('brk_')) {
70 // M2M client_credentials access token (scope=m2m).
71 try {
72 const { verifyM2mAccessToken } = await import('../services/auth-core/m2m.js');
73 const m2m = await verifyM2mAccessToken(token);
74 if (m2m.project_id !== projectId) {
75 throw new ForbiddenError('m2m token does not belong to this project');
76 }
77 c.set('apiKeyId', m2m.client_id);
78 c.set('projectRole', m2m.role as MemberRole);
79 await next();
80 return;
81 } catch (err) {
82 if (err instanceof ForbiddenError) throw err;
83 // Not an M2M token — fall through to CLI JWT.
84 }
85
86 // CLI JWT branch — accept scope=cli tokens minted by /v1/auth/cli-token.
87 // The token's `sub` identifies the user; we then resolve project access
88 // the same way the cookie/session branch does (getProjectAccessForUser),
89 // so both paths populate `projectRole` identically.
90 let userRow: User | null = null;
91 try {
92 const payload = await verifyCliToken(token);
93 const [row] = await getDb()
94 .select({ id: usersTable.id, email: usersTable.email, name: usersTable.name })
95 .from(usersTable)
96 .where(eq(usersTable.id, payload.sub))
97 .limit(1);
98 if (!row) {
99 return c.json({ code: 'unauthorized', message: 'cli token user not found' }, 401);
100 }
101 userRow = row as unknown as User;
102 } catch (err) {
103 log.warn('project_auth_cli_jwt_rejected', {
104 err: err instanceof Error ? err.message : String(err),
105 });
106 return c.json({ code: 'unauthorized', message: 'invalid cli or m2m token' }, 401);
107 }
108 c.set('user', userRow);
109 const access = await getProjectAccessForUser(projectId, userRow.id);
110 c.set('apiKeyId', null);
111 c.set('projectRole', access.role);
112 await next();
113 return;
114 }
115
116 const resolved = await resolveApiKey(token);
117 if (!resolved) throw new UnauthorizedError('invalid or revoked api key');
118 if (resolved.projectId !== projectId) {
119 throw new ForbiddenError('api key does not belong to this project');
120 }
121
122 c.set('apiKeyId', resolved.keyId);
123 c.set('projectRole', resolved.role);
124 await next();
125 return;
126 };
127
128/**
129 * Gate a route on a minimum `MemberRole`. Must follow `requireProjectAuth`
130 * in the chain (which populates `projectRole`). API-key authenticated
131 * requests carry the role they were minted at (default 'admin') — routes
132 * that need to refuse api keys outright should add an explicit check on
133 * `c.get('apiKeyId')`.
134 *
135 * Owner-tier gating: no route uses `requireProjectRole('owner')` today,
136 * but the scaffolding works end-to-end. To add a future owner-only route
137 * (e.g. project hard-delete or ownership transfer):
138 *
139 * projectsRouter.delete(
140 * '/v1/projects/:id/permanent',
141 * requireAuth(),
142 * requireProjectRole('owner'),
143 * async (c) => { ... },
144 * );
145 *
146 * Because `routes/api-keys.ts` and `services/api-keys.ts:isAssignableKeyRole`
147 * both reject 'owner' as an assignable key role, no api key can satisfy
148 * such a gate — owner-tier routes are session-only by construction.
149 * Rank semantics are pinned by `services/access.test.ts` ("owner-tier
150 * gating" suite).
151 */
152export const requireProjectRole =
153 (min: MemberRole): MiddlewareHandler =>
154 async (c, next) => {
155 const role = c.get('projectRole') as MemberRole | null | undefined;
156 if (!role) throw new ForbiddenError('no project role on request');
157 if (!hasRoleAtLeast(role, min)) {
158 throw new ForbiddenError(`requires role ${min} or higher`);
159 }
160 await next();
161 return;
162 };
163
164export type { Session, User };