auth-core-project.ts1099 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 setBrivenEngineJwtClaims,
19 setBrivenEngineMethodFlags,
20 clearBrivenEngineProviderSecrets,
21 setBrivenEngineProviderSecrets,
22 setBrivenEngineSmsSecrets,
23 setBrivenEngineUsernameLogin,
24 type BrivenEngineBranding,
25 type BrivenEngineMethodFlags,
26} from '../services/auth-core/project-config.js';
27import { sendBrivenEngineSmsTest } from '../services/auth-core/delivery.js';
28import { listBrivenEngineAudit } from '../services/auth-core/audit.js';
29import { recordBrivenEngineAudit } from '../services/auth-core/audit.js';
30import { env } from '../env.js';
31import { ValidationError } from '@briven/shared';
32import {
33 brandingLogoPublicUrl,
34 deleteBrandingLogo,
35 isStorageConfigured,
36 putBrandingLogo,
37 validateLogoUpload,
38} from '../services/auth-branding-logo.js';
39import { updateAuthConfig } from '../services/tenant-config-store.js';
40import { invalidateAuthInstance } from '../services/auth-tenant-pool.js';
41import { log } from '../lib/logger.js';
42import {
43 ensureBrivenEngineTenant,
44 listBrivenEngineTenants,
45} from '../services/auth-core/multitenancy.js';
46import {
47 assignBrivenEngineRole,
48 createBrivenEngineRole,
49 getBrivenEngineUserRoles,
50 listBrivenEngineRoles,
51} from '../services/auth-core/roles.js';
52import {
53 createTotpDevice,
54 listTotpDevices,
55 removeTotpDevice,
56 verifyAndEnableTotpDevice,
57 verifyUserTotp,
58} from '../services/auth-core/mfa.js';
59import {
60 createAuthenticationOptions,
61 createRegistrationOptions,
62 deletePasskey,
63 finishAuthentication,
64 finishRegistration,
65 listPasskeys,
66} from '../services/auth-core/webauthn.js';
67import {
68 BRIVEN_ENGINE_SOCIAL_CATALOG,
69 type BrivenSocialProviderId,
70} from '../services/auth-core/providers.js';
71import type { AppEnv } from '../types/app-env.js';
72
73const SOCIAL_IDS = new Set(
74 BRIVEN_ENGINE_SOCIAL_CATALOG.map((p) => p.thirdPartyId),
75);
76
77export const authCoreProjectRouter = new Hono<AppEnv>();
78
79authCoreProjectRouter.use(
80 '/v1/auth-core/projects/:projectId/*',
81 ...requireAuthCoreProject('admin'),
82);
83authCoreProjectRouter.use('/v1/auth-core/tenants', requireAuthCoreDashboard());
84authCoreProjectRouter.use('/v1/auth-core/roles', requireAuthCoreDashboard());
85authCoreProjectRouter.use('/v1/auth-core/roles/*', requireAuthCoreDashboard());
86authCoreProjectRouter.use('/v1/auth-core/mfa/*', requireAuthCoreDashboard());
87authCoreProjectRouter.use('/v1/auth-core/passkeys/*', requireAuthCoreDashboard());
88authCoreProjectRouter.use('/v1/auth-core/users/*/roles', requireAuthCoreDashboard());
89
90authCoreProjectRouter.get('/v1/auth-core/projects/:projectId/config', async (c) => {
91 const projectId = c.req.param('projectId');
92 try {
93 const config = await getBrivenEngineProjectConfig(projectId);
94 return c.json(config);
95 } catch (err) {
96 return c.json(
97 {
98 engine: BRIVEN_ENGINE_ID,
99 code: 'config_error',
100 message: err instanceof Error ? err.message : String(err),
101 },
102 400,
103 );
104 }
105});
106
107/** Golden-path checklist for Auth → project overview. */
108authCoreProjectRouter.get(
109 '/v1/auth-core/projects/:projectId/setup-status',
110 async (c) => {
111 const projectId = c.req.param('projectId');
112 try {
113 const { getAuthSetupStatus } = await import(
114 '../services/auth-core/setup.js'
115 );
116 const status = await getAuthSetupStatus(projectId);
117 return c.json(status);
118 } catch (err) {
119 return c.json(
120 {
121 engine: BRIVEN_ENGINE_ID,
122 code: 'setup_status_failed',
123 message: err instanceof Error ? err.message : String(err),
124 },
125 500,
126 );
127 }
128 },
129);
130
131/**
132 * One-click Finish setup: enable Auth, starter methods, localhost origin,
133 * mint browser key if missing. Optional body.productionOrigin.
134 */
135authCoreProjectRouter.post(
136 '/v1/auth-core/projects/:projectId/setup-finish',
137 async (c) => {
138 const projectId = c.req.param('projectId');
139 const user = c.get('user');
140 if (!user?.id) {
141 return c.json(
142 {
143 engine: BRIVEN_ENGINE_ID,
144 code: 'unauthorized',
145 message: 'dashboard session required',
146 },
147 401,
148 );
149 }
150 let body: { productionOrigin?: string } = {};
151 try {
152 body = (await c.req.json()) as { productionOrigin?: string };
153 } catch {
154 body = {};
155 }
156 try {
157 const { finishAuthSetup } = await import(
158 '../services/auth-core/setup.js'
159 );
160 const result = await finishAuthSetup(projectId, {
161 userId: user.id,
162 productionOrigin: body.productionOrigin ?? null,
163 });
164 return c.json(result);
165 } catch (err) {
166 return c.json(
167 {
168 engine: BRIVEN_ENGINE_ID,
169 code: 'setup_finish_failed',
170 message: err instanceof Error ? err.message : String(err),
171 },
172 500,
173 );
174 }
175 },
176);
177
178/** Replace or list app origins (CORS / passkey / golden path). */
179authCoreProjectRouter.put(
180 '/v1/auth-core/projects/:projectId/app-origins',
181 async (c) => {
182 const projectId = c.req.param('projectId');
183 let body: { origins?: string[] } = {};
184 try {
185 body = (await c.req.json()) as { origins?: string[] };
186 } catch {
187 body = {};
188 }
189 try {
190 const { setBrivenEngineAppOrigins } = await import(
191 '../services/auth-core/project-config.js'
192 );
193 const result = await setBrivenEngineAppOrigins(
194 projectId,
195 Array.isArray(body.origins) ? body.origins : [],
196 c.get('user')?.id ?? null,
197 );
198 void recordBrivenEngineAudit({
199 action: 'config.app_origins.updated',
200 projectId,
201 metadata: { count: result.appOrigins.length },
202 });
203 return c.json(result);
204 } catch (err) {
205 return c.json(
206 {
207 engine: BRIVEN_ENGINE_ID,
208 code: 'save_failed',
209 message: err instanceof Error ? err.message : String(err),
210 },
211 500,
212 );
213 }
214 },
215);
216
217authCoreProjectRouter.put(
218 '/v1/auth-core/projects/:projectId/providers/:thirdPartyId',
219 async (c) => {
220 const projectId = c.req.param('projectId');
221 const thirdPartyIdRaw = c.req.param('thirdPartyId');
222 const thirdPartyId = thirdPartyIdRaw as BrivenSocialProviderId;
223 if (!SOCIAL_IDS.has(thirdPartyId)) {
224 return c.json(
225 {
226 engine: BRIVEN_ENGINE_ID,
227 code: 'bad_request',
228 message: `unknown OAuth provider: ${thirdPartyIdRaw}`,
229 },
230 400,
231 );
232 }
233 let body: {
234 clientId?: string;
235 clientSecret?: string;
236 additionalConfig?: Record<string, string>;
237 } = {};
238 try {
239 body = await c.req.json();
240 } catch {
241 body = {};
242 }
243 const clientId = body.clientId?.trim() ?? '';
244 const clientSecret = body.clientSecret?.trim() ?? '';
245 if (!clientId || !clientSecret) {
246 return c.json(
247 {
248 engine: BRIVEN_ENGINE_ID,
249 code: 'bad_request',
250 message: 'clientId and clientSecret required (both non-empty)',
251 },
252 400,
253 );
254 }
255 try {
256 const result = await setBrivenEngineProviderSecrets(projectId, {
257 thirdPartyId,
258 clientId,
259 clientSecret,
260 additionalConfig: body.additionalConfig,
261 });
262 void recordBrivenEngineAudit({
263 action: 'config.oauth_secrets.saved',
264 projectId,
265 // Never log secret values — provider id only.
266 metadata: { thirdPartyId },
267 });
268 // Return public config so UI can show “configured”
269 const config = await getBrivenEngineProjectConfig(projectId);
270 const saved = config.providers.find((p) => p.thirdPartyId === thirdPartyId);
271 return c.json({
272 ...result,
273 config,
274 savedProvider: saved ?? null,
275 apiOrigin: env.BRIVEN_API_ORIGIN,
276 });
277 } catch (err) {
278 return c.json(
279 {
280 engine: BRIVEN_ENGINE_ID,
281 code: 'save_failed',
282 message: err instanceof Error ? err.message : String(err),
283 },
284 500,
285 );
286 }
287 },
288);
289
290/**
291 * Revoke OAuth provider secrets for a project (delete client id + secret).
292 * UI returns to empty / not configured.
293 */
294authCoreProjectRouter.delete(
295 '/v1/auth-core/projects/:projectId/providers/:thirdPartyId',
296 async (c) => {
297 const projectId = c.req.param('projectId');
298 const thirdPartyIdRaw = c.req.param('thirdPartyId');
299 const thirdPartyId = thirdPartyIdRaw as BrivenSocialProviderId;
300 if (!SOCIAL_IDS.has(thirdPartyId)) {
301 return c.json(
302 {
303 engine: BRIVEN_ENGINE_ID,
304 code: 'bad_request',
305 message: `unknown OAuth provider: ${thirdPartyIdRaw}`,
306 },
307 400,
308 );
309 }
310 try {
311 const result = await clearBrivenEngineProviderSecrets(
312 projectId,
313 thirdPartyId,
314 );
315 void recordBrivenEngineAudit({
316 action: 'config.oauth_secrets.revoked',
317 projectId,
318 metadata: { thirdPartyId },
319 });
320 const config = await getBrivenEngineProjectConfig(projectId);
321 const cleared = config.providers.find(
322 (p) => p.thirdPartyId === thirdPartyId,
323 );
324 return c.json({
325 ...result,
326 config,
327 savedProvider: cleared ?? null,
328 message: `${thirdPartyId} client id and secret deleted for this project`,
329 });
330 } catch (err) {
331 return c.json(
332 {
333 engine: BRIVEN_ENGINE_ID,
334 code: 'revoke_failed',
335 message: err instanceof Error ? err.message : String(err),
336 },
337 500,
338 );
339 }
340 },
341);
342
343/** Toggle which sign-in methods this project uses. */
344authCoreProjectRouter.put(
345 '/v1/auth-core/projects/:projectId/methods',
346 async (c) => {
347 const projectId = c.req.param('projectId');
348 let body: Partial<BrivenEngineMethodFlags> = {};
349 try {
350 body = await c.req.json();
351 } catch {
352 body = {};
353 }
354 try {
355 const result = await setBrivenEngineMethodFlags(projectId, body);
356 void recordBrivenEngineAudit({
357 action: 'config.methods.updated',
358 projectId,
359 metadata: { methods: result.methods },
360 });
361 const config = await getBrivenEngineProjectConfig(projectId);
362 return c.json({ ...result, config });
363 } catch (err) {
364 return c.json(
365 {
366 engine: BRIVEN_ENGINE_ID,
367 code: 'save_failed',
368 message: err instanceof Error ? err.message : String(err),
369 },
370 500,
371 );
372 }
373 },
374);
375
376/** Custom OIDC ID-token claim templates (string/number/boolean values). */
377authCoreProjectRouter.put(
378 '/v1/auth-core/projects/:projectId/jwt-claims',
379 async (c) => {
380 const projectId = c.req.param('projectId');
381 let body: Record<string, string | number | boolean> = {};
382 try {
383 body = (await c.req.json()) as Record<string, string | number | boolean>;
384 } catch {
385 body = {};
386 }
387 try {
388 const result = await setBrivenEngineJwtClaims(projectId, body);
389 void recordBrivenEngineAudit({
390 action: 'config.jwt_claims.updated',
391 projectId,
392 metadata: { keys: Object.keys(result.jwtClaims) },
393 });
394 const config = await getBrivenEngineProjectConfig(projectId);
395 return c.json({ ...result, config });
396 } catch (err) {
397 return c.json(
398 {
399 engine: BRIVEN_ENGINE_ID,
400 code: 'save_failed',
401 message: err instanceof Error ? err.message : String(err),
402 },
403 500,
404 );
405 }
406 },
407);
408
409/** Allow email/password sign-in with metadata.username when true. */
410authCoreProjectRouter.put(
411 '/v1/auth-core/projects/:projectId/username-login',
412 async (c) => {
413 const projectId = c.req.param('projectId');
414 let body: { enabled?: boolean } = {};
415 try {
416 body = (await c.req.json()) as { enabled?: boolean };
417 } catch {
418 body = {};
419 }
420 try {
421 const result = await setBrivenEngineUsernameLogin(
422 projectId,
423 Boolean(body.enabled),
424 );
425 void recordBrivenEngineAudit({
426 action: 'config.username_login.updated',
427 projectId,
428 metadata: { enabled: result.usernameLogin },
429 });
430 const config = await getBrivenEngineProjectConfig(projectId);
431 return c.json({ ...result, config });
432 } catch (err) {
433 return c.json(
434 {
435 engine: BRIVEN_ENGINE_ID,
436 code: 'save_failed',
437 message: err instanceof Error ? err.message : String(err),
438 },
439 500,
440 );
441 }
442 },
443);
444
445authCoreProjectRouter.put(
446 '/v1/auth-core/projects/:projectId/delivery/sms',
447 async (c) => {
448 const projectId = c.req.param('projectId');
449 let body: {
450 accountSid?: string;
451 authToken?: string;
452 fromNumber?: string;
453 } = {};
454 try {
455 body = await c.req.json();
456 } catch {
457 body = {};
458 }
459 const accountSid = body.accountSid?.trim() ?? '';
460 const authToken = body.authToken?.trim() ?? '';
461 const fromNumber = body.fromNumber?.trim() ?? '';
462 if (!accountSid || !authToken || !fromNumber) {
463 return c.json(
464 {
465 engine: BRIVEN_ENGINE_ID,
466 code: 'bad_request',
467 message: 'accountSid, authToken, fromNumber required',
468 },
469 400,
470 );
471 }
472 if (!fromNumber.startsWith('+')) {
473 return c.json(
474 {
475 engine: BRIVEN_ENGINE_ID,
476 code: 'bad_request',
477 message:
478 'fromNumber must be E.164 (start with + and country code), e.g. +15551234567',
479 },
480 400,
481 );
482 }
483 try {
484 const result = await setBrivenEngineSmsSecrets(projectId, {
485 accountSid,
486 authToken,
487 fromNumber,
488 });
489 void recordBrivenEngineAudit({
490 action: 'config.sms_secrets.saved',
491 projectId,
492 metadata: { fromNumber },
493 });
494 const config = await getBrivenEngineProjectConfig(projectId);
495 return c.json({ ...result, config });
496 } catch (err) {
497 return c.json(
498 {
499 engine: BRIVEN_ENGINE_ID,
500 code: 'save_failed',
501 message: err instanceof Error ? err.message : String(err),
502 },
503 500,
504 );
505 }
506 },
507);
508
509/** Save login email / hosted UI branding for this project. */
510authCoreProjectRouter.put(
511 '/v1/auth-core/projects/:projectId/branding',
512 async (c) => {
513 const projectId = c.req.param('projectId');
514 let body: Partial<BrivenEngineBranding> = {};
515 try {
516 body = await c.req.json();
517 } catch {
518 body = {};
519 }
520 try {
521 // Logo is managed only by POST/DELETE …/branding/logo — ignore logoUrl
522 // on this PUT so a partial form save never wipes an uploaded logo.
523 const { logoUrl: _ignoreLogo, ...rest } = body;
524 const result = await setBrivenEngineBranding(projectId, rest);
525 void recordBrivenEngineAudit({
526 action: 'config.branding.saved',
527 projectId,
528 metadata: {
529 hasLogo: Boolean(result.branding.logoUrl),
530 primaryColor: result.branding.primaryColor,
531 senderName: result.branding.senderName,
532 },
533 });
534 const config = await getBrivenEngineProjectConfig(projectId);
535 return c.json({
536 ...result,
537 branding: result.branding,
538 config,
539 });
540 } catch (err) {
541 return c.json(
542 {
543 engine: BRIVEN_ENGINE_ID,
544 code: 'save_failed',
545 message: err instanceof Error ? err.message : String(err),
546 },
547 500,
548 );
549 }
550 },
551);
552
553/**
554 * Upload project logo (multipart field `file`). Dashboard session path —
555 * same auth as other auth-core project routes so CSRF/cookies work via the
556 * web proxy (the bare /v1/projects/…/logo rewrite often fails CSRF).
557 */
558authCoreProjectRouter.post(
559 '/v1/auth-core/projects/:projectId/branding/logo',
560 async (c) => {
561 const projectId = c.req.param('projectId');
562 if (!isStorageConfigured()) {
563 return c.json(
564 {
565 engine: BRIVEN_ENGINE_ID,
566 code: 'storage_not_configured',
567 message: 'file storage is not configured on this api',
568 },
569 503,
570 );
571 }
572
573 let file: File | null = null;
574 try {
575 const body = await c.req.parseBody();
576 const f = body.file;
577 if (f instanceof File) file = f;
578 } catch {
579 return c.json(
580 {
581 engine: BRIVEN_ENGINE_ID,
582 code: 'validation_failed',
583 message: 'expected multipart form-data with field `file`',
584 },
585 400,
586 );
587 }
588 if (!file) {
589 return c.json(
590 {
591 engine: BRIVEN_ENGINE_ID,
592 code: 'validation_failed',
593 message: 'missing `file` form field',
594 },
595 400,
596 );
597 }
598
599 try {
600 let contentType = file.type || '';
601 if (!contentType && file.name) {
602 const lower = file.name.toLowerCase();
603 if (lower.endsWith('.png')) contentType = 'image/png';
604 else if (lower.endsWith('.jpg') || lower.endsWith('.jpeg'))
605 contentType = 'image/jpeg';
606 else if (lower.endsWith('.webp')) contentType = 'image/webp';
607 else if (lower.endsWith('.svg')) contentType = 'image/svg+xml';
608 }
609 validateLogoUpload({ contentType, size: file.size });
610 const bytes = new Uint8Array(await file.arrayBuffer());
611 await putBrandingLogo({ projectId, bytes, contentType });
612 const logoUrl = brandingLogoPublicUrl(projectId);
613 await setBrivenEngineBranding(projectId, { logoUrl });
614 try {
615 await updateAuthConfig(projectId, { branding: { logoUrl } });
616 await invalidateAuthInstance(projectId);
617 } catch {
618 // Engine branding is source of truth for Auth dashboard.
619 }
620 void recordBrivenEngineAudit({
621 action: 'config.branding.logo.uploaded',
622 projectId,
623 metadata: { contentType, sizeBytes: file.size },
624 });
625 const branding = (await getBrivenEngineProjectConfig(projectId)).branding;
626 return c.json({
627 ok: true,
628 engine: BRIVEN_ENGINE_ID,
629 logoUrl,
630 branding,
631 });
632 } catch (err) {
633 if (err instanceof ValidationError) {
634 return c.json(
635 {
636 engine: BRIVEN_ENGINE_ID,
637 code: 'validation_failed',
638 message: err.message,
639 },
640 400,
641 );
642 }
643 log.error('briven_engine_branding_logo_upload_failed', {
644 projectId,
645 message: err instanceof Error ? err.message : String(err),
646 });
647 return c.json(
648 {
649 engine: BRIVEN_ENGINE_ID,
650 code: 'logo_upload_failed',
651 message: err instanceof Error ? err.message : String(err),
652 },
653 500,
654 );
655 }
656 },
657);
658
659/** Remove project logo. */
660authCoreProjectRouter.delete(
661 '/v1/auth-core/projects/:projectId/branding/logo',
662 async (c) => {
663 const projectId = c.req.param('projectId');
664 if (!isStorageConfigured()) {
665 return c.json(
666 {
667 engine: BRIVEN_ENGINE_ID,
668 code: 'storage_not_configured',
669 message: 'file storage is not configured on this api',
670 },
671 503,
672 );
673 }
674 try {
675 await deleteBrandingLogo(projectId);
676 await setBrivenEngineBranding(projectId, { logoUrl: null });
677 try {
678 await updateAuthConfig(projectId, { branding: { logoUrl: null } });
679 await invalidateAuthInstance(projectId);
680 } catch {
681 /* engine branding is enough */
682 }
683 void recordBrivenEngineAudit({
684 action: 'config.branding.logo.removed',
685 projectId,
686 metadata: {},
687 });
688 return c.json({
689 ok: true,
690 engine: BRIVEN_ENGINE_ID,
691 logoUrl: null,
692 });
693 } catch (err) {
694 log.error('briven_engine_branding_logo_remove_failed', {
695 projectId,
696 message: err instanceof Error ? err.message : String(err),
697 });
698 return c.json(
699 {
700 engine: BRIVEN_ENGINE_ID,
701 code: 'logo_remove_failed',
702 message: err instanceof Error ? err.message : String(err),
703 },
704 500,
705 );
706 }
707 },
708);
709
710/** Security audit trail for this project (newest first). */
711authCoreProjectRouter.get(
712 '/v1/auth-core/projects/:projectId/audit',
713 async (c) => {
714 const projectId = c.req.param('projectId');
715 const limit = Number(c.req.query('limit') ?? '50');
716 const action = c.req.query('action') ?? null;
717 const userId = c.req.query('userId') ?? null;
718 try {
719 const result = await listBrivenEngineAudit({
720 projectId,
721 limit: Number.isFinite(limit) ? limit : 50,
722 action,
723 userId,
724 });
725 return c.json(result);
726 } catch (err) {
727 return c.json(
728 {
729 engine: BRIVEN_ENGINE_ID,
730 code: 'audit_list_failed',
731 message: err instanceof Error ? err.message : String(err),
732 },
733 500,
734 );
735 }
736 },
737);
738
739/** Send a test SMS with saved project secrets (no login code). */
740authCoreProjectRouter.post(
741 '/v1/auth-core/projects/:projectId/delivery/sms/test',
742 async (c) => {
743 const projectId = c.req.param('projectId');
744 let body: { phoneNumber?: string } = {};
745 try {
746 body = await c.req.json();
747 } catch {
748 body = {};
749 }
750 const phoneNumber = body.phoneNumber?.trim() ?? '';
751 if (!phoneNumber) {
752 return c.json(
753 {
754 engine: BRIVEN_ENGINE_ID,
755 code: 'bad_request',
756 message: 'phoneNumber required (E.164, e.g. +15551234567)',
757 },
758 400,
759 );
760 }
761 try {
762 const config = await getBrivenEngineProjectConfig(projectId);
763 if (!config.delivery.sms.configured) {
764 return c.json(
765 {
766 engine: BRIVEN_ENGINE_ID,
767 code: 'sms_not_configured',
768 ok: false,
769 message:
770 'SMS not set — save Account SID, Auth token, and From number first',
771 delivery: config.delivery.sms,
772 methods: config.methods,
773 },
774 400,
775 );
776 }
777 const result = await sendBrivenEngineSmsTest({ projectId, phoneNumber });
778 const status = result.ok ? 200 : result.mode === 'log' ? 400 : 502;
779 return c.json(
780 {
781 engine: BRIVEN_ENGINE_ID,
782 ok: result.ok,
783 delivery: result,
784 methods: config.methods,
785 passwordlessSmsEnabled: config.methods.passwordlessSms,
786 hint: result.ok
787 ? config.methods.passwordlessSms
788 ? 'Test sent. passwordless-sms is on for this project.'
789 : 'Test sent. Turn on passwordless-sms under Providers so apps can use phone login.'
790 : undefined,
791 },
792 status,
793 );
794 } catch (err) {
795 return c.json(
796 {
797 engine: BRIVEN_ENGINE_ID,
798 code: 'sms_test_failed',
799 ok: false,
800 message: err instanceof Error ? err.message : String(err),
801 },
802 500,
803 );
804 }
805 },
806);
807
808authCoreProjectRouter.post(
809 '/v1/auth-core/projects/:projectId/tenant',
810 async (c) => {
811 const projectId = c.req.param('projectId');
812 const result = await ensureBrivenEngineTenant(projectId);
813 return c.json(result, result.ok ? 200 : 503);
814 },
815);
816
817authCoreProjectRouter.get('/v1/auth-core/tenants', async (c) => {
818 const result = await listBrivenEngineTenants();
819 return c.json(result, result.ok ? 200 : 503);
820});
821
822authCoreProjectRouter.get('/v1/auth-core/roles', async (c) => {
823 return c.json(await listBrivenEngineRoles());
824});
825
826authCoreProjectRouter.post('/v1/auth-core/roles', async (c) => {
827 let body: {
828 role?: string;
829 permissions?: string[];
830 projectId?: string;
831 tenantId?: string;
832 } = {};
833 try {
834 body = await c.req.json();
835 } catch {
836 body = {};
837 }
838 if (!body.role) {
839 return c.json({ engine: BRIVEN_ENGINE_ID, code: 'role_required' }, 400);
840 }
841 return c.json(
842 await createBrivenEngineRole(body.role, body.permissions ?? [], {
843 projectId: body.projectId,
844 tenantId: body.tenantId,
845 }),
846 );
847});
848
849authCoreProjectRouter.post('/v1/auth-core/roles/assign', async (c) => {
850 let body: {
851 userId?: string;
852 role?: string;
853 projectId?: string;
854 tenantId?: string;
855 } = {};
856 try {
857 body = await c.req.json();
858 } catch {
859 body = {};
860 }
861 if (!body.userId || !body.role) {
862 return c.json(
863 { engine: BRIVEN_ENGINE_ID, code: 'userId_and_role_required' },
864 400,
865 );
866 }
867 return c.json(
868 await assignBrivenEngineRole(body.userId, body.role, {
869 projectId: body.projectId,
870 tenantId: body.tenantId,
871 }),
872 );
873});
874
875authCoreProjectRouter.get('/v1/auth-core/users/:userId/roles', async (c) => {
876 return c.json(
877 await getBrivenEngineUserRoles(c.req.param('userId'), {
878 projectId: c.req.query('projectId') ?? undefined,
879 tenantId: c.req.query('tenantId') ?? undefined,
880 }),
881 );
882});
883
884authCoreProjectRouter.get('/v1/auth-core/roles/list', async (c) => {
885 return c.json(
886 await listBrivenEngineRoles({
887 projectId: c.req.query('projectId') ?? undefined,
888 tenantId: c.req.query('tenantId') ?? undefined,
889 }),
890 );
891});
892// ─── MFA TOTP ───────────────────────────────────────────────────────
893authCoreProjectRouter.post('/v1/auth-core/mfa/totp', async (c) => {
894 if (!isAuthCoreInitialized()) {
895 return c.json({ engine: BRIVEN_ENGINE_ID, code: 'sdk_not_ready' }, 503);
896 }
897 let body: {
898 userId?: string;
899 deviceName?: string;
900 projectId?: string;
901 } = {};
902 try {
903 body = await c.req.json();
904 } catch {
905 body = {};
906 }
907 if (!body.userId) {
908 return c.json({ engine: BRIVEN_ENGINE_ID, code: 'userId_required' }, 400);
909 }
910 const result = await createTotpDevice(
911 body.userId,
912 body.deviceName ?? 'default',
913 { projectId: body.projectId },
914 );
915 return c.json(result, result.ok ? 200 : 400);
916});
917
918authCoreProjectRouter.post('/v1/auth-core/mfa/totp/verify', async (c) => {
919 let body: {
920 userId?: string;
921 deviceId?: string;
922 deviceName?: string;
923 code?: string;
924 } = {};
925 try {
926 body = await c.req.json();
927 } catch {
928 body = {};
929 }
930 if (!body.userId || !body.code) {
931 return c.json(
932 { engine: BRIVEN_ENGINE_ID, code: 'userId_and_code_required' },
933 400,
934 );
935 }
936 const result = await verifyAndEnableTotpDevice({
937 userId: body.userId,
938 deviceId: body.deviceId,
939 deviceName: body.deviceName,
940 code: body.code,
941 });
942 return c.json(result, result.ok ? 200 : 400);
943});
944
945authCoreProjectRouter.post('/v1/auth-core/mfa/totp/check', async (c) => {
946 let body: { userId?: string; code?: string } = {};
947 try {
948 body = await c.req.json();
949 } catch {
950 body = {};
951 }
952 if (!body.userId || !body.code) {
953 return c.json(
954 { engine: BRIVEN_ENGINE_ID, code: 'userId_and_code_required' },
955 400,
956 );
957 }
958 return c.json(await verifyUserTotp(body.userId, body.code));
959});
960
961authCoreProjectRouter.get('/v1/auth-core/mfa/totp/:userId', async (c) => {
962 return c.json(await listTotpDevices(c.req.param('userId')));
963});
964
965authCoreProjectRouter.delete(
966 '/v1/auth-core/mfa/totp/:userId/:deviceName',
967 async (c) => {
968 return c.json(
969 await removeTotpDevice(c.req.param('userId'), c.req.param('deviceName')),
970 );
971 },
972);
973
974// ─── Passkeys ───────────────────────────────────────────────────────
975authCoreProjectRouter.post('/v1/auth-core/passkeys/register/options', async (c) => {
976 let body: { userId?: string; userName?: string; projectId?: string } = {};
977 try {
978 body = await c.req.json();
979 } catch {
980 body = {};
981 }
982 if (!body.userId || !body.userName) {
983 return c.json(
984 { engine: BRIVEN_ENGINE_ID, code: 'userId_and_userName_required' },
985 400,
986 );
987 }
988 const result = await createRegistrationOptions({
989 userId: body.userId,
990 userName: body.userName,
991 projectId: body.projectId,
992 });
993 return c.json(result, result.status === 'OK' ? 200 : 400);
994});
995
996authCoreProjectRouter.post('/v1/auth-core/passkeys/register/finish', async (c) => {
997 let body: {
998 userId?: string;
999 challengeId?: string;
1000 credentialId?: string;
1001 publicKey?: string;
1002 transports?: string[];
1003 response?: unknown;
1004 expectedOrigin?: string;
1005 } = {};
1006 try {
1007 body = await c.req.json();
1008 } catch {
1009 body = {};
1010 }
1011 if (!body.userId || !body.challengeId) {
1012 return c.json({ engine: BRIVEN_ENGINE_ID, code: 'bad_request' }, 400);
1013 }
1014 if (!body.response && (!body.credentialId || !body.publicKey)) {
1015 return c.json(
1016 {
1017 engine: BRIVEN_ENGINE_ID,
1018 code: 'bad_request',
1019 message: 'response (WebAuthn JSON) or credentialId+publicKey required',
1020 },
1021 400,
1022 );
1023 }
1024 const result = await finishRegistration({
1025 userId: body.userId,
1026 challengeId: body.challengeId,
1027 // eslint-disable-next-line @typescript-eslint/no-explicit-any
1028 response: body.response as any,
1029 credentialId: body.credentialId,
1030 publicKey: body.publicKey,
1031 transports: body.transports,
1032 expectedOrigin: body.expectedOrigin,
1033 });
1034 return c.json(result, result.status === 'OK' ? 200 : 400);
1035});
1036
1037authCoreProjectRouter.post('/v1/auth-core/passkeys/authenticate/options', async (c) => {
1038 let body: { userId?: string; projectId?: string } = {};
1039 try {
1040 body = await c.req.json();
1041 } catch {
1042 body = {};
1043 }
1044 const result = await createAuthenticationOptions({
1045 userId: body.userId,
1046 projectId: body.projectId,
1047 });
1048 return c.json(result, result.status === 'OK' ? 200 : 400);
1049});
1050
1051authCoreProjectRouter.post('/v1/auth-core/passkeys/authenticate/finish', async (c) => {
1052 let body: {
1053 challengeId?: string;
1054 credentialId?: string;
1055 projectId?: string;
1056 response?: unknown;
1057 expectedOrigin?: string;
1058 } = {};
1059 try {
1060 body = await c.req.json();
1061 } catch {
1062 body = {};
1063 }
1064 if (!body.challengeId) {
1065 return c.json({ engine: BRIVEN_ENGINE_ID, code: 'bad_request' }, 400);
1066 }
1067 if (!body.response && !body.credentialId) {
1068 return c.json(
1069 {
1070 engine: BRIVEN_ENGINE_ID,
1071 code: 'bad_request',
1072 message: 'response or credentialId required',
1073 },
1074 400,
1075 );
1076 }
1077 const result = await finishAuthentication({
1078 challengeId: body.challengeId,
1079 credentialId: body.credentialId,
1080 // eslint-disable-next-line @typescript-eslint/no-explicit-any
1081 response: body.response as any,
1082 projectId: body.projectId,
1083 expectedOrigin: body.expectedOrigin,
1084 });
1085 return c.json(result, result.status === 'OK' ? 200 : 400);
1086});
1087
1088authCoreProjectRouter.get('/v1/auth-core/passkeys/:userId', async (c) => {
1089 return c.json(await listPasskeys(c.req.param('userId')));
1090});
1091
1092authCoreProjectRouter.delete(
1093 '/v1/auth-core/passkeys/:userId/:credentialId',
1094 async (c) => {
1095 return c.json(
1096 await deletePasskey(c.req.param('userId'), c.req.param('credentialId')),
1097 );
1098 },
1099);