idp-browser-allow-proof.mjs 10 KB

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