auth.ts401 lines · main
1import { mkdir, readFile, writeFile } from 'node:fs/promises';
2import { dirname, resolve } from 'node:path';
3
4import { apiCall, ApiCallError } from '../api-client.js';
5import { readCredentials, readUserCredential } from '../config.js';
6import { error as printError, banner, step, success, blankLine } from '../output.js';
7import { 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 */
13const MIDDLEWARE_TS = `import { NextResponse, type NextRequest } from 'next/server';
14
15const BRIVEN_API_ORIGIN = process.env.NEXT_PUBLIC_BRIVEN_API_ORIGIN ?? 'https://api.briven.tech';
16const 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.
19const 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 */
28export 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
60export const config = {
61 matcher: ['/api/auth/:path*'],
62};
63`;
64
65function envLocalTemplate(projectId: string): string {
66 return `# Briven Auth — fill public key from dashboard → Auth → API keys
67# https://docs.briven.tech/auth
68NEXT_PUBLIC_BRIVEN_API_ORIGIN=https://api.briven.tech
69NEXT_PUBLIC_BRIVEN_PROJECT_ID=${projectId}
70# Browser-safe key (pk_briven_auth_…). Never put a brk_ server key here.
71NEXT_PUBLIC_BRIVEN_AUTH_KEY=pk_briven_auth_xxxxxxxxxxxxxxxx
72# Optional alias for middleware (same value as NEXT_PUBLIC_BRIVEN_AUTH_KEY)
73BRIVEN_AUTH_PUBLIC_KEY=pk_briven_auth_xxxxxxxxxxxxxxxx
74`;
75}
76
77const 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 */
83export const auth = createBrivenAuth({
84 projectId: process.env.NEXT_PUBLIC_BRIVEN_PROJECT_ID!,
85 publicKey: process.env.NEXT_PUBLIC_BRIVEN_AUTH_KEY!,
86});
87`;
88
89const 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
112export 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
134interface 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
149export 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 */
164export 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 */
206async 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). */
289async 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
334async 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
399function link(url: string): void {
400 step(` docs: ${url}`);
401}