auth.ts401 lines · main
| 1 | import { mkdir, readFile, writeFile } from 'node:fs/promises'; |
| 2 | import { dirname, resolve } from 'node:path'; |
| 3 | |
| 4 | import { apiCall, ApiCallError } from '../api-client.js'; |
| 5 | import { readCredentials, readUserCredential } from '../config.js'; |
| 6 | import { error as printError, banner, step, success, blankLine } from '../output.js'; |
| 7 | import { readProjectConfig } from '../project-config.js'; |
| 8 | |
| 9 | /** |
| 10 | * Next.js middleware that proxies `/api/auth/*` to Briven's auth-tenant bridge |
| 11 | * with the project id + browser-safe public key. Body streaming needs duplex. |
| 12 | */ |
| 13 | const MIDDLEWARE_TS = `import { NextResponse, type NextRequest } from 'next/server'; |
| 14 | |
| 15 | const BRIVEN_API_ORIGIN = process.env.NEXT_PUBLIC_BRIVEN_API_ORIGIN ?? 'https://api.briven.tech'; |
| 16 | const BRIVEN_PROJECT_ID = process.env.NEXT_PUBLIC_BRIVEN_PROJECT_ID!; |
| 17 | // Prefer the public Next env (browser-safe pk_briven_auth_…); fall back to the |
| 18 | // server-only alias used by older scaffolds. |
| 19 | const BRIVEN_AUTH_KEY = |
| 20 | process.env.BRIVEN_AUTH_PUBLIC_KEY ?? process.env.NEXT_PUBLIC_BRIVEN_AUTH_KEY!; |
| 21 | |
| 22 | /** |
| 23 | * First-party proxy → briven-engine FDI (live). |
| 24 | * Never proxy to /v1/auth-tenant/* (retired → HTTP 410). |
| 25 | * Browser: /api/auth/signinup/code |
| 26 | * Upstream: /v1/auth-core/fdi/signinup/code |
| 27 | */ |
| 28 | export async function middleware(req: NextRequest) { |
| 29 | if (!req.nextUrl.pathname.startsWith('/api/auth/')) return NextResponse.next(); |
| 30 | |
| 31 | let path = req.nextUrl.pathname; |
| 32 | if (path.startsWith('/api/auth/v1/auth-core/fdi')) { |
| 33 | path = path.slice('/api/auth'.length); |
| 34 | } else if (path.startsWith('/api/auth/v1/auth-tenant')) { |
| 35 | // Old scaffold / emails — rewrite retired bridge → FDI |
| 36 | path = path.replace(/^\\/api\\/auth\\/v1\\/auth-tenant/, '/v1/auth-core/fdi'); |
| 37 | } else { |
| 38 | path = path.replace(/^\\/api\\/auth/, '/v1/auth-core/fdi'); |
| 39 | } |
| 40 | |
| 41 | const url = new URL(path, BRIVEN_API_ORIGIN); |
| 42 | url.search = req.nextUrl.search; |
| 43 | |
| 44 | const headers = new Headers(req.headers); |
| 45 | headers.set('x-briven-project-id', BRIVEN_PROJECT_ID); |
| 46 | headers.set('authorization', \`Bearer \${BRIVEN_AUTH_KEY}\`); |
| 47 | if (!url.searchParams.has('briven_project_id')) { |
| 48 | url.searchParams.set('briven_project_id', BRIVEN_PROJECT_ID); |
| 49 | } |
| 50 | |
| 51 | return fetch(url, { |
| 52 | method: req.method, |
| 53 | headers, |
| 54 | body: req.body, |
| 55 | // @ts-expect-error — duplex is required for streaming request bodies in Node 18+ |
| 56 | duplex: 'half', |
| 57 | }); |
| 58 | } |
| 59 | |
| 60 | export const config = { |
| 61 | matcher: ['/api/auth/:path*'], |
| 62 | }; |
| 63 | `; |
| 64 | |
| 65 | function envLocalTemplate(projectId: string): string { |
| 66 | return `# Briven Auth — fill public key from dashboard → Auth → API keys |
| 67 | # https://docs.briven.tech/auth |
| 68 | NEXT_PUBLIC_BRIVEN_API_ORIGIN=https://api.briven.tech |
| 69 | NEXT_PUBLIC_BRIVEN_PROJECT_ID=${projectId} |
| 70 | # Browser-safe key (pk_briven_auth_…). Never put a brk_ server key here. |
| 71 | NEXT_PUBLIC_BRIVEN_AUTH_KEY=pk_briven_auth_xxxxxxxxxxxxxxxx |
| 72 | # Optional alias for middleware (same value as NEXT_PUBLIC_BRIVEN_AUTH_KEY) |
| 73 | BRIVEN_AUTH_PUBLIC_KEY=pk_briven_auth_xxxxxxxxxxxxxxxx |
| 74 | `; |
| 75 | } |
| 76 | |
| 77 | const AUTH_TS = `import { createBrivenAuth } from '@briven/auth'; |
| 78 | |
| 79 | /** |
| 80 | * Stateless client. Session lives in the httpOnly cookie set by Briven. |
| 81 | * https://docs.briven.tech/auth |
| 82 | */ |
| 83 | export const auth = createBrivenAuth({ |
| 84 | projectId: process.env.NEXT_PUBLIC_BRIVEN_PROJECT_ID!, |
| 85 | publicKey: process.env.NEXT_PUBLIC_BRIVEN_AUTH_KEY!, |
| 86 | }); |
| 87 | `; |
| 88 | |
| 89 | const SIGN_IN_HINT = `// Example sign-in page (paste into app/sign-in/page.tsx or similar) |
| 90 | // |
| 91 | // Option A — hosted Briven pages (fastest pilot): |
| 92 | // 'use client'; |
| 93 | // import { auth } from '@/lib/auth'; |
| 94 | // export default function SignIn() { |
| 95 | // return ( |
| 96 | // <button type="button" onClick={() => { |
| 97 | // window.location.assign(auth.hostedPageURL('sign-in', '/dashboard')); |
| 98 | // }}> |
| 99 | // Sign in |
| 100 | // </button> |
| 101 | // ); |
| 102 | // } |
| 103 | // |
| 104 | // Option B — embedded panel: |
| 105 | // 'use client'; |
| 106 | // import { BrivenSignIn } from '@briven/auth/react'; |
| 107 | // export default function SignIn() { |
| 108 | // return <BrivenSignIn redirectTo="/dashboard" showEmailPassword showMagicLink />; |
| 109 | // } |
| 110 | `; |
| 111 | |
| 112 | export async function runAuth(argv: readonly string[]): Promise<number> { |
| 113 | const cmd = argv[0]; |
| 114 | |
| 115 | if (cmd === 'scaffold') { |
| 116 | return runScaffold(); |
| 117 | } |
| 118 | if (cmd === 'enable') { |
| 119 | return runEnable(argv.slice(1)); |
| 120 | } |
| 121 | |
| 122 | banner('auth'); |
| 123 | step('usage:'); |
| 124 | step(' briven auth enable [--origin https://your.app] [--project p_…]'); |
| 125 | step(' — turn Auth ON (starter pack) + mint pk_briven_auth_… if missing'); |
| 126 | step(' briven auth scaffold'); |
| 127 | step(' — middleware.ts + lib/auth.ts + .env.local seeds'); |
| 128 | blankLine(); |
| 129 | step('agents: prefer `briven auth enable` after `briven connect` / `briven setup`'); |
| 130 | step('docs: https://docs.briven.tech/auth'); |
| 131 | return 0; |
| 132 | } |
| 133 | |
| 134 | interface SetupFinishResponse { |
| 135 | ok?: boolean; |
| 136 | projectId?: string; |
| 137 | actions?: string[]; |
| 138 | mintedKeyPlaintext?: string | null; |
| 139 | status?: { |
| 140 | authEnabled?: boolean; |
| 141 | methodsReady?: boolean; |
| 142 | hasPublicKey?: boolean; |
| 143 | originsReady?: boolean; |
| 144 | }; |
| 145 | message?: string; |
| 146 | code?: string; |
| 147 | } |
| 148 | |
| 149 | export interface EnableAuthOptions { |
| 150 | projectId: string; |
| 151 | apiOrigin: string; |
| 152 | /** Platform user JWT (CLI login token). */ |
| 153 | bearer: string; |
| 154 | productionOrigin?: string; |
| 155 | /** When true, skip banner chrome (used from setup/connect). */ |
| 156 | quiet?: boolean; |
| 157 | cwd?: string; |
| 158 | } |
| 159 | |
| 160 | /** |
| 161 | * Enable Auth + starter methods + mint browser key if missing. |
| 162 | * Shared by `briven auth enable` and automatic setup/connect. |
| 163 | */ |
| 164 | export async function enableAuthForProject( |
| 165 | opts: EnableAuthOptions, |
| 166 | ): Promise<{ ok: true; publicKey: string | null; actions: string[] } | { ok: false; message: string }> { |
| 167 | const apiOrigin = opts.apiOrigin.replace(/\/$/, ''); |
| 168 | const body: { productionOrigin?: string } = {}; |
| 169 | if (opts.productionOrigin?.trim()) { |
| 170 | body.productionOrigin = opts.productionOrigin.trim(); |
| 171 | } |
| 172 | try { |
| 173 | const result = await apiCall<SetupFinishResponse>( |
| 174 | `/v1/auth-core/projects/${encodeURIComponent(opts.projectId)}/setup-finish`, |
| 175 | { |
| 176 | apiOrigin, |
| 177 | bearer: opts.bearer, |
| 178 | method: 'POST', |
| 179 | body, |
| 180 | }, |
| 181 | ); |
| 182 | const pk = result.mintedKeyPlaintext?.trim() || null; |
| 183 | if (pk) { |
| 184 | await mergeEnvLocal(opts.projectId, pk, apiOrigin, opts.cwd); |
| 185 | } |
| 186 | return { ok: true, publicKey: pk, actions: result.actions ?? [] }; |
| 187 | } catch (err) { |
| 188 | if (err instanceof ApiCallError) { |
| 189 | return { |
| 190 | ok: false, |
| 191 | message: `server rejected: ${err.code} (${err.status}) — ${err.message}`, |
| 192 | }; |
| 193 | } |
| 194 | return { |
| 195 | ok: false, |
| 196 | message: err instanceof Error ? err.message : 'auth enable failed', |
| 197 | }; |
| 198 | } |
| 199 | } |
| 200 | |
| 201 | /** |
| 202 | * One-shot Auth enable for agents and humans. |
| 203 | * Calls platform setup-finish (enable + methods + localhost + mint key). |
| 204 | * Needs `briven login` / connect so a CLI user token exists (not only brk_). |
| 205 | */ |
| 206 | async function runEnable(argv: readonly string[]): Promise<number> { |
| 207 | banner('auth enable'); |
| 208 | |
| 209 | let productionOrigin: string | undefined; |
| 210 | let projectArg: string | undefined; |
| 211 | for (let i = 0; i < argv.length; i += 1) { |
| 212 | const a = argv[i]; |
| 213 | if (a === '--origin' || a === '-o') { |
| 214 | productionOrigin = argv[i + 1]; |
| 215 | i += 1; |
| 216 | continue; |
| 217 | } |
| 218 | if (a === '--project' || a === '-p') { |
| 219 | projectArg = argv[i + 1]; |
| 220 | i += 1; |
| 221 | continue; |
| 222 | } |
| 223 | if (a?.startsWith('--origin=')) { |
| 224 | productionOrigin = a.slice('--origin='.length); |
| 225 | continue; |
| 226 | } |
| 227 | if (a?.startsWith('--project=')) { |
| 228 | projectArg = a.slice('--project='.length); |
| 229 | } |
| 230 | } |
| 231 | |
| 232 | const user = await readUserCredential(); |
| 233 | if (!user?.token) { |
| 234 | printError('no platform login in the CLI'); |
| 235 | step('run: briven login (or briven connect) so agents have a user token'); |
| 236 | step('then: briven auth enable'); |
| 237 | return 1; |
| 238 | } |
| 239 | |
| 240 | const file = await readCredentials(); |
| 241 | const local = await readProjectConfig(); |
| 242 | const projectId = |
| 243 | projectArg?.trim() || local?.projectId?.trim() || file.default || undefined; |
| 244 | if (!projectId) { |
| 245 | printError('no project id — link a project first'); |
| 246 | step('run: briven projects use <p_…> or pass --project p_…'); |
| 247 | return 1; |
| 248 | } |
| 249 | |
| 250 | const apiOrigin = user.apiOrigin.replace(/\/$/, ''); |
| 251 | step(`project ${projectId}`); |
| 252 | step(`origin ${apiOrigin}`); |
| 253 | if (productionOrigin) step(`app URL ${productionOrigin}`); |
| 254 | |
| 255 | const result = await enableAuthForProject({ |
| 256 | projectId, |
| 257 | apiOrigin, |
| 258 | bearer: user.token, |
| 259 | productionOrigin, |
| 260 | }); |
| 261 | if (!result.ok) { |
| 262 | printError(result.message); |
| 263 | if (result.message.includes('401') || result.message.includes('403')) { |
| 264 | step('need admin access on this project + fresh `briven login`'); |
| 265 | } |
| 266 | return 1; |
| 267 | } |
| 268 | |
| 269 | blankLine(); |
| 270 | for (const action of result.actions) { |
| 271 | step(` · ${action}`); |
| 272 | } |
| 273 | if (result.publicKey) { |
| 274 | blankLine(); |
| 275 | success('browser public key (copy once — not shown again):'); |
| 276 | step(result.publicKey); |
| 277 | } else { |
| 278 | step('public key already present — check dashboard Auth → API keys'); |
| 279 | } |
| 280 | blankLine(); |
| 281 | success('Auth enable finished'); |
| 282 | step('next: briven auth scaffold (if app files not wired yet)'); |
| 283 | step(' pnpm add @briven/auth'); |
| 284 | step(' prove login on an Allowed Domain / origin'); |
| 285 | return 0; |
| 286 | } |
| 287 | |
| 288 | /** Seed or update .env.local with project id + public key (never overwrites other keys blindly). */ |
| 289 | async function mergeEnvLocal( |
| 290 | projectId: string, |
| 291 | publicKey: string, |
| 292 | apiOrigin: string, |
| 293 | cwd: string = process.cwd(), |
| 294 | ): Promise<void> { |
| 295 | const envPath = resolve(cwd, '.env.local'); |
| 296 | const lines = [ |
| 297 | `NEXT_PUBLIC_BRIVEN_API_ORIGIN=${apiOrigin}`, |
| 298 | `NEXT_PUBLIC_BRIVEN_PROJECT_ID=${projectId}`, |
| 299 | `NEXT_PUBLIC_BRIVEN_AUTH_KEY=${publicKey}`, |
| 300 | `BRIVEN_AUTH_PUBLIC_KEY=${publicKey}`, |
| 301 | ]; |
| 302 | try { |
| 303 | let existing = ''; |
| 304 | try { |
| 305 | existing = await readFile(envPath, 'utf8'); |
| 306 | } catch { |
| 307 | existing = ''; |
| 308 | } |
| 309 | if (!existing) { |
| 310 | await writeFile(envPath, `${lines.join('\n')}\n`, { flag: 'wx' }); |
| 311 | step('wrote .env.local with the new public key'); |
| 312 | return; |
| 313 | } |
| 314 | let next = existing; |
| 315 | const upsert = (key: string, value: string) => { |
| 316 | const re = new RegExp(`^${key}=.*$`, 'm'); |
| 317 | if (re.test(next)) { |
| 318 | next = next.replace(re, `${key}=${value}`); |
| 319 | } else { |
| 320 | next = `${next.trimEnd()}\n${key}=${value}\n`; |
| 321 | } |
| 322 | }; |
| 323 | upsert('NEXT_PUBLIC_BRIVEN_API_ORIGIN', apiOrigin); |
| 324 | upsert('NEXT_PUBLIC_BRIVEN_PROJECT_ID', projectId); |
| 325 | upsert('NEXT_PUBLIC_BRIVEN_AUTH_KEY', publicKey); |
| 326 | upsert('BRIVEN_AUTH_PUBLIC_KEY', publicKey); |
| 327 | await writeFile(envPath, next.endsWith('\n') ? next : `${next}\n`); |
| 328 | step('updated .env.local with project id + public key'); |
| 329 | } catch { |
| 330 | step('could not write .env.local — paste the public key yourself'); |
| 331 | } |
| 332 | } |
| 333 | |
| 334 | async function runScaffold(): Promise<number> { |
| 335 | const cwd = process.cwd(); |
| 336 | const config = await readProjectConfig(cwd); |
| 337 | if (!config) { |
| 338 | printError('no briven.json found — run `briven link` first.'); |
| 339 | return 1; |
| 340 | } |
| 341 | |
| 342 | const projectId = config.projectId?.trim() || 'p_xxxxxxxxxxxxxxxx'; |
| 343 | if (!config.projectId) { |
| 344 | step('warning: briven.json has no projectId yet — run `briven link` and re-scaffold,'); |
| 345 | step(' or paste your real p_… id into .env.local yourself.'); |
| 346 | } |
| 347 | |
| 348 | banner('auth scaffold'); |
| 349 | |
| 350 | const middlewarePath = resolve(cwd, 'middleware.ts'); |
| 351 | await writeFile(middlewarePath, MIDDLEWARE_TS); |
| 352 | step('created middleware.ts (proxies /api/auth/* → Briven)'); |
| 353 | |
| 354 | const authPath = resolve(cwd, 'lib/auth.ts'); |
| 355 | try { |
| 356 | await mkdir(dirname(authPath), { recursive: true }); |
| 357 | await writeFile(authPath, AUTH_TS, { flag: 'wx' }); |
| 358 | step('created lib/auth.ts (createBrivenAuth client)'); |
| 359 | } catch { |
| 360 | step('lib/auth.ts already exists — skipped'); |
| 361 | } |
| 362 | |
| 363 | const hintPath = resolve(cwd, 'lib/auth.sign-in.example.tsx.txt'); |
| 364 | try { |
| 365 | await writeFile(hintPath, SIGN_IN_HINT, { flag: 'wx' }); |
| 366 | step('created lib/auth.sign-in.example.tsx.txt (copy into a page)'); |
| 367 | } catch { |
| 368 | step('sign-in example already exists — skipped'); |
| 369 | } |
| 370 | |
| 371 | const envPath = resolve(cwd, '.env.local'); |
| 372 | try { |
| 373 | // Only write .env.local if it doesn't exist — never overwrite secrets. |
| 374 | await writeFile(envPath, envLocalTemplate(projectId), { flag: 'wx' }); |
| 375 | step(`created .env.local (project id prefilled: ${projectId})`); |
| 376 | } catch { |
| 377 | step('.env.local already exists — skipped (add the vars manually if missing)'); |
| 378 | } |
| 379 | |
| 380 | blankLine(); |
| 381 | success('scaffolded Briven Auth:'); |
| 382 | step(' middleware.ts'); |
| 383 | step(' lib/auth.ts'); |
| 384 | step(' lib/auth.sign-in.example.tsx.txt'); |
| 385 | step(' .env.local (if it was missing)'); |
| 386 | blankLine(); |
| 387 | step('Clerk-simple next steps (do in order):'); |
| 388 | step(' 1. briven auth enable (or Dashboard → Auth → Enable once)'); |
| 389 | step(' 2. Public key lands in .env.local when enable mints one'); |
| 390 | step(' 3. Add real site origin: briven auth enable --origin https://your.app'); |
| 391 | step(' 4. pnpm add @briven/auth'); |
| 392 | step(' 5. Copy lib/auth.sign-in.example into a real page (or hostedPageURL sign-in)'); |
| 393 | step(' 6. Deploy THIS app after any auth code change'); |
| 394 | step(' Tip: if agents say "providers OFF", re-check THIS project — not another MCP binding.'); |
| 395 | link('https://docs.briven.tech/auth'); |
| 396 | return 0; |
| 397 | } |
| 398 | |
| 399 | function link(url: string): void { |
| 400 | step(` docs: ${url}`); |
| 401 | } |