fdi-guard.ts246 lines · main
1/**
2 * FDI request lock — project + pk_briven_auth_ required.
3 *
4 * SuperTokens-style: end-user recipes are still "public" to the app, but the
5 * app proves itself with a publishable auth key bound to one project. No
6 * unauthenticated internet spam of passwordless / OAuth / passkeys.
7 */
8
9import type { Context } from 'hono';
10
11import { env } from '../../env.js';
12import { resolveAuthSdkKey } from '../auth-sdk-keys.js';
13import { isBrivenEngineAuthEnabled } from './workspace.js';
14import { mapProjectToAuthCore } from './project-map.js';
15import {
16 getBrivenEngineMethodFlags,
17 type BrivenEngineMethodFlags,
18} from './project-config.js';
19
20export type FdiProjectContext = {
21 projectId: string;
22 tenantId: string;
23 keyId: string;
24 scope: string;
25 methods: BrivenEngineMethodFlags;
26};
27
28function projectIdFromHeaders(c: Context): string | null {
29 const raw =
30 c.req.header('x-briven-project-id') ??
31 c.req.header('x-project-id') ??
32 c.req.header('briven-project-id') ??
33 // GET authorisationurl from <a href> cannot set headers — allow query.
34 c.req.query('briven_project_id') ??
35 c.req.query('projectId');
36 const id = raw?.trim() ?? '';
37 if (!id.startsWith('p_')) return null;
38 return id;
39}
40
41function bearerToken(c: Context): string | null {
42 const auth = c.req.header('authorization') ?? '';
43 if (!auth.toLowerCase().startsWith('bearer ')) return null;
44 const token = auth.slice('bearer '.length).trim();
45 return token || null;
46}
47
48/**
49 * Hosted Briven Auth UI (briven.tech/auth/…) is first-party IdP login.
50 * SuperTokens-style: the IdP host is trusted; third-party apps still need pk.
51 * Pure helper — unit-tested.
52 */
53export function isHostedPlatformOrigin(
54 originHeader: string | null | undefined,
55 refererHeader: string | null | undefined,
56 webOrigin: string,
57): boolean {
58 const allowed = webOrigin.replace(/\/$/, '').toLowerCase();
59 if (!allowed) return false;
60 const candidates: string[] = [];
61 if (originHeader?.trim()) candidates.push(originHeader.trim());
62 if (refererHeader?.trim()) {
63 try {
64 candidates.push(new URL(refererHeader.trim()).origin);
65 } catch {
66 /* ignore bad referer */
67 }
68 }
69 return candidates.some((c) => c.replace(/\/$/, '').toLowerCase() === allowed);
70}
71
72function deny(
73 c: Context,
74 status: 401 | 403 | 400,
75 body: Record<string, unknown>,
76): Response {
77 return c.json(
78 {
79 engine: 'briven-engine',
80 storage: 'doltgres',
81 ...body,
82 },
83 status,
84 );
85}
86
87/**
88 * Resolve and authorize FDI project context.
89 * Returns a Response on failure (caller must return it).
90 */
91export async function requireFdiProjectKey(
92 c: Context,
93): Promise<FdiProjectContext | Response> {
94 const projectId = projectIdFromHeaders(c);
95 if (!projectId) {
96 return deny(c, 401, {
97 status: 'UNAUTHORIZED',
98 code: 'project_required',
99 message:
100 'x-briven-project-id header required (project public auth is scoped per project)',
101 });
102 }
103
104 const token = bearerToken(c);
105 const hosted = isHostedPlatformOrigin(
106 c.req.header('origin'),
107 c.req.header('referer'),
108 env.BRIVEN_WEB_ORIGIN,
109 );
110
111 // Third-party apps: require pk_briven_auth_. Hosted IdP pages on briven.tech
112 // may omit the browser key (still project-scoped + Auth-enabled).
113 let keyId = 'hosted_platform';
114 let scope = 'read-write';
115
116 if (token && token.startsWith('pk_briven_auth_')) {
117 let resolved: Awaited<ReturnType<typeof resolveAuthSdkKey>>;
118 try {
119 resolved = await resolveAuthSdkKey(token);
120 } catch {
121 resolved = null;
122 }
123 if (!resolved) {
124 return deny(c, 401, {
125 status: 'UNAUTHORIZED',
126 code: 'invalid_auth_key',
127 message: 'invalid or revoked Auth public key',
128 });
129 }
130 if (resolved.projectId !== projectId) {
131 return deny(c, 403, {
132 status: 'FORBIDDEN',
133 code: 'project_key_mismatch',
134 message: 'Auth public key does not belong to this project',
135 });
136 }
137 const method = c.req.method.toUpperCase();
138 if (
139 resolved.scope === 'read' &&
140 method !== 'GET' &&
141 method !== 'HEAD' &&
142 method !== 'OPTIONS'
143 ) {
144 return deny(c, 403, {
145 status: 'FORBIDDEN',
146 code: 'key_scope_readonly',
147 message: 'this Auth key is read-only; mint a read-write key for sign-in',
148 });
149 }
150 keyId = resolved.keyId;
151 scope = resolved.scope;
152 } else if (!hosted) {
153 return deny(c, 401, {
154 status: 'UNAUTHORIZED',
155 code: 'auth_key_required',
156 message:
157 'Authorization: Bearer pk_briven_auth_… required for Auth end-user APIs',
158 });
159 }
160
161 const enabled = await isBrivenEngineAuthEnabled(projectId);
162 if (!enabled) {
163 return deny(c, 403, {
164 status: 'AUTH_DISABLED',
165 code: 'auth_disabled',
166 message: 'Auth is disabled for this project',
167 });
168 }
169
170 let map: ReturnType<typeof mapProjectToAuthCore>;
171 try {
172 map = mapProjectToAuthCore(projectId);
173 } catch {
174 return deny(c, 400, {
175 status: 'BAD_REQUEST',
176 code: 'invalid_project',
177 message: 'invalid project id',
178 });
179 }
180
181 const methods = await getBrivenEngineMethodFlags(projectId);
182 return {
183 projectId: map.projectId,
184 tenantId: map.tenantId,
185 keyId,
186 scope,
187 methods,
188 };
189}
190
191/** Recipe method flags for a specific flow. */
192export function methodFlagDenied(
193 methods: BrivenEngineMethodFlags,
194 recipe:
195 | 'emailPassword'
196 | 'passwordlessEmail'
197 | 'magicLink'
198 | 'passwordlessSms'
199 | 'passkeys'
200 | 'mfa',
201): string | null {
202 if (recipe === 'emailPassword' && !methods.emailPassword) {
203 return 'email/password sign-in is disabled for this project';
204 }
205 if (recipe === 'passwordlessEmail' && !methods.passwordlessEmail) {
206 return 'email OTP is disabled for this project';
207 }
208 if (recipe === 'magicLink' && !methods.magicLink) {
209 return 'magic link is disabled for this project';
210 }
211 if (recipe === 'passwordlessSms' && !methods.passwordlessSms) {
212 return 'SMS OTP is disabled for this project';
213 }
214 if (recipe === 'passkeys' && !methods.passkeys) {
215 return 'passkeys are disabled for this project';
216 }
217 if (recipe === 'mfa' && !methods.mfa) {
218 // MFA flag false means "not required / not offered as product toggle"
219 // Setup can still work if user enrolled — only block verify enroll paths if needed.
220 // For login second-factor we still allow if user has TOTP enrolled (security).
221 return null;
222 }
223 return null;
224}
225
226/** Production must never fall back to shared `public` tenant. */
227export function requireTenantId(
228 projectId: string | undefined,
229 tenantId: string | undefined,
230): { ok: true; tenantId: string } | { ok: false; message: string } {
231 if (tenantId) return { ok: true, tenantId };
232 if (projectId) {
233 try {
234 return { ok: true, tenantId: mapProjectToAuthCore(projectId).tenantId };
235 } catch {
236 return { ok: false, message: 'invalid project id' };
237 }
238 }
239 if (env.BRIVEN_ENV === 'production') {
240 return {
241 ok: false,
242 message: 'project id required (shared public tenant disabled in production)',
243 };
244 }
245 return { ok: true, tenantId: 'public' };
246}