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 | |
| 13 | import { createServer } from 'node:http'; |
| 14 | import { randomBytes, createHash } from 'node:crypto'; |
| 15 | import { readFileSync } from 'node:fs'; |
| 16 | import { homedir } from 'node:os'; |
| 17 | import { join } from 'node:path'; |
| 18 | |
| 19 | const API = 'https://api.briven.tech'; |
| 20 | const projectId = process.argv[2] || 'p_01KWQ37MSQPAZNQCTESBV370NM'; |
| 21 | |
| 22 | function fail(msg, extra) { |
| 23 | console.error('FAIL', msg, extra ?? ''); |
| 24 | process.exit(1); |
| 25 | } |
| 26 | function ok(msg) { |
| 27 | console.log('ok', msg); |
| 28 | } |
| 29 | |
| 30 | function 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 */ |
| 38 | function 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 | |
| 62 | async 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 | |
| 279 | main().catch((e) => { |
| 280 | console.error(e); |
| 281 | process.exit(1); |
| 282 | }); |