auth-v2.ts240 lines · main
| 1 | /** |
| 2 | * Briven Auth v2 dashboard API — SuperTokens-style workspace ops. |
| 3 | * Mounted at /v1/auth-v2/* |
| 4 | * |
| 5 | * Phase 1: list projects, save providers with read-back proof, project snapshot. |
| 6 | * Runtime login still uses /v1/auth-tenant (Better Auth pool) until full engine swap. |
| 7 | */ |
| 8 | |
| 9 | import { Hono } from 'hono'; |
| 10 | |
| 11 | import { ValidationError } from '@briven/shared'; |
| 12 | |
| 13 | import { requireAuth } from '../middleware/session.js'; |
| 14 | import { requireProjectAuth, requireProjectRole } from '../middleware/project-auth.js'; |
| 15 | import { requireAuthTeamAdmin } from '../middleware/auth-team.js'; |
| 16 | import { |
| 17 | getAuthV2ProjectSnapshot, |
| 18 | hasAtLeastOneProvider, |
| 19 | listAuthV2Workspace, |
| 20 | saveAuthV2PasswordPolicy, |
| 21 | saveAuthV2Providers, |
| 22 | saveAuthV2TwoFactor, |
| 23 | } from '../services/auth-v2-workspace.js'; |
| 24 | import type { AppEnv } from '../types/app-env.js'; |
| 25 | |
| 26 | export const authV2Router = new Hono<AppEnv>(); |
| 27 | |
| 28 | authV2Router.use('/v1/auth-v2/*', requireAuth()); |
| 29 | |
| 30 | /** |
| 31 | * GET /v1/auth-v2/workspace |
| 32 | * All projects for the signed-in user + Auth enable + core provider flags. |
| 33 | */ |
| 34 | authV2Router.get('/v1/auth-v2/workspace', async (c) => { |
| 35 | const user = c.get('user'); |
| 36 | if (!user) return c.json({ code: 'unauthorized' }, 401); |
| 37 | const workspace = await listAuthV2Workspace(user.id); |
| 38 | return c.json({ |
| 39 | ok: true, |
| 40 | phase: 1, |
| 41 | engine: 'briven-auth-v2', |
| 42 | note: 'login runtime still /v1/auth-tenant; dashboard config is Auth v2', |
| 43 | ...workspace, |
| 44 | }); |
| 45 | }); |
| 46 | |
| 47 | authV2Router.use('/v1/auth-v2/projects/:id/*', requireProjectAuth()); |
| 48 | authV2Router.use('/v1/auth-v2/projects/:id/*', requireAuthTeamAdmin()); |
| 49 | |
| 50 | /** |
| 51 | * GET /v1/auth-v2/projects/:id/snapshot |
| 52 | */ |
| 53 | authV2Router.get( |
| 54 | '/v1/auth-v2/projects/:id/snapshot', |
| 55 | requireProjectRole('admin'), |
| 56 | async (c) => { |
| 57 | const projectId = c.req.param('id'); |
| 58 | if (!projectId) return c.json({ code: 'validation_failed', message: 'missing :id' }, 400); |
| 59 | try { |
| 60 | const snap = await getAuthV2ProjectSnapshot(projectId); |
| 61 | return c.json({ ok: true, projectId, ...snap }); |
| 62 | } catch (err) { |
| 63 | return c.json( |
| 64 | { |
| 65 | code: 'snapshot_failed', |
| 66 | message: err instanceof Error ? err.message : String(err), |
| 67 | }, |
| 68 | 500, |
| 69 | ); |
| 70 | } |
| 71 | }, |
| 72 | ); |
| 73 | |
| 74 | /** |
| 75 | * PUT /v1/auth-v2/projects/:id/providers |
| 76 | * Body: { emailPassword, magicLink, emailOtp, passkey } booleans. |
| 77 | * Returns live providers after re-read (save-sticks proof). |
| 78 | */ |
| 79 | authV2Router.put( |
| 80 | '/v1/auth-v2/projects/:id/providers', |
| 81 | requireProjectRole('admin'), |
| 82 | async (c) => { |
| 83 | const projectId = c.req.param('id'); |
| 84 | if (!projectId) return c.json({ code: 'validation_failed', message: 'missing :id' }, 400); |
| 85 | |
| 86 | const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null; |
| 87 | if (!body || typeof body !== 'object') { |
| 88 | return c.json({ code: 'validation_failed', message: 'body must be JSON' }, 400); |
| 89 | } |
| 90 | |
| 91 | const flags = { |
| 92 | emailPassword: body.emailPassword === true, |
| 93 | magicLink: body.magicLink === true, |
| 94 | emailOtp: body.emailOtp === true, |
| 95 | passkey: body.passkey === true, |
| 96 | }; |
| 97 | |
| 98 | if (!hasAtLeastOneProvider(flags)) { |
| 99 | return c.json( |
| 100 | { |
| 101 | code: 'validation_failed', |
| 102 | message: 'turn on at least one method (password, magic link, OTP, or passkey)', |
| 103 | }, |
| 104 | 400, |
| 105 | ); |
| 106 | } |
| 107 | |
| 108 | try { |
| 109 | const result = await saveAuthV2Providers(projectId, flags); |
| 110 | return c.json({ |
| 111 | ok: true, |
| 112 | projectId, |
| 113 | saved: true, |
| 114 | savedAt: new Date().toISOString(), |
| 115 | enabled: result.enabled, |
| 116 | providers: result.providers, |
| 117 | // proof: what DB returns after invalidate |
| 118 | proof: result.providers, |
| 119 | }); |
| 120 | } catch (err) { |
| 121 | const msg = err instanceof Error ? err.message : String(err); |
| 122 | if (msg === 'auth_not_enabled') { |
| 123 | return c.json( |
| 124 | { |
| 125 | code: 'auth_not_enabled', |
| 126 | message: 'enable Auth for this project first', |
| 127 | }, |
| 128 | 400, |
| 129 | ); |
| 130 | } |
| 131 | if (err instanceof ValidationError) { |
| 132 | return c.json({ code: 'validation_failed', message: err.message }, 400); |
| 133 | } |
| 134 | return c.json({ code: 'save_failed', message: msg }, 500); |
| 135 | } |
| 136 | }, |
| 137 | ); |
| 138 | |
| 139 | /** |
| 140 | * PUT /v1/auth-v2/projects/:id/two-factor |
| 141 | * Body: { enabled, required }. When enabled, end-users get TOTP + 10 backup codes. |
| 142 | */ |
| 143 | authV2Router.put( |
| 144 | '/v1/auth-v2/projects/:id/two-factor', |
| 145 | requireProjectRole('admin'), |
| 146 | async (c) => { |
| 147 | const projectId = c.req.param('id'); |
| 148 | if (!projectId) return c.json({ code: 'validation_failed', message: 'missing :id' }, 400); |
| 149 | |
| 150 | const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null; |
| 151 | if (!body || typeof body !== 'object') { |
| 152 | return c.json({ code: 'validation_failed', message: 'body must be JSON' }, 400); |
| 153 | } |
| 154 | |
| 155 | try { |
| 156 | const result = await saveAuthV2TwoFactor(projectId, { |
| 157 | enabled: body.enabled === true, |
| 158 | required: body.required === true, |
| 159 | }); |
| 160 | return c.json({ |
| 161 | ok: true, |
| 162 | projectId, |
| 163 | saved: true, |
| 164 | savedAt: new Date().toISOString(), |
| 165 | twoFactor: result.twoFactor, |
| 166 | proof: result.twoFactor, |
| 167 | }); |
| 168 | } catch (err) { |
| 169 | const msg = err instanceof Error ? err.message : String(err); |
| 170 | if (msg === 'auth_not_enabled') { |
| 171 | return c.json( |
| 172 | { code: 'auth_not_enabled', message: 'enable Auth for this project first' }, |
| 173 | 400, |
| 174 | ); |
| 175 | } |
| 176 | if (err instanceof ValidationError) { |
| 177 | return c.json({ code: 'validation_failed', message: err.message }, 400); |
| 178 | } |
| 179 | return c.json({ code: 'save_failed', message: msg }, 500); |
| 180 | } |
| 181 | }, |
| 182 | ); |
| 183 | |
| 184 | /** |
| 185 | * PUT /v1/auth-v2/projects/:id/password-policy |
| 186 | * Body: PasswordPolicy partial. Returns live policy after save. |
| 187 | */ |
| 188 | authV2Router.put( |
| 189 | '/v1/auth-v2/projects/:id/password-policy', |
| 190 | requireProjectRole('admin'), |
| 191 | async (c) => { |
| 192 | const projectId = c.req.param('id'); |
| 193 | if (!projectId) return c.json({ code: 'validation_failed', message: 'missing :id' }, 400); |
| 194 | |
| 195 | const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null; |
| 196 | if (!body || typeof body !== 'object') { |
| 197 | return c.json({ code: 'validation_failed', message: 'body must be JSON' }, 400); |
| 198 | } |
| 199 | |
| 200 | try { |
| 201 | const result = await saveAuthV2PasswordPolicy(projectId, { |
| 202 | minLength: typeof body.minLength === 'number' ? body.minLength : undefined, |
| 203 | requireUppercase: |
| 204 | typeof body.requireUppercase === 'boolean' ? body.requireUppercase : undefined, |
| 205 | requireLowercase: |
| 206 | typeof body.requireLowercase === 'boolean' ? body.requireLowercase : undefined, |
| 207 | requireNumber: typeof body.requireNumber === 'boolean' ? body.requireNumber : undefined, |
| 208 | requireSpecial: |
| 209 | typeof body.requireSpecial === 'boolean' ? body.requireSpecial : undefined, |
| 210 | maxAgeDays: |
| 211 | body.maxAgeDays === null |
| 212 | ? null |
| 213 | : typeof body.maxAgeDays === 'number' |
| 214 | ? body.maxAgeDays |
| 215 | : undefined, |
| 216 | preventReuse: typeof body.preventReuse === 'number' ? body.preventReuse : undefined, |
| 217 | }); |
| 218 | return c.json({ |
| 219 | ok: true, |
| 220 | projectId, |
| 221 | saved: true, |
| 222 | savedAt: new Date().toISOString(), |
| 223 | passwordPolicy: result.passwordPolicy, |
| 224 | proof: result.passwordPolicy, |
| 225 | }); |
| 226 | } catch (err) { |
| 227 | const msg = err instanceof Error ? err.message : String(err); |
| 228 | if (msg === 'auth_not_enabled') { |
| 229 | return c.json( |
| 230 | { code: 'auth_not_enabled', message: 'enable Auth for this project first' }, |
| 231 | 400, |
| 232 | ); |
| 233 | } |
| 234 | if (err instanceof ValidationError) { |
| 235 | return c.json({ code: 'validation_failed', message: err.message }, 400); |
| 236 | } |
| 237 | return c.json({ code: 'save_failed', message: msg }, 500); |
| 238 | } |
| 239 | }, |
| 240 | ); |