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