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
9import { Hono } from 'hono';
10
11import { ValidationError } from '@briven/shared';
12
13import { requireAuth } from '../middleware/session.js';
14import { requireProjectAuth, requireProjectRole } from '../middleware/project-auth.js';
15import { requireAuthTeamAdmin } from '../middleware/auth-team.js';
16import {
17 getAuthV2ProjectSnapshot,
18 hasAtLeastOneProvider,
19 listAuthV2Workspace,
20 saveAuthV2PasswordPolicy,
21 saveAuthV2Providers,
22 saveAuthV2TwoFactor,
23} from '../services/auth-v2-workspace.js';
24import type { AppEnv } from '../types/app-env.js';
25
26export const authV2Router = new Hono<AppEnv>();
27
28authV2Router.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 */
34authV2Router.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
47authV2Router.use('/v1/auth-v2/projects/:id/*', requireProjectAuth());
48authV2Router.use('/v1/auth-v2/projects/:id/*', requireAuthTeamAdmin());
49
50/**
51 * GET /v1/auth-v2/projects/:id/snapshot
52 */
53authV2Router.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 */
79authV2Router.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 */
143authV2Router.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 */
188authV2Router.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);