auth-scim.ts324 lines · main
1/**
2 * SCIM 2.0 + admin token routes (Phase 9).
3 *
4 * Protocol (Bearer scim_briven_… only):
5 * /v1/projects/:id/scim/v2/*
6 *
7 * Admin (dashboard session, project admin):
8 * /v1/projects/:id/auth/scim/tokens
9 */
10
11import { Hono } from 'hono';
12
13import { env } from '../env.js';
14import { requireProjectAuth, requireProjectRole } from '../middleware/project-auth.js';
15import { requireAuthTeamAdmin } from '../middleware/auth-team.js';
16import { audit } from '../services/audit.js';
17import {
18 ScimError,
19 createScimToken,
20 listScimTokens,
21 revokeScimToken,
22 scimCreateGroup,
23 scimCreateUser,
24 scimDeleteGroup,
25 scimDeleteUser,
26 scimGetGroup,
27 scimGetUser,
28 scimListGroups,
29 scimListUsers,
30 scimPatchUser,
31 scimReplaceUser,
32 scimResourceTypes,
33 scimServiceProviderConfig,
34 verifyScimBearer,
35} from '../services/auth-scim.js';
36import type { ProjectAppEnv as AppEnv } from '../types/app-env.js';
37
38export const authScimRouter = new Hono<AppEnv>();
39
40function apiOrigin(): string {
41 return env.BRIVEN_API_ORIGIN || 'https://api.briven.tech';
42}
43
44function bearerFrom(c: { req: { header: (n: string) => string | undefined } }): string | null {
45 const h = c.req.header('authorization') ?? c.req.header('Authorization');
46 if (!h) return null;
47 const m = h.match(/^Bearer\s+(.+)$/i);
48 return m?.[1]?.trim() ?? null;
49}
50
51async function requireScimBearer(
52 c: {
53 req: { param: (n: string) => string; header: (n: string) => string | undefined };
54 json: (body: unknown, status?: number) => Response;
55 },
56): Promise<{ projectId: string } | Response> {
57 const projectId = c.req.param('id');
58 if (!projectId) {
59 return c.json({ schemas: ['urn:ietf:params:scim:api:messages:2.0:Error'], status: '400', detail: 'missing project id' }, 400);
60 }
61 const token = bearerFrom(c);
62 const ok = await verifyScimBearer(projectId, token);
63 if (!ok) {
64 return c.json(
65 {
66 schemas: ['urn:ietf:params:scim:api:messages:2.0:Error'],
67 status: '401',
68 detail: 'invalid or missing SCIM bearer token',
69 },
70 401,
71 );
72 }
73 return { projectId };
74}
75
76function scimCatch(err: unknown): Response {
77 if (err instanceof ScimError) {
78 return new Response(JSON.stringify(err.toJson()), {
79 status: err.status,
80 headers: { 'content-type': 'application/scim+json' },
81 });
82 }
83 const message = err instanceof Error ? err.message : 'internal error';
84 return new Response(
85 JSON.stringify({
86 schemas: ['urn:ietf:params:scim:api:messages:2.0:Error'],
87 status: '500',
88 detail: message,
89 }),
90 { status: 500, headers: { 'content-type': 'application/scim+json' } },
91 );
92}
93
94function scimJson(body: unknown, status = 200): Response {
95 return new Response(JSON.stringify(body), {
96 status,
97 headers: { 'content-type': 'application/scim+json' },
98 });
99}
100
101// ─── Admin: SCIM tokens ────────────────────────────────────────────────────
102
103authScimRouter.use('/v1/projects/:id/auth/scim/*', requireProjectAuth());
104authScimRouter.use('/v1/projects/:id/auth/scim/*', requireAuthTeamAdmin());
105
106authScimRouter.get(
107 '/v1/projects/:id/auth/scim/tokens',
108 requireProjectRole('admin'),
109 async (c) => {
110 const projectId = c.req.param('id');
111 const items = await listScimTokens(projectId);
112 return c.json({ items });
113 },
114);
115
116authScimRouter.post(
117 '/v1/projects/:id/auth/scim/tokens',
118 requireProjectRole('admin'),
119 async (c) => {
120 const projectId = c.req.param('id');
121 const actor = c.get('user');
122 if (!actor) return c.json({ code: 'unauthorized' }, 401);
123 const body = (await c.req.json().catch(() => null)) as { name?: unknown } | null;
124 if (!body || typeof body.name !== 'string') {
125 return c.json({ code: 'validation_failed', message: 'name required' }, 400);
126 }
127 try {
128 const created = await createScimToken(projectId, body.name);
129 await audit({
130 actorId: actor.id,
131 projectId,
132 action: 'briven_auth.scim_token.created',
133 metadata: { tokenId: created.id, suffix: created.suffix },
134 });
135 return c.json(
136 {
137 id: created.id,
138 name: created.name,
139 prefix: created.prefix,
140 suffix: created.suffix,
141 createdAt: created.createdAt,
142 plaintext: created.plaintext,
143 },
144 201,
145 );
146 } catch (err) {
147 const message = err instanceof Error ? err.message : 'create failed';
148 return c.json({ code: 'validation_failed', message }, 400);
149 }
150 },
151);
152
153authScimRouter.delete(
154 '/v1/projects/:id/auth/scim/tokens/:tokenId',
155 requireProjectRole('admin'),
156 async (c) => {
157 const projectId = c.req.param('id');
158 const tokenId = c.req.param('tokenId');
159 const actor = c.get('user');
160 if (!actor) return c.json({ code: 'unauthorized' }, 401);
161 try {
162 await revokeScimToken(projectId, tokenId);
163 await audit({
164 actorId: actor.id,
165 projectId,
166 action: 'briven_auth.scim_token.revoked',
167 metadata: { tokenId },
168 });
169 return c.json({ ok: true });
170 } catch {
171 return c.json({ code: 'not_found' }, 404);
172 }
173 },
174);
175
176// ─── SCIM protocol ─────────────────────────────────────────────────────────
177
178authScimRouter.get('/v1/projects/:id/scim/v2/ServiceProviderConfig', async (c) => {
179 const gate = await requireScimBearer(c);
180 if (gate instanceof Response) return gate;
181 return scimJson(scimServiceProviderConfig());
182});
183
184authScimRouter.get('/v1/projects/:id/scim/v2/ResourceTypes', async (c) => {
185 const gate = await requireScimBearer(c);
186 if (gate instanceof Response) return gate;
187 return scimJson(scimResourceTypes(gate.projectId, apiOrigin()));
188});
189
190authScimRouter.get('/v1/projects/:id/scim/v2/Schemas', async (c) => {
191 const gate = await requireScimBearer(c);
192 if (gate instanceof Response) return gate;
193 return scimJson({
194 schemas: ['urn:ietf:params:scim:api:messages:2.0:ListResponse'],
195 totalResults: 2,
196 Resources: [
197 { id: 'urn:ietf:params:scim:schemas:core:2.0:User', name: 'User' },
198 { id: 'urn:ietf:params:scim:schemas:core:2.0:Group', name: 'Group' },
199 ],
200 });
201});
202
203authScimRouter.get('/v1/projects/:id/scim/v2/Users', async (c) => {
204 const gate = await requireScimBearer(c);
205 if (gate instanceof Response) return gate;
206 try {
207 const startIndex = Number(c.req.query('startIndex') ?? '1');
208 const count = Number(c.req.query('count') ?? '100');
209 const filter = c.req.query('filter') ?? undefined;
210 const list = await scimListUsers(
211 gate.projectId,
212 { filter, startIndex, count },
213 apiOrigin(),
214 );
215 return scimJson(list);
216 } catch (err) {
217 return scimCatch(err);
218 }
219});
220
221authScimRouter.post('/v1/projects/:id/scim/v2/Users', async (c) => {
222 const gate = await requireScimBearer(c);
223 if (gate instanceof Response) return gate;
224 try {
225 const body = await c.req.json();
226 const user = await scimCreateUser(gate.projectId, body, apiOrigin());
227 return scimJson(user, 201);
228 } catch (err) {
229 return scimCatch(err);
230 }
231});
232
233authScimRouter.get('/v1/projects/:id/scim/v2/Users/:userId', async (c) => {
234 const gate = await requireScimBearer(c);
235 if (gate instanceof Response) return gate;
236 try {
237 const user = await scimGetUser(gate.projectId, c.req.param('userId'), apiOrigin());
238 return scimJson(user);
239 } catch (err) {
240 return scimCatch(err);
241 }
242});
243
244authScimRouter.put('/v1/projects/:id/scim/v2/Users/:userId', async (c) => {
245 const gate = await requireScimBearer(c);
246 if (gate instanceof Response) return gate;
247 try {
248 const body = await c.req.json();
249 const user = await scimReplaceUser(gate.projectId, c.req.param('userId'), body, apiOrigin());
250 return scimJson(user);
251 } catch (err) {
252 return scimCatch(err);
253 }
254});
255
256authScimRouter.patch('/v1/projects/:id/scim/v2/Users/:userId', async (c) => {
257 const gate = await requireScimBearer(c);
258 if (gate instanceof Response) return gate;
259 try {
260 const body = await c.req.json();
261 const user = await scimPatchUser(gate.projectId, c.req.param('userId'), body, apiOrigin());
262 return scimJson(user);
263 } catch (err) {
264 return scimCatch(err);
265 }
266});
267
268authScimRouter.delete('/v1/projects/:id/scim/v2/Users/:userId', async (c) => {
269 const gate = await requireScimBearer(c);
270 if (gate instanceof Response) return gate;
271 try {
272 await scimDeleteUser(gate.projectId, c.req.param('userId'));
273 return new Response(null, { status: 204 });
274 } catch (err) {
275 return scimCatch(err);
276 }
277});
278
279authScimRouter.get('/v1/projects/:id/scim/v2/Groups', async (c) => {
280 const gate = await requireScimBearer(c);
281 if (gate instanceof Response) return gate;
282 try {
283 const startIndex = Number(c.req.query('startIndex') ?? '1');
284 const count = Number(c.req.query('count') ?? '100');
285 const list = await scimListGroups(gate.projectId, { startIndex, count }, apiOrigin());
286 return scimJson(list);
287 } catch (err) {
288 return scimCatch(err);
289 }
290});
291
292authScimRouter.post('/v1/projects/:id/scim/v2/Groups', async (c) => {
293 const gate = await requireScimBearer(c);
294 if (gate instanceof Response) return gate;
295 try {
296 const body = await c.req.json();
297 const group = await scimCreateGroup(gate.projectId, body, apiOrigin());
298 return scimJson(group, 201);
299 } catch (err) {
300 return scimCatch(err);
301 }
302});
303
304authScimRouter.get('/v1/projects/:id/scim/v2/Groups/:groupId', async (c) => {
305 const gate = await requireScimBearer(c);
306 if (gate instanceof Response) return gate;
307 try {
308 const group = await scimGetGroup(gate.projectId, c.req.param('groupId'), apiOrigin());
309 return scimJson(group);
310 } catch (err) {
311 return scimCatch(err);
312 }
313});
314
315authScimRouter.delete('/v1/projects/:id/scim/v2/Groups/:groupId', async (c) => {
316 const gate = await requireScimBearer(c);
317 if (gate instanceof Response) return gate;
318 try {
319 await scimDeleteGroup(gate.projectId, c.req.param('groupId'));
320 return new Response(null, { status: 204 });
321 } catch (err) {
322 return scimCatch(err);
323 }
324});