idp-browser-allow-proof.mjs282 lines · main
1#!/usr/bin/env node
2/**
3 * Slice 1 IdP proof — create OIDC client → authorize → consent Allow → code → tokens.
4 *
5 * Uses the same HTTP surfaces a browser uses (FDI session cookies + consent API).
6 * Needs: CLI user token (~/.config/briven/credentials.json) with admin on project.
7 *
8 * node scripts/idp-browser-allow-proof.mjs [projectId]
9 *
10 * Default project: p_01KWQ37MSQPAZNQCTESBV370NM (Mavi pay pilot)
11 */
12
13import { createServer } from 'node:http';
14import { randomBytes, createHash } from 'node:crypto';
15import { readFileSync } from 'node:fs';
16import { homedir } from 'node:os';
17import { join } from 'node:path';
18
19const API = 'https://api.briven.tech';
20const projectId = process.argv[2] || 'p_01KWQ37MSQPAZNQCTESBV370NM';
21
22function fail(msg, extra) {
23 console.error('FAIL', msg, extra ?? '');
24 process.exit(1);
25}
26function ok(msg) {
27 console.log('ok', msg);
28}
29
30function loadUserToken() {
31 const path = join(homedir(), '.config/briven/credentials.json');
32 const raw = JSON.parse(readFileSync(path, 'utf8'));
33 if (!raw.user?.token) fail('no CLI user token — run briven login first');
34 return { token: raw.user.token, apiOrigin: (raw.user.apiOrigin || API).replace(/\/$/, '') };
35}
36
37/** Minimal cookie jar for api.briven.tech */
38function jar() {
39 const map = new Map();
40 return {
41 store(res) {
42 const raw = res.headers.getSetCookie?.() ?? [];
43 // Node < 20 fallback
44 const list =
45 raw.length > 0
46 ? raw
47 : (res.headers.get('set-cookie') ? [res.headers.get('set-cookie')] : []);
48 for (const line of list) {
49 if (!line) continue;
50 const part = line.split(';')[0];
51 const eq = part.indexOf('=');
52 if (eq < 1) continue;
53 map.set(part.slice(0, eq), part.slice(eq + 1));
54 }
55 },
56 header() {
57 return [...map.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
58 },
59 };
60}
61
62async function main() {
63 console.log('=== Slice 1 IdP Allow proof ===');
64 console.log('project', projectId);
65
66 const { token: userToken, apiOrigin } = loadUserToken();
67 const cookies = jar();
68
69 // 0) Discovery live
70 const disc = await fetch(
71 `${apiOrigin}/v1/auth-core/oidc/.well-known/openid-configuration`,
72 );
73 if (!disc.ok) fail('discovery', disc.status);
74 const discJson = await disc.json();
75 if (!discJson.authorization_endpoint) fail('discovery shape', discJson);
76 ok('discovery + endpoints');
77
78 // 1) Mint a throwaway browser key for FDI (or fail if unauthorized)
79 const keyRes = await fetch(
80 `${apiOrigin}/v1/auth-core/projects/${encodeURIComponent(projectId)}/keys`,
81 {
82 method: 'POST',
83 headers: {
84 authorization: `Bearer ${userToken}`,
85 'content-type': 'application/json',
86 accept: 'application/json',
87 },
88 body: JSON.stringify({ name: `idp-proof-${Date.now()}`, scope: 'read-write' }),
89 },
90 );
91 const keyBody = await keyRes.json().catch(() => ({}));
92 if (!keyRes.ok) fail('mint pk key', { status: keyRes.status, keyBody });
93 const pk = keyBody.key?.plaintext;
94 if (!pk?.startsWith('pk_briven_auth_')) fail('no plaintext pk', keyBody);
95 ok(`minted ${keyBody.key?.hint ?? 'pk'}`);
96
97 // 2) Register confidential OIDC client (redirect to local catcher)
98 const port = 18765;
99 const redirectUri = `http://127.0.0.1:${port}/cb`;
100 const clientRes = await fetch(
101 `${apiOrigin}/v1/auth-core/projects/${encodeURIComponent(projectId)}/oidc/clients`,
102 {
103 method: 'POST',
104 headers: {
105 authorization: `Bearer ${userToken}`,
106 'content-type': 'application/json',
107 accept: 'application/json',
108 },
109 body: JSON.stringify({
110 name: `Slice1 proof ${new Date().toISOString().slice(0, 16)}`,
111 redirectUris: [redirectUri],
112 isPublic: false,
113 }),
114 },
115 );
116 const clientBody = await clientRes.json().catch(() => ({}));
117 if (!clientRes.ok) fail('create oidc client', { status: clientRes.status, clientBody });
118 const clientId = clientBody.client?.clientId;
119 const clientSecret = clientBody.client?.clientSecret;
120 if (!clientId || !clientSecret) fail('client missing id/secret', clientBody);
121 ok(`client ${clientId}`);
122
123 // 3) End-user signup via FDI (sets engine session cookies on API host)
124 const email = `idp.proof.${Date.now()}@example.com`;
125 const password = 'IdpProof!Allow99';
126 const su = await fetch(`${apiOrigin}/v1/auth-core/fdi/signup`, {
127 method: 'POST',
128 headers: {
129 'content-type': 'application/json',
130 accept: 'application/json',
131 authorization: `Bearer ${pk}`,
132 'x-briven-project-id': projectId,
133 origin: 'http://localhost:3000',
134 },
135 body: JSON.stringify({ email, password }),
136 });
137 cookies.store(su);
138 const suBody = await su.json().catch(() => ({}));
139 if (!su.ok || suBody.status !== 'OK') fail('fdi signup', { status: su.status, suBody });
140 ok(`end-user ${email}`);
141
142 // 4) Local callback catcher
143 const got = { code: null, state: null, err: null };
144 const server = await new Promise((resolve) => {
145 const s = createServer((req, res) => {
146 const u = new URL(req.url || '/', `http://127.0.0.1:${port}`);
147 if (u.pathname === '/cb') {
148 got.code = u.searchParams.get('code');
149 got.state = u.searchParams.get('state');
150 got.err = u.searchParams.get('error');
151 res.writeHead(200, { 'content-type': 'text/html' });
152 res.end(
153 '<!doctype html><html><body style="font-family:monospace;background:#0a0b0d;color:#e8e8ea;padding:2rem"><h1>you\'re in</h1><p>IdP callback received. You can close this tab.</p></body></html>',
154 );
155 return;
156 }
157 res.writeHead(404);
158 res.end('not found');
159 });
160 s.listen(port, '127.0.0.1', () => resolve(s));
161 });
162
163 const state = randomBytes(16).toString('hex');
164 const authUrl = new URL(`${apiOrigin}/v1/auth-core/oidc/authorize`);
165 authUrl.searchParams.set('client_id', clientId);
166 authUrl.searchParams.set('redirect_uri', redirectUri);
167 authUrl.searchParams.set('response_type', 'code');
168 authUrl.searchParams.set('scope', 'openid profile email');
169 authUrl.searchParams.set('state', state);
170
171 // 5) Authorize with session cookies — should land on consent or code
172 const authRes = await fetch(authUrl.toString(), {
173 redirect: 'manual',
174 headers: {
175 cookie: cookies.header(),
176 accept: 'text/html,application/json',
177 },
178 });
179 cookies.store(authRes);
180 const loc = authRes.headers.get('location') || '';
181 ok(`authorize → ${authRes.status} ${loc.slice(0, 120)}`);
182
183 if (loc.startsWith(redirectUri) && loc.includes('code=')) {
184 const u = new URL(loc);
185 got.code = u.searchParams.get('code');
186 got.state = u.searchParams.get('state');
187 ok('short-circuit code (prior consent)');
188 } else if (loc.includes('/oauth/consent') && loc.includes('challenge=')) {
189 const challenge = new URL(loc, 'https://briven.tech').searchParams.get('challenge');
190 if (!challenge) fail('no challenge in consent redirect', loc);
191 // 6) Consent Allow — same as the browser Allow button
192 const consentRes = await fetch(`${apiOrigin}/v1/auth-core/oidc/consent`, {
193 method: 'POST',
194 headers: {
195 'content-type': 'application/json',
196 accept: 'application/json',
197 cookie: cookies.header(),
198 },
199 body: JSON.stringify({ challenge, decision: 'allow' }),
200 redirect: 'manual',
201 });
202 cookies.store(consentRes);
203 const consentBody = await consentRes.json().catch(() => ({}));
204 const redirectOut =
205 consentBody.redirectUrl ||
206 consentBody.redirect_uri ||
207 consentRes.headers.get('location') ||
208 '';
209 ok(`consent allow → ${consentRes.status}`);
210 if (!redirectOut.includes('code=') && !consentBody.code) {
211 // Some implementations return { redirectUrl }
212 if (consentBody.redirectUrl) {
213 const u = new URL(consentBody.redirectUrl);
214 got.code = u.searchParams.get('code');
215 got.state = u.searchParams.get('state');
216 } else {
217 fail('consent did not return code redirect', {
218 status: consentRes.status,
219 consentBody,
220 redirectOut,
221 });
222 }
223 } else if (consentBody.redirectUrl) {
224 const u = new URL(consentBody.redirectUrl);
225 got.code = u.searchParams.get('code');
226 got.state = u.searchParams.get('state');
227 } else if (redirectOut.includes('code=')) {
228 const u = new URL(redirectOut);
229 got.code = u.searchParams.get('code');
230 got.state = u.searchParams.get('state');
231 }
232 ok('Allow granted (consent API = browser Allow button)');
233 } else if (loc.includes('/sign-in')) {
234 fail('session cookie not accepted — landed on sign-in', loc);
235 } else {
236 fail('unexpected authorize redirect', { status: authRes.status, loc });
237 }
238
239 if (!got.code) fail('no authorization code');
240 if (got.state && got.state !== state) fail('state mismatch', got);
241 ok(`code ${got.code.slice(0, 12)}…`);
242
243 // 7) Token exchange
244 const basic = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
245 const tokenRes = await fetch(`${apiOrigin}/v1/auth-core/oidc/token`, {
246 method: 'POST',
247 headers: {
248 'content-type': 'application/x-www-form-urlencoded',
249 authorization: `Basic ${basic}`,
250 accept: 'application/json',
251 },
252 body: new URLSearchParams({
253 grant_type: 'authorization_code',
254 code: got.code,
255 redirect_uri: redirectUri,
256 }),
257 });
258 const tokens = await tokenRes.json().catch(() => ({}));
259 if (!tokenRes.ok || !tokens.access_token) {
260 fail('token exchange', { status: tokenRes.status, tokens });
261 }
262 ok('token exchange (access_token + maybe id_token)');
263
264 // 8) userinfo
265 const ui = await fetch(`${apiOrigin}/v1/auth-core/oidc/userinfo`, {
266 headers: { authorization: `Bearer ${tokens.access_token}` },
267 });
268 const uiBody = await ui.json().catch(() => ({}));
269 if (!ui.ok) fail('userinfo', { status: ui.status, uiBody });
270 ok(`userinfo sub=${uiBody.sub ?? uiBody.id ?? '?'}`);
271
272 server.close();
273 console.log('');
274 console.log('PASS Slice 1 IdP Allow path (discovery → client → session → Allow → code → tokens → userinfo)');
275 console.log('project', projectId);
276 console.log('client_id', clientId);
277}
278
279main().catch((e) => {
280 console.error(e);
281 process.exit(1);
282});