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 | |
| 11 | import { Hono } from 'hono'; |
| 12 | |
| 13 | import { env } from '../env.js'; |
| 14 | import { requireProjectAuth, requireProjectRole } from '../middleware/project-auth.js'; |
| 15 | import { requireAuthTeamAdmin } from '../middleware/auth-team.js'; |
| 16 | import { audit } from '../services/audit.js'; |
| 17 | import { |
| 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'; |
| 36 | import type { ProjectAppEnv as AppEnv } from '../types/app-env.js'; |
| 37 | |
| 38 | export const authScimRouter = new Hono<AppEnv>(); |
| 39 | |
| 40 | function apiOrigin(): string { |
| 41 | return env.BRIVEN_API_ORIGIN || 'https://api.briven.tech'; |
| 42 | } |
| 43 | |
| 44 | function 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 | |
| 51 | async 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 | |
| 76 | function 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 | |
| 94 | function 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 | |
| 103 | authScimRouter.use('/v1/projects/:id/auth/scim/*', requireProjectAuth()); |
| 104 | authScimRouter.use('/v1/projects/:id/auth/scim/*', requireAuthTeamAdmin()); |
| 105 | |
| 106 | authScimRouter.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 | |
| 116 | authScimRouter.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 | |
| 153 | authScimRouter.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 | |
| 178 | authScimRouter.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 | |
| 184 | authScimRouter.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 | |
| 190 | authScimRouter.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 | |
| 203 | authScimRouter.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 | |
| 221 | authScimRouter.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 | |
| 233 | authScimRouter.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 | |
| 244 | authScimRouter.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 | |
| 256 | authScimRouter.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 | |
| 268 | authScimRouter.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 | |
| 279 | authScimRouter.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 | |
| 292 | authScimRouter.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 | |
| 304 | authScimRouter.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 | |
| 315 | authScimRouter.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 | }); |