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 | |
| 9 | import type { Context } from 'hono'; |
| 10 | |
| 11 | import { env } from '../../env.js'; |
| 12 | import { resolveAuthSdkKey } from '../auth-sdk-keys.js'; |
| 13 | import { isBrivenEngineAuthEnabled } from './workspace.js'; |
| 14 | import { mapProjectToAuthCore } from './project-map.js'; |
| 15 | import { |
| 16 | getBrivenEngineMethodFlags, |
| 17 | type BrivenEngineMethodFlags, |
| 18 | } from './project-config.js'; |
| 19 | |
| 20 | export type FdiProjectContext = { |
| 21 | projectId: string; |
| 22 | tenantId: string; |
| 23 | keyId: string; |
| 24 | scope: string; |
| 25 | methods: BrivenEngineMethodFlags; |
| 26 | }; |
| 27 | |
| 28 | function 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 | |
| 41 | function 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 | */ |
| 53 | export 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 | |
| 72 | function 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 | */ |
| 91 | export 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. */ |
| 192 | export 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. */ |
| 227 | export 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 | } |