auth-core-fdi.ts1022 lines · main
1/**
2 * briven-engine FDI-compatible routes — implemented on Doltgres only.
3 * No SuperTokens Core process.
4 *
5 * End-user recipes (apps proxy first-party). Locked: project + pk_briven_auth_
6 * required; soft-disable + method flags enforced (security deep-test 2026-07-27).
7 */
8
9import { Hono } from 'hono';
10
11import {
12 signInEmailPassword,
13 signUpEmailPassword,
14} from '../services/auth-core/emailpassword.js';
15import {
16 createEngineSession,
17 refreshEngineSession,
18 revokeEngineSession,
19} from '../services/auth-core/native-session.js';
20import {
21 consumePasswordlessCode,
22 createPasswordlessCode,
23} from '../services/auth-core/passwordless.js';
24import {
25 getAuthorisationUrl,
26 signInUpWithCode,
27 signInUpWithThirdPartyProfile,
28 type SupportedSocial,
29} from '../services/auth-core/thirdparty.js';
30import {
31 createTotpDevice,
32 listTotpDevices,
33 removeTotpDevice,
34 userHasVerifiedTotp,
35 verifyAndEnableTotpDevice,
36 verifyUserTotp,
37} from '../services/auth-core/mfa.js';
38import {
39 createAuthenticationOptions,
40 createRegistrationOptions,
41 deletePasskey,
42 finishAuthentication,
43 finishRegistration,
44 listPasskeys,
45} from '../services/auth-core/webauthn.js';
46import { env } from '../env.js';
47import { requireTurnstileIfConfigured } from '../services/auth-core/abuse.js';
48import { isAuthCoreInitialized } from '../services/auth-core/engine.js';
49import {
50 methodFlagDenied,
51 requireFdiProjectKey,
52 type FdiProjectContext,
53} from '../services/auth-core/fdi-guard.js';
54import {
55 consumeMfaChallenge,
56 issueMfaChallenge,
57} from '../services/auth-core/mfa-challenge.js';
58import { getBrivenEngineAppOrigins } from '../services/auth-core/project-config.js';
59import { verifyAuthCoreSession } from '../services/auth-core/session.js';
60import type { AppEnv } from '../types/app-env.js';
61
62type FdiEnv = AppEnv & {
63 Variables: AppEnv['Variables'] & {
64 fdiCtx: FdiProjectContext;
65 };
66};
67
68export const authCoreFdiRouter = new Hono<FdiEnv>();
69
70const FDI = '/v1/auth-core/fdi';
71
72/** Lock every FDI recipe behind project + public auth key. */
73authCoreFdiRouter.use(`${FDI}/*`, async (c, next) => {
74 if (c.req.method.toUpperCase() === 'OPTIONS') {
75 await next();
76 return;
77 }
78 const ctx = await requireFdiProjectKey(c);
79 if (ctx instanceof Response) {
80 return ctx;
81 }
82 c.set('fdiCtx', ctx);
83 await next();
84 return;
85});
86
87/** Cookie holds session handle (Doltgres lookup key). Secure in production. */
88function setSessionCookies(
89 c: { header: (n: string, v: string, o?: { append?: boolean }) => void },
90 session: { sessionHandle: string; refreshToken: string; expiresAt: Date },
91) {
92 const secure = env.BRIVEN_ENV === 'production' ? '; Secure' : '';
93 const maxAge = Math.max(
94 60,
95 Math.floor((session.expiresAt.getTime() - Date.now()) / 1000),
96 );
97 c.header(
98 'Set-Cookie',
99 `sAccessToken=${encodeURIComponent(session.sessionHandle)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${secure}`,
100 { append: true },
101 );
102 c.header(
103 'Set-Cookie',
104 `sRefreshToken=${encodeURIComponent(session.refreshToken)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${secure}`,
105 { append: true },
106 );
107}
108
109async function captchaGate(
110 body: Record<string, unknown>,
111): Promise<Response | null> {
112 const cap = await requireTurnstileIfConfigured(body);
113 if (!cap.ok) {
114 return new Response(
115 JSON.stringify({
116 status: 'CAPTCHA_ERROR',
117 engine: 'briven-engine',
118 message: cap.message,
119 }),
120 { status: 400, headers: { 'content-type': 'application/json' } },
121 );
122 }
123 return null;
124}
125
126function notReady() {
127 return {
128 code: 'auth_core_sdk_not_ready',
129 engine: 'briven-engine',
130 storage: 'doltgres',
131 message: 'briven-engine not ready on Doltgres',
132 };
133}
134
135authCoreFdiRouter.post(`${FDI}/signup`, async (c) => {
136 if (!isAuthCoreInitialized()) return c.json(notReady(), 503);
137 const fdi = c.get('fdiCtx');
138 const denied = methodFlagDenied(fdi.methods, 'emailPassword');
139 if (denied) {
140 return c.json(
141 { status: 'METHOD_DISABLED', engine: 'briven-engine', message: denied },
142 403,
143 );
144 }
145 let body: {
146 formFields?: Array<{ id: string; value: string }>;
147 email?: string;
148 password?: string;
149 turnstileToken?: string;
150 } = {};
151 try {
152 body = await c.req.json();
153 } catch {
154 body = {};
155 }
156 const cap = await captchaGate(body as Record<string, unknown>);
157 if (cap) return cap;
158 const fields = Object.fromEntries(
159 (body.formFields ?? []).map((f) => [f.id, f.value]),
160 );
161 const email = body.email ?? fields.email;
162 const password = body.password ?? fields.password;
163 if (!email || !password) {
164 return c.json({ status: 'FIELD_ERROR', formFields: [] }, 400);
165 }
166 const result = await signUpEmailPassword({
167 email,
168 password,
169 tenantId: fdi.tenantId,
170 projectId: fdi.projectId,
171 });
172 if (result.status !== 'OK') {
173 return c.json({ status: result.status });
174 }
175 const session = await createEngineSession({
176 userId: result.user.id,
177 tenantId: result.user.tenantId,
178 });
179 setSessionCookies(c, session);
180 c.header('x-briven-engine', 'briven-engine');
181 c.header('x-briven-session-handle', session.sessionHandle);
182 c.header('x-briven-tenant-id', fdi.tenantId);
183 return c.json({
184 status: 'OK',
185 user: { id: result.user.id, emails: [result.user.email] },
186 session: {
187 handle: session.sessionHandle,
188 userId: session.userId,
189 },
190 engine: 'briven-engine',
191 storage: 'doltgres',
192 });
193});
194
195authCoreFdiRouter.post(`${FDI}/signin`, async (c) => {
196 if (!isAuthCoreInitialized()) return c.json(notReady(), 503);
197 const fdi = c.get('fdiCtx');
198 const denied = methodFlagDenied(fdi.methods, 'emailPassword');
199 if (denied) {
200 return c.json(
201 { status: 'METHOD_DISABLED', engine: 'briven-engine', message: denied },
202 403,
203 );
204 }
205 let body: {
206 formFields?: Array<{ id: string; value: string }>;
207 email?: string;
208 password?: string;
209 turnstileToken?: string;
210 } = {};
211 try {
212 body = await c.req.json();
213 } catch {
214 body = {};
215 }
216 const cap = await captchaGate(body as Record<string, unknown>);
217 if (cap) return cap;
218 const fields = Object.fromEntries(
219 (body.formFields ?? []).map((f) => [f.id, f.value]),
220 );
221 const email = body.email ?? fields.email;
222 const password = body.password ?? fields.password;
223 if (!email || !password) {
224 return c.json({ status: 'FIELD_ERROR' }, 400);
225 }
226 const result = await signInEmailPassword({
227 email,
228 password,
229 tenantId: fdi.tenantId,
230 projectId: fdi.projectId,
231 });
232 if (result.status !== 'OK') {
233 return c.json({ status: result.status });
234 }
235 // SuperTokens-style MFA: first factor OK → challenge; no session yet.
236 if (await userHasVerifiedTotp(result.user.id)) {
237 const mfaChallenge = issueMfaChallenge({
238 userId: result.user.id,
239 tenantId: result.user.tenantId,
240 });
241 return c.json({
242 status: 'MFA_REQUIRED',
243 factor: 'totp',
244 userId: result.user.id,
245 tenantId: result.user.tenantId,
246 mfaChallenge,
247 engine: 'briven-engine',
248 storage: 'doltgres',
249 message:
250 'password ok — POST /v1/auth-core/fdi/totp/verify with userId, code, mfaChallenge',
251 });
252 }
253 const session = await createEngineSession({
254 userId: result.user.id,
255 tenantId: result.user.tenantId,
256 });
257 setSessionCookies(c, session);
258 c.header('x-briven-engine', 'briven-engine');
259 c.header('x-briven-session-handle', session.sessionHandle);
260 return c.json({
261 status: 'OK',
262 user: { id: result.user.id, emails: [result.user.email] },
263 session: { handle: session.sessionHandle, userId: session.userId },
264 engine: 'briven-engine',
265 storage: 'doltgres',
266 });
267});
268
269authCoreFdiRouter.post(`${FDI}/signout`, async (c) => {
270 if (!isAuthCoreInitialized()) return c.json(notReady(), 503);
271 const handle =
272 c.req.header('x-briven-session-handle') ??
273 (() => {
274 const cookie = c.req.header('cookie') ?? '';
275 const m = /(?:^|;\s*)sAccessToken=([^;]+)/.exec(cookie);
276 return m?.[1] ? decodeURIComponent(m[1]) : undefined;
277 })();
278 if (handle) await revokeEngineSession(handle);
279 c.header(
280 'Set-Cookie',
281 'sAccessToken=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0',
282 { append: true },
283 );
284 c.header(
285 'Set-Cookie',
286 'sRefreshToken=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0',
287 { append: true },
288 );
289 return c.json({ status: 'OK', engine: 'briven-engine' });
290});
291
292/** Passwordless: create email/SMS code (magic link + OTP). Phase 3. */
293authCoreFdiRouter.post(`${FDI}/signinup/code`, async (c) => {
294 if (!isAuthCoreInitialized()) return c.json(notReady(), 503);
295 const fdi = c.get('fdiCtx');
296 let body: {
297 email?: string;
298 phoneNumber?: string;
299 flowType?: 'USER_INPUT_CODE' | 'MAGIC_LINK' | 'USER_INPUT_CODE_AND_MAGIC_LINK';
300 magicLinkBaseUrl?: string;
301 turnstileToken?: string;
302 } = {};
303 try {
304 body = await c.req.json();
305 } catch {
306 body = {};
307 }
308 // SuperTokens-style: when platform Turnstile secret is set, require captcha
309 // on passwordless send (same as email/password) to stop abuse.
310 const plCap = await captchaGate(body as Record<string, unknown>);
311 if (plCap) return plCap;
312 const flow = body.flowType ?? 'USER_INPUT_CODE';
313 if (body.phoneNumber) {
314 const d = methodFlagDenied(fdi.methods, 'passwordlessSms');
315 if (d) {
316 return c.json(
317 { status: 'METHOD_DISABLED', engine: 'briven-engine', message: d },
318 403,
319 );
320 }
321 } else if (flow === 'MAGIC_LINK') {
322 const d = methodFlagDenied(fdi.methods, 'magicLink');
323 if (d) {
324 return c.json(
325 { status: 'METHOD_DISABLED', engine: 'briven-engine', message: d },
326 403,
327 );
328 }
329 } else {
330 const d = methodFlagDenied(fdi.methods, 'passwordlessEmail');
331 if (d) {
332 return c.json(
333 { status: 'METHOD_DISABLED', engine: 'briven-engine', message: d },
334 403,
335 );
336 }
337 }
338 const requestOrigin =
339 c.req.header('origin') ??
340 (() => {
341 const ref = c.req.header('referer');
342 if (!ref) return undefined;
343 try {
344 return new URL(ref).origin;
345 } catch {
346 return undefined;
347 }
348 })() ??
349 undefined;
350 // Prefer x-briven-client-ip (set by first-party app proxy) so Location
351 // geo uses the end-user IP, not the app-server hop.
352 const { clientIpFromHeaders } = await import(
353 '../services/auth-core/auth-email-context.js'
354 );
355 const clientIp = clientIpFromHeaders((n) => c.req.header(n));
356 const userAgent = c.req.header('user-agent') ?? null;
357 // Brave usually spoofs Chrome in User-Agent; Sec-CH-UA carries the real brand.
358 const clientHintsUa =
359 c.req.header('sec-ch-ua') ?? c.req.header('Sec-CH-UA') ?? null;
360 const result = await createPasswordlessCode({
361 email: body.email,
362 phoneNumber: body.phoneNumber,
363 projectId: fdi.projectId,
364 tenantId: fdi.tenantId,
365 flowType: body.flowType,
366 magicLinkBaseUrl: body.magicLinkBaseUrl,
367 requestOrigin,
368 clientIp,
369 userAgent,
370 clientHintsUa,
371 });
372 if (result.status !== 'OK') {
373 return c.json({ ...result, engine: 'briven-engine' }, 400);
374 }
375 return c.json({
376 status: 'OK',
377 engine: 'briven-engine',
378 storage: 'doltgres',
379 preAuthSessionId: result.preAuthSessionId,
380 deviceId: result.deviceId,
381 flowType: result.flowType,
382 channel: result.channel,
383 // Dev-only fields stripped in production inside service
384 userInputCode: result.userInputCode,
385 linkCode: result.linkCode,
386 delivery: result.delivery,
387 });
388});
389
390/** Passwordless: consume OTP or magic link. Phase 3. */
391authCoreFdiRouter.post(`${FDI}/signinup/code/consume`, async (c) => {
392 if (!isAuthCoreInitialized()) return c.json(notReady(), 503);
393 const fdi = c.get('fdiCtx');
394 let body: {
395 preAuthSessionId?: string;
396 deviceId?: string;
397 userInputCode?: string;
398 linkCode?: string;
399 } = {};
400 try {
401 body = await c.req.json();
402 } catch {
403 body = {};
404 }
405 if (!body.preAuthSessionId || !body.deviceId) {
406 return c.json(
407 {
408 status: 'BAD_REQUEST',
409 message: 'preAuthSessionId and deviceId required',
410 engine: 'briven-engine',
411 },
412 400,
413 );
414 }
415 const result = await consumePasswordlessCode({
416 preAuthSessionId: body.preAuthSessionId,
417 deviceId: body.deviceId,
418 userInputCode: body.userInputCode,
419 linkCode: body.linkCode,
420 projectId: fdi.projectId,
421 tenantId: fdi.tenantId,
422 });
423 if (result.status !== 'OK') {
424 return c.json(
425 { ...result, engine: 'briven-engine' },
426 result.status === 'EXPIRED' ? 401 : 400,
427 );
428 }
429 // accessToken === session handle (Phase 2 cookie contract)
430 setSessionCookies(c, {
431 sessionHandle: result.session.handle,
432 refreshToken: result.session.refreshToken,
433 expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
434 });
435 c.header('x-briven-engine', 'briven-engine');
436 c.header('x-briven-session-handle', result.session.handle);
437 return c.json({
438 status: 'OK',
439 engine: 'briven-engine',
440 storage: 'doltgres',
441 createdNewUser: result.createdNewUser,
442 user: result.user,
443 session: { handle: result.session.handle, userId: result.session.userId },
444 });
445});
446
447/** Social: get Google/GitHub authorisation URL (Phase 4). */
448authCoreFdiRouter.get(`${FDI}/authorisationurl`, async (c) => {
449 if (!isAuthCoreInitialized()) return c.json(notReady(), 503);
450 const fdi = c.get('fdiCtx');
451 const thirdPartyId = (c.req.query('thirdPartyId') ?? '') as SupportedSocial;
452 // Accept both SuperTokens name and our older alias.
453 const redirectURI =
454 c.req.query('redirectURI') ??
455 c.req.query('redirectURIOnProviderDashboard') ??
456 '';
457 // redirectURI origin must be on project Allowed Domains (open-redirect guard).
458 if (redirectURI) {
459 try {
460 const origins = await getBrivenEngineAppOrigins(fdi.projectId);
461 const allowed = new Set(
462 origins.map((o) => {
463 try {
464 return new URL(o.includes('://') ? o : `https://${o}`).origin;
465 } catch {
466 return '';
467 }
468 }).filter(Boolean),
469 );
470 const redirectOrigin = new URL(redirectURI).origin;
471 if (allowed.size > 0 && !allowed.has(redirectOrigin)) {
472 return c.json(
473 {
474 status: 'BAD_REQUEST',
475 engine: 'briven-engine',
476 message: 'redirectURI origin is not on Allowed Domains',
477 },
478 400,
479 );
480 }
481 if (allowed.size === 0 && env.BRIVEN_ENV === 'production') {
482 return c.json(
483 {
484 status: 'BAD_REQUEST',
485 engine: 'briven-engine',
486 message: 'configure Allowed Domains before OAuth',
487 },
488 400,
489 );
490 }
491 } catch {
492 return c.json(
493 {
494 status: 'BAD_REQUEST',
495 engine: 'briven-engine',
496 message: 'invalid redirectURI',
497 },
498 400,
499 );
500 }
501 }
502 const result = await getAuthorisationUrl({
503 thirdPartyId,
504 redirectURI,
505 projectId: fdi.projectId,
506 });
507 if (result.status !== 'OK') {
508 return c.json({ ...result, engine: 'briven-engine' }, 400);
509 }
510 return c.json({ ...result, engine: 'briven-engine', storage: 'doltgres' });
511});
512
513/** Social: complete sign-in with OAuth authorization code */
514authCoreFdiRouter.post(`${FDI}/signinup`, async (c) => {
515 if (!isAuthCoreInitialized()) return c.json(notReady(), 503);
516 const fdi = c.get('fdiCtx');
517 let body: {
518 thirdPartyId?: SupportedSocial;
519 redirectURI?: string;
520 code?: string;
521 state?: string;
522 /** Dev-only synthetic profile (step 3 local proof) */
523 testProfile?: {
524 thirdPartyUserId: string;
525 email?: string;
526 emailVerified?: boolean;
527 name?: string;
528 };
529 } = {};
530 try {
531 body = await c.req.json();
532 } catch {
533 body = {};
534 }
535
536 // Development-only: skip real provider when testProfile supplied
537 if (
538 body.testProfile &&
539 body.thirdPartyId &&
540 env.BRIVEN_ENV !== 'production'
541 ) {
542 const result = await signInUpWithThirdPartyProfile({
543 profile: {
544 thirdPartyId: body.thirdPartyId,
545 thirdPartyUserId: body.testProfile.thirdPartyUserId,
546 email: body.testProfile.email ?? null,
547 emailVerified: body.testProfile.emailVerified ?? true,
548 name: body.testProfile.name ?? null,
549 },
550 projectId: fdi.projectId,
551 tenantId: fdi.tenantId,
552 });
553 if (result.status !== 'OK') {
554 return c.json({ ...result, engine: 'briven-engine' }, 400);
555 }
556 setSessionCookies(c, {
557 sessionHandle: result.session.handle,
558 refreshToken: result.session.refreshToken,
559 expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
560 });
561 c.header('x-briven-engine', 'briven-engine');
562 c.header('x-briven-session-handle', result.session.handle);
563 return c.json({
564 status: 'OK',
565 engine: 'briven-engine',
566 storage: 'doltgres',
567 createdNewUser: result.createdNewUser,
568 user: result.user,
569 session: { handle: result.session.handle, userId: result.session.userId },
570 mode: 'test_profile',
571 });
572 }
573
574 if (!body.thirdPartyId || !body.code || !body.redirectURI) {
575 return c.json(
576 {
577 status: 'BAD_REQUEST',
578 engine: 'briven-engine',
579 message: 'thirdPartyId, code, redirectURI required (or testProfile in dev)',
580 },
581 400,
582 );
583 }
584
585 const result = await signInUpWithCode({
586 thirdPartyId: body.thirdPartyId,
587 code: body.code,
588 redirectURI: body.redirectURI,
589 projectId: fdi.projectId,
590 state: body.state,
591 });
592 if (result.status !== 'OK') {
593 return c.json({ ...result, engine: 'briven-engine' }, 400);
594 }
595 setSessionCookies(c, {
596 sessionHandle: result.session.handle,
597 refreshToken: result.session.refreshToken,
598 expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
599 });
600 c.header('x-briven-engine', 'briven-engine');
601 c.header('x-briven-session-handle', result.session.handle);
602 return c.json({
603 status: 'OK',
604 engine: 'briven-engine',
605 storage: 'doltgres',
606 createdNewUser: result.createdNewUser,
607 user: result.user,
608 session: { handle: result.session.handle, userId: result.session.userId },
609 });
610});
611
612/**
613 * SuperTokens-style session refresh.
614 * Body optional: { refreshToken } — else sRefreshToken cookie.
615 * Rotates handle: old session deleted, new cookies set.
616 */
617authCoreFdiRouter.post(`${FDI}/session/refresh`, async (c) => {
618 if (!isAuthCoreInitialized()) return c.json(notReady(), 503);
619 let body: { refreshToken?: string } = {};
620 try {
621 body = await c.req.json();
622 } catch {
623 body = {};
624 }
625 const fromCookie = (() => {
626 const cookie = c.req.header('cookie') ?? '';
627 const m = /(?:^|;\s*)sRefreshToken=([^;]+)/.exec(cookie);
628 return m?.[1] ? decodeURIComponent(m[1]) : undefined;
629 })();
630 const refreshToken = body.refreshToken?.trim() || fromCookie;
631 if (!refreshToken) {
632 return c.json(
633 {
634 status: 'UNAUTHORISED',
635 engine: 'briven-engine',
636 message: 'refresh token required (cookie sRefreshToken or body.refreshToken)',
637 },
638 401,
639 );
640 }
641 try {
642 const session = await refreshEngineSession(refreshToken);
643 if (!session) {
644 return c.json(
645 { status: 'UNAUTHORISED', engine: 'briven-engine', message: 'invalid or expired refresh token' },
646 401,
647 );
648 }
649 setSessionCookies(c, session);
650 c.header('x-briven-engine', 'briven-engine');
651 c.header('x-briven-session-handle', session.sessionHandle);
652 return c.json({
653 status: 'OK',
654 engine: 'briven-engine',
655 storage: 'doltgres',
656 session: { handle: session.sessionHandle, userId: session.userId },
657 });
658 } catch (err) {
659 const msg = err instanceof Error ? err.message : String(err);
660 if (msg === 'user_held' || msg === 'user_archived') {
661 return c.json({ status: 'UNAUTHORISED', engine: 'briven-engine', message: msg }, 401);
662 }
663 throw err;
664 }
665});
666
667// ─── Phase 5: TOTP MFA ───────────────────────────────────────────────
668
669async function sessionUserId(c: {
670 req: { header: (n: string) => string | undefined; raw: { headers: Headers } };
671}): Promise<string | null> {
672 const result = await verifyAuthCoreSession({
673 url: 'http://local/session',
674 method: 'GET',
675 headers: c.req.raw.headers,
676 cookieHeader: c.req.header('cookie'),
677 });
678 if (!result.ok) return null;
679 return result.session.getUserId();
680}
681
682/** Enroll TOTP (needs existing session). */
683authCoreFdiRouter.post(`${FDI}/totp/setup`, async (c) => {
684 if (!isAuthCoreInitialized()) return c.json(notReady(), 503);
685 const fdi = c.get('fdiCtx');
686 const userId = await sessionUserId(c);
687 if (!userId) {
688 return c.json(
689 { status: 'UNAUTHORISED', engine: 'briven-engine', message: 'session required' },
690 401,
691 );
692 }
693 let body: { deviceName?: string } = {};
694 try {
695 body = await c.req.json();
696 } catch {
697 body = {};
698 }
699 const created = await createTotpDevice(userId, body.deviceName ?? 'authenticator', {
700 projectId: fdi.projectId,
701 tenantId: fdi.tenantId,
702 });
703 if (!created.ok) {
704 return c.json({ status: 'ERROR', ...created }, 400);
705 }
706 return c.json({
707 status: 'OK',
708 engine: 'briven-engine',
709 storage: 'doltgres',
710 deviceId: created.deviceId,
711 deviceName: created.deviceName,
712 secret: created.secret,
713 otpauthUrl: created.otpauthUrl,
714 });
715});
716
717/** Confirm enroll with first code from authenticator app. */
718authCoreFdiRouter.post(`${FDI}/totp/setup/verify`, async (c) => {
719 if (!isAuthCoreInitialized()) return c.json(notReady(), 503);
720 const userId = await sessionUserId(c);
721 if (!userId) {
722 return c.json(
723 { status: 'UNAUTHORISED', engine: 'briven-engine', message: 'session required' },
724 401,
725 );
726 }
727 let body: { deviceId?: string; code?: string } = {};
728 try {
729 body = await c.req.json();
730 } catch {
731 body = {};
732 }
733 if (!body.code) {
734 return c.json({ status: 'BAD_REQUEST', message: 'code required' }, 400);
735 }
736 const v = await verifyAndEnableTotpDevice({
737 userId,
738 deviceId: body.deviceId,
739 code: body.code,
740 });
741 if (!v.ok) {
742 return c.json({ status: 'ERROR', engine: 'briven-engine', message: v.message }, 400);
743 }
744 return c.json({ status: 'OK', engine: 'briven-engine', storage: 'doltgres' });
745});
746
747/**
748 * Second factor after password when MFA_REQUIRED.
749 * Body: { userId, code, mfaChallenge } — challenge issued at password step.
750 */
751authCoreFdiRouter.post(`${FDI}/totp/verify`, async (c) => {
752 if (!isAuthCoreInitialized()) return c.json(notReady(), 503);
753 const fdi = c.get('fdiCtx');
754 let body: {
755 userId?: string;
756 code?: string;
757 tenantId?: string;
758 mfaChallenge?: string;
759 } = {};
760 try {
761 body = await c.req.json();
762 } catch {
763 body = {};
764 }
765 if (!body.userId || !body.code) {
766 return c.json(
767 { status: 'BAD_REQUEST', message: 'userId and code required' },
768 400,
769 );
770 }
771 const chal = await consumeMfaChallenge(body.mfaChallenge ?? '', body.userId);
772 if (!chal.ok) {
773 return c.json(
774 {
775 status: 'MFA_CHALLENGE_ERROR',
776 engine: 'briven-engine',
777 message: chal.message,
778 },
779 401,
780 );
781 }
782 const ok = await verifyUserTotp(body.userId, body.code);
783 if (!ok.ok) {
784 return c.json({ status: 'WRONG_CREDENTIALS_ERROR', engine: 'briven-engine' }, 401);
785 }
786 const tenantId = chal.tenantId || fdi.tenantId;
787 const session = await createEngineSession({
788 userId: body.userId,
789 tenantId,
790 });
791 setSessionCookies(c, session);
792 c.header('x-briven-engine', 'briven-engine');
793 c.header('x-briven-session-handle', session.sessionHandle);
794 return c.json({
795 status: 'OK',
796 engine: 'briven-engine',
797 storage: 'doltgres',
798 session: { handle: session.sessionHandle, userId: session.userId },
799 });
800});
801
802authCoreFdiRouter.get(`${FDI}/totp/devices`, async (c) => {
803 if (!isAuthCoreInitialized()) return c.json(notReady(), 503);
804 const userId = await sessionUserId(c);
805 if (!userId) {
806 return c.json({ status: 'UNAUTHORISED', engine: 'briven-engine' }, 401);
807 }
808 const list = await listTotpDevices(userId);
809 return c.json({ status: 'OK', ...list });
810});
811
812authCoreFdiRouter.delete(`${FDI}/totp/devices/:deviceId`, async (c) => {
813 if (!isAuthCoreInitialized()) return c.json(notReady(), 503);
814 const userId = await sessionUserId(c);
815 if (!userId) {
816 return c.json({ status: 'UNAUTHORISED', engine: 'briven-engine' }, 401);
817 }
818 const r = await removeTotpDevice(userId, c.req.param('deviceId'));
819 return c.json({ status: r.ok ? 'OK' : 'ERROR', engine: 'briven-engine' }, r.ok ? 200 : 404);
820});
821
822// ─── Phase 5: Passkeys (WebAuthn) ────────────────────────────────────
823
824function requestOriginFrom(c: { req: { header: (n: string) => string | undefined } }): string | null {
825 const o = c.req.header('origin')?.trim();
826 if (o) return o;
827 const ref = c.req.header('referer')?.trim();
828 if (!ref) return null;
829 try {
830 return new URL(ref).origin;
831 } catch {
832 return null;
833 }
834}
835
836authCoreFdiRouter.post(`${FDI}/webauthn/register/options`, async (c) => {
837 if (!isAuthCoreInitialized()) return c.json(notReady(), 503);
838 const fdi = c.get('fdiCtx');
839 const userId = await sessionUserId(c);
840 if (!userId) {
841 return c.json({ status: 'UNAUTHORISED', engine: 'briven-engine' }, 401);
842 }
843 let body: { userName?: string; rpId?: string; expectedOrigin?: string } = {};
844 try {
845 body = await c.req.json();
846 } catch {
847 body = {};
848 }
849 const result = await createRegistrationOptions({
850 userId,
851 userName: body.userName ?? userId,
852 projectId: fdi.projectId,
853 tenantId: fdi.tenantId,
854 rpId: body.rpId,
855 expectedOrigin: body.expectedOrigin,
856 requestOrigin: requestOriginFrom(c),
857 });
858 if (result.status !== 'OK') {
859 return c.json({ ...result, engine: 'briven-engine' }, 400);
860 }
861 return c.json({ ...result, engine: 'briven-engine', storage: 'doltgres' });
862});
863
864authCoreFdiRouter.post(`${FDI}/webauthn/register/finish`, async (c) => {
865 if (!isAuthCoreInitialized()) return c.json(notReady(), 503);
866 const fdi = c.get('fdiCtx');
867 const userId = await sessionUserId(c);
868 if (!userId) {
869 return c.json({ status: 'UNAUTHORISED', engine: 'briven-engine' }, 401);
870 }
871 let body: {
872 challengeId?: string;
873 credentialId?: string;
874 publicKey?: string;
875 transports?: string[];
876 response?: unknown;
877 credential?: unknown;
878 rpId?: string;
879 expectedOrigin?: string;
880 } = {};
881 try {
882 body = await c.req.json();
883 } catch {
884 body = {};
885 }
886 if (!body.challengeId) {
887 return c.json({ status: 'BAD_REQUEST', message: 'challengeId required' }, 400);
888 }
889 const result = await finishRegistration({
890 userId,
891 challengeId: body.challengeId,
892 credentialId: body.credentialId,
893 publicKey: body.publicKey,
894 transports: body.transports,
895 projectId: fdi.projectId,
896 // eslint-disable-next-line @typescript-eslint/no-explicit-any
897 response: (body.response ?? body.credential) as any,
898 rpId: body.rpId,
899 expectedOrigin: body.expectedOrigin,
900 requestOrigin: requestOriginFrom(c),
901 });
902 if (result.status !== 'OK') {
903 return c.json({ ...result, engine: 'briven-engine' }, 400);
904 }
905 return c.json({ ...result, engine: 'briven-engine', storage: 'doltgres' });
906});
907
908authCoreFdiRouter.post(`${FDI}/webauthn/signin/options`, async (c) => {
909 if (!isAuthCoreInitialized()) return c.json(notReady(), 503);
910 const fdi = c.get('fdiCtx');
911 const denied = methodFlagDenied(fdi.methods, 'passkeys');
912 if (denied) {
913 return c.json(
914 { status: 'METHOD_DISABLED', engine: 'briven-engine', message: denied },
915 403,
916 );
917 }
918 let body: { userId?: string; rpId?: string; expectedOrigin?: string } = {};
919 try {
920 body = await c.req.json();
921 } catch {
922 body = {};
923 }
924 const result = await createAuthenticationOptions({
925 userId: body.userId,
926 projectId: fdi.projectId,
927 tenantId: fdi.tenantId,
928 rpId: body.rpId,
929 expectedOrigin: body.expectedOrigin,
930 requestOrigin: requestOriginFrom(c),
931 });
932 if (result.status !== 'OK') {
933 return c.json({ ...result, engine: 'briven-engine' }, 400);
934 }
935 return c.json({ ...result, engine: 'briven-engine', storage: 'doltgres' });
936});
937
938authCoreFdiRouter.post(`${FDI}/webauthn/signin/finish`, async (c) => {
939 if (!isAuthCoreInitialized()) return c.json(notReady(), 503);
940 const fdi = c.get('fdiCtx');
941 let body: {
942 challengeId?: string;
943 credentialId?: string;
944 response?: unknown;
945 credential?: unknown;
946 rpId?: string;
947 expectedOrigin?: string;
948 } = {};
949 try {
950 body = await c.req.json();
951 } catch {
952 body = {};
953 }
954 if (!body.challengeId) {
955 return c.json({ status: 'BAD_REQUEST', message: 'challengeId required' }, 400);
956 }
957 const result = await finishAuthentication({
958 challengeId: body.challengeId,
959 credentialId: body.credentialId,
960 // eslint-disable-next-line @typescript-eslint/no-explicit-any
961 response: (body.response ?? body.credential) as any,
962 projectId: fdi.projectId,
963 rpId: body.rpId,
964 expectedOrigin: body.expectedOrigin,
965 requestOrigin: requestOriginFrom(c),
966 });
967 if (result.status !== 'OK') {
968 return c.json({ ...result, engine: 'briven-engine' }, 400);
969 }
970 setSessionCookies(c, {
971 sessionHandle: result.session.handle,
972 refreshToken: result.session.refreshToken,
973 expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
974 });
975 c.header('x-briven-engine', 'briven-engine');
976 c.header('x-briven-session-handle', result.session.handle);
977 return c.json({
978 status: 'OK',
979 engine: 'briven-engine',
980 storage: 'doltgres',
981 verified: result.verified,
982 userId: result.userId,
983 session: { handle: result.session.handle, userId: result.session.userId },
984 });
985});
986
987authCoreFdiRouter.get(`${FDI}/webauthn/credentials`, async (c) => {
988 if (!isAuthCoreInitialized()) return c.json(notReady(), 503);
989 const userId = await sessionUserId(c);
990 if (!userId) {
991 return c.json({ status: 'UNAUTHORISED', engine: 'briven-engine' }, 401);
992 }
993 const list = await listPasskeys(userId);
994 return c.json({ status: 'OK', ...list });
995});
996
997authCoreFdiRouter.delete(`${FDI}/webauthn/credentials/:id`, async (c) => {
998 if (!isAuthCoreInitialized()) return c.json(notReady(), 503);
999 const userId = await sessionUserId(c);
1000 if (!userId) {
1001 return c.json({ status: 'UNAUTHORISED', engine: 'briven-engine' }, 401);
1002 }
1003 const r = await deletePasskey(userId, c.req.param('id'));
1004 return c.json({ status: r.ok ? 'OK' : 'ERROR', engine: 'briven-engine' }, r.ok ? 200 : 404);
1005});
1006
1007authCoreFdiRouter.all(`${FDI}/*`, async (c) => {
1008 if (!isAuthCoreInitialized()) return c.json(notReady(), 503);
1009 return c.json(
1010 {
1011 code: 'auth_core_fdi_partial',
1012 engine: 'briven-engine',
1013 storage: 'doltgres',
1014 message:
1015 'Path not implemented. Working: EP, passwordless, social, TOTP MFA, passkeys.',
1016 path: c.req.path,
1017 },
1018 404,
1019 );
1020});
1021
1022