auth-core-project.ts688 lines · main
1/**
2 * briven-engine per-project config + multitenancy + MFA + passkeys.
3 * Project routes: dashboard session + project admin.
4 * MFA/passkey admin: dashboard session.
5 * DOLTGRES ONLY.
6 */
7
8import { Hono } from 'hono';
9
10import {
11 requireAuthCoreDashboard,
12 requireAuthCoreProject,
13} from '../middleware/auth-core-guard.js';
14import { BRIVEN_ENGINE_ID, isAuthCoreInitialized } from '../services/auth-core/engine.js';
15import {
16 getBrivenEngineProjectConfig,
17 setBrivenEngineBranding,
18 setBrivenEngineMethodFlags,
19 setBrivenEngineProviderSecrets,
20 setBrivenEngineSmsSecrets,
21 type BrivenEngineBranding,
22 type BrivenEngineMethodFlags,
23} from '../services/auth-core/project-config.js';
24import { sendBrivenEngineSmsTest } from '../services/auth-core/delivery.js';
25import { listBrivenEngineAudit } from '../services/auth-core/audit.js';
26import { recordBrivenEngineAudit } from '../services/auth-core/audit.js';
27import { env } from '../env.js';
28import {
29 ensureBrivenEngineTenant,
30 listBrivenEngineTenants,
31} from '../services/auth-core/multitenancy.js';
32import {
33 assignBrivenEngineRole,
34 createBrivenEngineRole,
35 getBrivenEngineUserRoles,
36 listBrivenEngineRoles,
37} from '../services/auth-core/roles.js';
38import {
39 createTotpDevice,
40 listTotpDevices,
41 removeTotpDevice,
42 verifyAndEnableTotpDevice,
43 verifyUserTotp,
44} from '../services/auth-core/mfa.js';
45import {
46 createAuthenticationOptions,
47 createRegistrationOptions,
48 deletePasskey,
49 finishAuthentication,
50 finishRegistration,
51 listPasskeys,
52} from '../services/auth-core/webauthn.js';
53import {
54 BRIVEN_ENGINE_SOCIAL_CATALOG,
55 type BrivenSocialProviderId,
56} from '../services/auth-core/providers.js';
57import type { AppEnv } from '../types/app-env.js';
58
59const SOCIAL_IDS = new Set(
60 BRIVEN_ENGINE_SOCIAL_CATALOG.map((p) => p.thirdPartyId),
61);
62
63export const authCoreProjectRouter = new Hono<AppEnv>();
64
65authCoreProjectRouter.use(
66 '/v1/auth-core/projects/:projectId/*',
67 ...requireAuthCoreProject('admin'),
68);
69authCoreProjectRouter.use('/v1/auth-core/tenants', requireAuthCoreDashboard());
70authCoreProjectRouter.use('/v1/auth-core/roles', requireAuthCoreDashboard());
71authCoreProjectRouter.use('/v1/auth-core/roles/*', requireAuthCoreDashboard());
72authCoreProjectRouter.use('/v1/auth-core/mfa/*', requireAuthCoreDashboard());
73authCoreProjectRouter.use('/v1/auth-core/passkeys/*', requireAuthCoreDashboard());
74authCoreProjectRouter.use('/v1/auth-core/users/*/roles', requireAuthCoreDashboard());
75
76authCoreProjectRouter.get('/v1/auth-core/projects/:projectId/config', async (c) => {
77 const projectId = c.req.param('projectId');
78 try {
79 const config = await getBrivenEngineProjectConfig(projectId);
80 return c.json(config);
81 } catch (err) {
82 return c.json(
83 {
84 engine: BRIVEN_ENGINE_ID,
85 code: 'config_error',
86 message: err instanceof Error ? err.message : String(err),
87 },
88 400,
89 );
90 }
91});
92
93authCoreProjectRouter.put(
94 '/v1/auth-core/projects/:projectId/providers/:thirdPartyId',
95 async (c) => {
96 const projectId = c.req.param('projectId');
97 const thirdPartyIdRaw = c.req.param('thirdPartyId');
98 const thirdPartyId = thirdPartyIdRaw as BrivenSocialProviderId;
99 if (!SOCIAL_IDS.has(thirdPartyId)) {
100 return c.json(
101 {
102 engine: BRIVEN_ENGINE_ID,
103 code: 'bad_request',
104 message: `unknown OAuth provider: ${thirdPartyIdRaw}`,
105 },
106 400,
107 );
108 }
109 let body: {
110 clientId?: string;
111 clientSecret?: string;
112 additionalConfig?: Record<string, string>;
113 } = {};
114 try {
115 body = await c.req.json();
116 } catch {
117 body = {};
118 }
119 const clientId = body.clientId?.trim() ?? '';
120 const clientSecret = body.clientSecret?.trim() ?? '';
121 if (!clientId || !clientSecret) {
122 return c.json(
123 {
124 engine: BRIVEN_ENGINE_ID,
125 code: 'bad_request',
126 message: 'clientId and clientSecret required (both non-empty)',
127 },
128 400,
129 );
130 }
131 try {
132 const result = await setBrivenEngineProviderSecrets(projectId, {
133 thirdPartyId,
134 clientId,
135 clientSecret,
136 additionalConfig: body.additionalConfig,
137 });
138 void recordBrivenEngineAudit({
139 action: 'config.oauth_secrets.saved',
140 projectId,
141 // Never log secret values — provider id only.
142 metadata: { thirdPartyId },
143 });
144 // Return public config so UI can show “configured”
145 const config = await getBrivenEngineProjectConfig(projectId);
146 const saved = config.providers.find((p) => p.thirdPartyId === thirdPartyId);
147 return c.json({
148 ...result,
149 config,
150 savedProvider: saved ?? null,
151 apiOrigin: env.BRIVEN_API_ORIGIN,
152 });
153 } catch (err) {
154 return c.json(
155 {
156 engine: BRIVEN_ENGINE_ID,
157 code: 'save_failed',
158 message: err instanceof Error ? err.message : String(err),
159 },
160 500,
161 );
162 }
163 },
164);
165
166/** Toggle which sign-in methods this project uses. */
167authCoreProjectRouter.put(
168 '/v1/auth-core/projects/:projectId/methods',
169 async (c) => {
170 const projectId = c.req.param('projectId');
171 let body: Partial<BrivenEngineMethodFlags> = {};
172 try {
173 body = await c.req.json();
174 } catch {
175 body = {};
176 }
177 try {
178 const result = await setBrivenEngineMethodFlags(projectId, body);
179 void recordBrivenEngineAudit({
180 action: 'config.methods.updated',
181 projectId,
182 metadata: { methods: result.methods },
183 });
184 const config = await getBrivenEngineProjectConfig(projectId);
185 return c.json({ ...result, config });
186 } catch (err) {
187 return c.json(
188 {
189 engine: BRIVEN_ENGINE_ID,
190 code: 'save_failed',
191 message: err instanceof Error ? err.message : String(err),
192 },
193 500,
194 );
195 }
196 },
197);
198
199authCoreProjectRouter.put(
200 '/v1/auth-core/projects/:projectId/delivery/sms',
201 async (c) => {
202 const projectId = c.req.param('projectId');
203 let body: {
204 accountSid?: string;
205 authToken?: string;
206 fromNumber?: string;
207 } = {};
208 try {
209 body = await c.req.json();
210 } catch {
211 body = {};
212 }
213 const accountSid = body.accountSid?.trim() ?? '';
214 const authToken = body.authToken?.trim() ?? '';
215 const fromNumber = body.fromNumber?.trim() ?? '';
216 if (!accountSid || !authToken || !fromNumber) {
217 return c.json(
218 {
219 engine: BRIVEN_ENGINE_ID,
220 code: 'bad_request',
221 message: 'accountSid, authToken, fromNumber required',
222 },
223 400,
224 );
225 }
226 if (!fromNumber.startsWith('+')) {
227 return c.json(
228 {
229 engine: BRIVEN_ENGINE_ID,
230 code: 'bad_request',
231 message:
232 'fromNumber must be E.164 (start with + and country code), e.g. +15551234567',
233 },
234 400,
235 );
236 }
237 try {
238 const result = await setBrivenEngineSmsSecrets(projectId, {
239 accountSid,
240 authToken,
241 fromNumber,
242 });
243 void recordBrivenEngineAudit({
244 action: 'config.sms_secrets.saved',
245 projectId,
246 metadata: { fromNumber },
247 });
248 const config = await getBrivenEngineProjectConfig(projectId);
249 return c.json({ ...result, config });
250 } catch (err) {
251 return c.json(
252 {
253 engine: BRIVEN_ENGINE_ID,
254 code: 'save_failed',
255 message: err instanceof Error ? err.message : String(err),
256 },
257 500,
258 );
259 }
260 },
261);
262
263/** Save login email / hosted UI branding for this project. */
264authCoreProjectRouter.put(
265 '/v1/auth-core/projects/:projectId/branding',
266 async (c) => {
267 const projectId = c.req.param('projectId');
268 let body: Partial<BrivenEngineBranding> = {};
269 try {
270 body = await c.req.json();
271 } catch {
272 body = {};
273 }
274 try {
275 const result = await setBrivenEngineBranding(projectId, body);
276 void recordBrivenEngineAudit({
277 action: 'config.branding.saved',
278 projectId,
279 metadata: {
280 hasLogo: Boolean(result.branding.logoUrl),
281 primaryColor: result.branding.primaryColor,
282 },
283 });
284 const config = await getBrivenEngineProjectConfig(projectId);
285 return c.json({ ...result, config });
286 } catch (err) {
287 return c.json(
288 {
289 engine: BRIVEN_ENGINE_ID,
290 code: 'save_failed',
291 message: err instanceof Error ? err.message : String(err),
292 },
293 500,
294 );
295 }
296 },
297);
298
299/** Security audit trail for this project (newest first). */
300authCoreProjectRouter.get(
301 '/v1/auth-core/projects/:projectId/audit',
302 async (c) => {
303 const projectId = c.req.param('projectId');
304 const limit = Number(c.req.query('limit') ?? '50');
305 const action = c.req.query('action') ?? null;
306 const userId = c.req.query('userId') ?? null;
307 try {
308 const result = await listBrivenEngineAudit({
309 projectId,
310 limit: Number.isFinite(limit) ? limit : 50,
311 action,
312 userId,
313 });
314 return c.json(result);
315 } catch (err) {
316 return c.json(
317 {
318 engine: BRIVEN_ENGINE_ID,
319 code: 'audit_list_failed',
320 message: err instanceof Error ? err.message : String(err),
321 },
322 500,
323 );
324 }
325 },
326);
327
328/** Send a test SMS with saved project secrets (no login code). */
329authCoreProjectRouter.post(
330 '/v1/auth-core/projects/:projectId/delivery/sms/test',
331 async (c) => {
332 const projectId = c.req.param('projectId');
333 let body: { phoneNumber?: string } = {};
334 try {
335 body = await c.req.json();
336 } catch {
337 body = {};
338 }
339 const phoneNumber = body.phoneNumber?.trim() ?? '';
340 if (!phoneNumber) {
341 return c.json(
342 {
343 engine: BRIVEN_ENGINE_ID,
344 code: 'bad_request',
345 message: 'phoneNumber required (E.164, e.g. +15551234567)',
346 },
347 400,
348 );
349 }
350 try {
351 const config = await getBrivenEngineProjectConfig(projectId);
352 if (!config.delivery.sms.configured) {
353 return c.json(
354 {
355 engine: BRIVEN_ENGINE_ID,
356 code: 'sms_not_configured',
357 ok: false,
358 message:
359 'SMS not set — save Account SID, Auth token, and From number first',
360 delivery: config.delivery.sms,
361 methods: config.methods,
362 },
363 400,
364 );
365 }
366 const result = await sendBrivenEngineSmsTest({ projectId, phoneNumber });
367 const status = result.ok ? 200 : result.mode === 'log' ? 400 : 502;
368 return c.json(
369 {
370 engine: BRIVEN_ENGINE_ID,
371 ok: result.ok,
372 delivery: result,
373 methods: config.methods,
374 passwordlessSmsEnabled: config.methods.passwordlessSms,
375 hint: result.ok
376 ? config.methods.passwordlessSms
377 ? 'Test sent. passwordless-sms is on for this project.'
378 : 'Test sent. Turn on passwordless-sms under Providers so apps can use phone login.'
379 : undefined,
380 },
381 status,
382 );
383 } catch (err) {
384 return c.json(
385 {
386 engine: BRIVEN_ENGINE_ID,
387 code: 'sms_test_failed',
388 ok: false,
389 message: err instanceof Error ? err.message : String(err),
390 },
391 500,
392 );
393 }
394 },
395);
396
397authCoreProjectRouter.post(
398 '/v1/auth-core/projects/:projectId/tenant',
399 async (c) => {
400 const projectId = c.req.param('projectId');
401 const result = await ensureBrivenEngineTenant(projectId);
402 return c.json(result, result.ok ? 200 : 503);
403 },
404);
405
406authCoreProjectRouter.get('/v1/auth-core/tenants', async (c) => {
407 const result = await listBrivenEngineTenants();
408 return c.json(result, result.ok ? 200 : 503);
409});
410
411authCoreProjectRouter.get('/v1/auth-core/roles', async (c) => {
412 return c.json(await listBrivenEngineRoles());
413});
414
415authCoreProjectRouter.post('/v1/auth-core/roles', async (c) => {
416 let body: {
417 role?: string;
418 permissions?: string[];
419 projectId?: string;
420 tenantId?: string;
421 } = {};
422 try {
423 body = await c.req.json();
424 } catch {
425 body = {};
426 }
427 if (!body.role) {
428 return c.json({ engine: BRIVEN_ENGINE_ID, code: 'role_required' }, 400);
429 }
430 return c.json(
431 await createBrivenEngineRole(body.role, body.permissions ?? [], {
432 projectId: body.projectId,
433 tenantId: body.tenantId,
434 }),
435 );
436});
437
438authCoreProjectRouter.post('/v1/auth-core/roles/assign', async (c) => {
439 let body: {
440 userId?: string;
441 role?: string;
442 projectId?: string;
443 tenantId?: string;
444 } = {};
445 try {
446 body = await c.req.json();
447 } catch {
448 body = {};
449 }
450 if (!body.userId || !body.role) {
451 return c.json(
452 { engine: BRIVEN_ENGINE_ID, code: 'userId_and_role_required' },
453 400,
454 );
455 }
456 return c.json(
457 await assignBrivenEngineRole(body.userId, body.role, {
458 projectId: body.projectId,
459 tenantId: body.tenantId,
460 }),
461 );
462});
463
464authCoreProjectRouter.get('/v1/auth-core/users/:userId/roles', async (c) => {
465 return c.json(
466 await getBrivenEngineUserRoles(c.req.param('userId'), {
467 projectId: c.req.query('projectId') ?? undefined,
468 tenantId: c.req.query('tenantId') ?? undefined,
469 }),
470 );
471});
472
473authCoreProjectRouter.get('/v1/auth-core/roles/list', async (c) => {
474 return c.json(
475 await listBrivenEngineRoles({
476 projectId: c.req.query('projectId') ?? undefined,
477 tenantId: c.req.query('tenantId') ?? undefined,
478 }),
479 );
480});
481// ─── MFA TOTP ───────────────────────────────────────────────────────
482authCoreProjectRouter.post('/v1/auth-core/mfa/totp', async (c) => {
483 if (!isAuthCoreInitialized()) {
484 return c.json({ engine: BRIVEN_ENGINE_ID, code: 'sdk_not_ready' }, 503);
485 }
486 let body: {
487 userId?: string;
488 deviceName?: string;
489 projectId?: string;
490 } = {};
491 try {
492 body = await c.req.json();
493 } catch {
494 body = {};
495 }
496 if (!body.userId) {
497 return c.json({ engine: BRIVEN_ENGINE_ID, code: 'userId_required' }, 400);
498 }
499 const result = await createTotpDevice(
500 body.userId,
501 body.deviceName ?? 'default',
502 { projectId: body.projectId },
503 );
504 return c.json(result, result.ok ? 200 : 400);
505});
506
507authCoreProjectRouter.post('/v1/auth-core/mfa/totp/verify', async (c) => {
508 let body: {
509 userId?: string;
510 deviceId?: string;
511 deviceName?: string;
512 code?: string;
513 } = {};
514 try {
515 body = await c.req.json();
516 } catch {
517 body = {};
518 }
519 if (!body.userId || !body.code) {
520 return c.json(
521 { engine: BRIVEN_ENGINE_ID, code: 'userId_and_code_required' },
522 400,
523 );
524 }
525 const result = await verifyAndEnableTotpDevice({
526 userId: body.userId,
527 deviceId: body.deviceId,
528 deviceName: body.deviceName,
529 code: body.code,
530 });
531 return c.json(result, result.ok ? 200 : 400);
532});
533
534authCoreProjectRouter.post('/v1/auth-core/mfa/totp/check', async (c) => {
535 let body: { userId?: string; code?: string } = {};
536 try {
537 body = await c.req.json();
538 } catch {
539 body = {};
540 }
541 if (!body.userId || !body.code) {
542 return c.json(
543 { engine: BRIVEN_ENGINE_ID, code: 'userId_and_code_required' },
544 400,
545 );
546 }
547 return c.json(await verifyUserTotp(body.userId, body.code));
548});
549
550authCoreProjectRouter.get('/v1/auth-core/mfa/totp/:userId', async (c) => {
551 return c.json(await listTotpDevices(c.req.param('userId')));
552});
553
554authCoreProjectRouter.delete(
555 '/v1/auth-core/mfa/totp/:userId/:deviceName',
556 async (c) => {
557 return c.json(
558 await removeTotpDevice(c.req.param('userId'), c.req.param('deviceName')),
559 );
560 },
561);
562
563// ─── Passkeys ───────────────────────────────────────────────────────
564authCoreProjectRouter.post('/v1/auth-core/passkeys/register/options', async (c) => {
565 let body: { userId?: string; userName?: string; projectId?: string } = {};
566 try {
567 body = await c.req.json();
568 } catch {
569 body = {};
570 }
571 if (!body.userId || !body.userName) {
572 return c.json(
573 { engine: BRIVEN_ENGINE_ID, code: 'userId_and_userName_required' },
574 400,
575 );
576 }
577 const result = await createRegistrationOptions({
578 userId: body.userId,
579 userName: body.userName,
580 projectId: body.projectId,
581 });
582 return c.json(result, result.status === 'OK' ? 200 : 400);
583});
584
585authCoreProjectRouter.post('/v1/auth-core/passkeys/register/finish', async (c) => {
586 let body: {
587 userId?: string;
588 challengeId?: string;
589 credentialId?: string;
590 publicKey?: string;
591 transports?: string[];
592 response?: unknown;
593 expectedOrigin?: string;
594 } = {};
595 try {
596 body = await c.req.json();
597 } catch {
598 body = {};
599 }
600 if (!body.userId || !body.challengeId) {
601 return c.json({ engine: BRIVEN_ENGINE_ID, code: 'bad_request' }, 400);
602 }
603 if (!body.response && (!body.credentialId || !body.publicKey)) {
604 return c.json(
605 {
606 engine: BRIVEN_ENGINE_ID,
607 code: 'bad_request',
608 message: 'response (WebAuthn JSON) or credentialId+publicKey required',
609 },
610 400,
611 );
612 }
613 const result = await finishRegistration({
614 userId: body.userId,
615 challengeId: body.challengeId,
616 // eslint-disable-next-line @typescript-eslint/no-explicit-any
617 response: body.response as any,
618 credentialId: body.credentialId,
619 publicKey: body.publicKey,
620 transports: body.transports,
621 expectedOrigin: body.expectedOrigin,
622 });
623 return c.json(result, result.status === 'OK' ? 200 : 400);
624});
625
626authCoreProjectRouter.post('/v1/auth-core/passkeys/authenticate/options', async (c) => {
627 let body: { userId?: string; projectId?: string } = {};
628 try {
629 body = await c.req.json();
630 } catch {
631 body = {};
632 }
633 const result = await createAuthenticationOptions({
634 userId: body.userId,
635 projectId: body.projectId,
636 });
637 return c.json(result, result.status === 'OK' ? 200 : 400);
638});
639
640authCoreProjectRouter.post('/v1/auth-core/passkeys/authenticate/finish', async (c) => {
641 let body: {
642 challengeId?: string;
643 credentialId?: string;
644 projectId?: string;
645 response?: unknown;
646 expectedOrigin?: string;
647 } = {};
648 try {
649 body = await c.req.json();
650 } catch {
651 body = {};
652 }
653 if (!body.challengeId) {
654 return c.json({ engine: BRIVEN_ENGINE_ID, code: 'bad_request' }, 400);
655 }
656 if (!body.response && !body.credentialId) {
657 return c.json(
658 {
659 engine: BRIVEN_ENGINE_ID,
660 code: 'bad_request',
661 message: 'response or credentialId required',
662 },
663 400,
664 );
665 }
666 const result = await finishAuthentication({
667 challengeId: body.challengeId,
668 credentialId: body.credentialId,
669 // eslint-disable-next-line @typescript-eslint/no-explicit-any
670 response: body.response as any,
671 projectId: body.projectId,
672 expectedOrigin: body.expectedOrigin,
673 });
674 return c.json(result, result.status === 'OK' ? 200 : 400);
675});
676
677authCoreProjectRouter.get('/v1/auth-core/passkeys/:userId', async (c) => {
678 return c.json(await listPasskeys(c.req.param('userId')));
679});
680
681authCoreProjectRouter.delete(
682 '/v1/auth-core/passkeys/:userId/:credentialId',
683 async (c) => {
684 return c.json(
685 await deletePasskey(c.req.param('userId'), c.req.param('credentialId')),
686 );
687 },
688);