From 0697d6ef26e60c6d9ae88dc08b95231fd5078c35 Mon Sep 17 00:00:00 2001 From: Anthoni Rivera Date: Wed, 4 Nov 2026 00:13:49 -0500 Subject: [PATCH] feat: polish the Vercel connection page and revoke on disconnect The callback now renders a small dark page instead of plain text, with a short reason when something fails, and still no scripts (only style-src 'unsafe-inline'). Disconnect deletes the local session and then asks Vercel to revoke the token through the documented revoke endpoint with Basic client credentials; a failure is logged and ignored so the local session always ends. Tests cover the rendered page, the revocation request, and disconnecting when revocation throws. --- docs/vercel-deployment-monitoring.md | 2 +- docs/vercel-security-review.md | 2 +- integrations/vercel-worker/README.md | 7 ++- integrations/vercel-worker/index.mjs | 61 ++++++++++++++++--- integrations/vercel-worker/index.test.mjs | 74 +++++++++++++++++++++-- 5 files changed, 128 insertions(+), 18 deletions(-) diff --git a/docs/vercel-deployment-monitoring.md b/docs/vercel-deployment-monitoring.md index 39ab4f9..caf94b1 100644 --- a/docs/vercel-deployment-monitoring.md +++ b/docs/vercel-deployment-monitoring.md @@ -19,6 +19,6 @@ Show production deployments from every project available to a Vercel integration ## Current status -A Worker and desktop prototype exist, with local pairing, polling, UI, notification code, and mocked tests. The Worker is deployed at `agent-notch-vercel.onilabs-dev.workers.dev` with its three secrets set, the Vercel integration is registered but not submitted for public listing, and the pairing flow completed end to end once on 2026-11-04 (team installation). A review of the pairing flow is in `docs/vercel-security-review.md` (not independent), the privacy wording to ship with it is drafted in `docs/vercel-privacy.md`, and the Worker URL is injected at packaging time with `VERCEL_WORKER_URL`. Still required: an independent review, a personal-account run, revocation on disconnect, and a real deployment rendered end to end; see `integrations/vercel-worker/README.md` before any redeployment. +A Worker and desktop prototype exist, with local pairing, polling, UI, notification code, and mocked tests. The Worker is deployed at `agent-notch-vercel.onilabs-dev.workers.dev` with its three secrets set, the Vercel integration is registered but not submitted for public listing, and the pairing flow completed end to end once on 2026-11-04 (team installation). A review of the pairing flow is in `docs/vercel-security-review.md` (not independent), the privacy wording shipped with 0.3.4, and the Worker URL is injected at packaging time with `VERCEL_WORKER_URL`. Disconnect now asks Vercel to revoke the token, best effort. Still required: an independent review, a personal-account run, a live confirmation that the revocation call returns 200, and a real deployment rendered end to end; see `integrations/vercel-worker/README.md` before any redeployment. References: [Vercel integration authorization](https://vercel.com/docs/integrations/create-integration/vercel-api-integrations), [Vercel integration registration](https://vercel.com/docs/integrations/create-integration/submit-integration), [Cloudflare Worker secrets](https://developers.cloudflare.com/workers/configuration/secrets/). diff --git a/docs/vercel-security-review.md b/docs/vercel-security-review.md index ad2761e..476ac1e 100644 --- a/docs/vercel-security-review.md +++ b/docs/vercel-security-review.md @@ -22,7 +22,7 @@ cleared findings. | # | Severity | Finding | Status | |---|----------|---------|--------| -| F1 | Medium | **Disconnect deletes the local session but does not revoke the Vercel token.** Access remains until the user removes the integration in Vercel; the confirmation dialog says so, but a user who disconnects and forgets leaves a live token. | Open: `POST https://api.vercel.com/v2/oauth/revoke` answers `415` instead of `404` to an empty request, so the endpoint likely exists, but its contract is not in the pages reviewed. Confirm the parameters before calling it; until then keep the current copy and make it louder. | +| F1 | Medium | **Disconnect deleted the local session but did not revoke the Vercel token.** Access stayed alive until the user removed the integration in Vercel. | Fixed, best effort: on disconnect the Worker deletes the session and then calls the documented `POST https://api.vercel.com/login/oauth/token/revoke` with Basic client credentials (`client_id:client_secret`) and the token, logging the status. A revocation failure is logged and ignored, so the local session always ends. Caveat: Vercel documents that endpoint for Sign in with Vercel tokens; this is the first use against an integration token, so confirm the log says `revoke 200` on the next real disconnect. | | F2 | Medium | **An expired Vercel token was reported as a generic failure.** A `401` from Vercel became `502` to the client, so the app showed "Could not connect to Vercel" forever instead of asking to reconnect. | Fixed: Vercel `401` now maps to `401`, which the client already treats as expired and clears. | | F3 | Low | The callback response carried no `X-Content-Type-Options`. | Fixed: `nosniff` added. | | F4 | Low | **The callback is bound to Vercel only by the `code` and the secrecy of `state`.** Someone who learns a live `state` (it travels in the Vercel redirect URL, so it can appear in logs) could attach their own authorization to that session; they still cannot read deployments or disconnect, which require `verifier`. | Accepted: standard OAuth-with-state trade-off. Shorten the 10-minute window if that ever matters. | diff --git a/integrations/vercel-worker/README.md b/integrations/vercel-worker/README.md index 8671320..c028e2d 100644 --- a/integrations/vercel-worker/README.md +++ b/integrations/vercel-worker/README.md @@ -96,10 +96,11 @@ The pairing flow completed once against production on 2026-11-04 (a team install was found. Repeat this list after any Worker change: - Connect from a build that has the Worker URL, complete the Vercel flow, and confirm the app says - connected. + connected. The browser shows a confirmation page you can close. - Start a real production deployment from a throwaway repository; watch the badge appear when idle and the row in the expanded list. -- Disconnect, then confirm the Worker session is gone (`/status` returns 401) and the Vercel - integration's access was revoked or flagged for manual removal. +- Disconnect, then confirm the Worker session is gone (`/status` returns 401) and the Worker logs + `revoke 200` for the token revocation. If it logs another status, Vercel rejected the revocation + for that token and the integration must be removed manually. - Restart the app while a deployment runs; the deployment must not notify for work that finished while it was closed. diff --git a/integrations/vercel-worker/index.mjs b/integrations/vercel-worker/index.mjs index 81b5b63..b13948d 100644 --- a/integrations/vercel-worker/index.mjs +++ b/integrations/vercel-worker/index.mjs @@ -6,6 +6,28 @@ const digest = async value => hex(await crypto.subtle.digest('SHA-256', new Text const noRedirects = { redirect: 'manual' }; const redirected = response => response.type === 'opaqueredirect' || (response.status >= 300 && response.status < 400); +// Browser-facing page for the callback. Every string is static: no user input is interpolated, so no escaping is needed. +const page = (status, title, message) => new Response(` + +${title} · Agent Notch + +
+
${status < 400 ? '✓' : '!'}
+

${title}

+

${message}

+

Agent Notch

+
`, { status, headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer', 'X-Content-Type-Options': 'nosniff', 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'" } }); + export default { async fetch(request, env) { const url = new URL(request.url); @@ -38,9 +60,10 @@ export class Session { if (url.pathname === '/callback' && request.method === 'GET') { return this.state.blockConcurrencyWhile(async () => { try { - if (!(await storage.get('challenge')) || Date.now() > (await storage.get('expires')) || await storage.get('token')) return json({ error: 'Expired or completed' }, 400); + if (!(await storage.get('challenge')) || Date.now() > (await storage.get('expires')) || await storage.get('token')) + return page(400, 'This connection expired', 'Start again from Agent Notch: choose Connect Vercel… and authorize.'); const code = url.searchParams.get('code'); - if (!code || code.length > 1024) return json({ error: 'Invalid code' }, 400); + if (!code || code.length > 1024) return page(400, 'Vercel did not send an authorization code', 'Start again from Agent Notch.'); // Log presence, never values: a missing secret is the most common cause of a failed exchange. console.log('callback', { hasCode: Boolean(code), clientId: Boolean(this.env.VERCEL_CLIENT_ID), clientSecret: Boolean(this.env.VERCEL_CLIENT_SECRET), redirectUri: this.env.VERCEL_REDIRECT_URI ?? null }); const response = await fetch('https://api.vercel.com/v2/oauth/access_token', { @@ -48,24 +71,29 @@ export class Session { body: new URLSearchParams({ client_id: this.env.VERCEL_CLIENT_ID, client_secret: this.env.VERCEL_CLIENT_SECRET, code, redirect_uri: this.env.VERCEL_REDIRECT_URI }) }); console.log('token exchange', response.status); - if (redirected(response)) return json({ error: 'Authorization failed' }, 502); - if (!response.ok) return json({ error: 'Authorization failed' }, 502); + if (redirected(response)) return page(502, 'Vercel rejected the connection', 'Check that the redirect URL matches the integration settings, then try again from Agent Notch.'); + if (!response.ok) return page(502, 'Vercel rejected the connection', 'Check that the client ID, secret and redirect URL match, then try again from Agent Notch.'); let result; - try { result = JSON.parse(await response.text()); } catch { return json({ error: 'Authorization response was not JSON' }, 502); } + try { result = JSON.parse(await response.text()); } catch { return page(502, 'Unexpected response from Vercel', 'Try again from Agent Notch.'); } // A personal account installs with team_id null; only a team install has an id to scope requests with. - if (typeof result?.access_token !== 'string' || !result.access_token) return json({ error: 'Invalid authorization response' }, 502); + if (typeof result?.access_token !== 'string' || !result.access_token) return page(502, 'Unexpected response from Vercel', 'Try again from Agent Notch.'); await storage.put({ token: result.access_token, team: typeof result.team_id === 'string' ? result.team_id : '' }); - return new Response('Connected. Return to Agent Notch.', { headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer', 'X-Content-Type-Options': 'nosniff', 'Content-Security-Policy': "default-src 'none'" } }); + return page(200, 'Agent Notch is connected', 'You can close this tab and return to the app. Deployments appear in the notch while they run.'); } catch (error) { // Never surface a 1101 here: the browser should show a short reason and the log keeps the stack. console.log('callback failed', error?.stack ?? String(error)); - return json({ error: 'Callback failed' }, 502); + return page(502, 'The connection could not be completed', 'Try again from Agent Notch. If it keeps failing, check the Worker logs.'); } }); } const verifier = request.headers.get('Authorization')?.replace(/^Bearer /, '') ?? ''; if (!pattern.test(verifier) || await digest(verifier) !== await storage.get('challenge')) return json({ error: 'Unauthorized' }, 401); - if (url.pathname === '/disconnect' && request.method === 'POST') { await storage.deleteAll(); return json({ ok: true }); } + if (url.pathname === '/disconnect' && request.method === 'POST') { + const token = await storage.get('token'); + await storage.deleteAll(); + if (typeof token === 'string' && token) await this.revoke(token); + return json({ ok: true }); + } if (!await storage.get('claimed') && Date.now() > (await storage.get('expires'))) { await storage.deleteAll(); return json({ error: 'Expired' }, 410); } const token = await storage.get('token'); if (!token) return Date.now() > (await storage.get('expires')) ? json({ error: 'Expired' }, 410) : json({ status: 'pending' }); @@ -100,4 +128,19 @@ export class Session { } async alarm() { if (!await this.state.storage.get('claimed')) await this.state.storage.deleteAll(); } + + /// Best effort: revokes the Vercel token when the user disconnects. A failure here must never keep the local session alive. + async revoke(token) { + try { + const basic = btoa(`${this.env.VERCEL_CLIENT_ID}:${this.env.VERCEL_CLIENT_SECRET}`); + const response = await fetch('https://api.vercel.com/login/oauth/token/revoke', { + ...noRedirects, method: 'POST', + headers: { Authorization: `Basic ${basic}`, 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ token }) + }); + console.log('revoke', response.status); + } catch (error) { + console.log('revoke failed', String(error)); + } + } } diff --git a/integrations/vercel-worker/index.test.mjs b/integrations/vercel-worker/index.test.mjs index 7bcca2a..8ab5f3f 100644 --- a/integrations/vercel-worker/index.test.mjs +++ b/integrations/vercel-worker/index.test.mjs @@ -69,7 +69,8 @@ test('a failing token exchange answers 502 instead of throwing a 1101', async () try { const response = await session.fetch(req('/callback?code=example')); assert.equal(response.status, 502); - assert.deepEqual(await response.json(), {error: 'Callback failed'}); + assert.match(response.headers.get('Content-Type') ?? '', /text\/html/); + assert.match(await response.text(), /could not be completed/); } finally { globalThis.fetch = oldFetch; } }); @@ -81,7 +82,7 @@ test('a non-JSON authorization response answers 502 instead of throwing', async try { const response = await session.fetch(req('/callback?code=example')); assert.equal(response.status, 502); - assert.deepEqual(await response.json(), {error: 'Authorization response was not JSON'}); + assert.match(await response.text(), /Unexpected response from Vercel/); } finally { globalThis.fetch = oldFetch; } }); @@ -93,7 +94,70 @@ test('a redirect from Vercel is refused instead of followed', async () => { try { const response = await session.fetch(req('/callback?code=example')); assert.equal(response.status, 502); - assert.deepEqual(await response.json(), {error: 'Authorization failed'}); + assert.match(await response.text(), /Vercel rejected the connection/); + } finally { globalThis.fetch = oldFetch; } +}); + +test('disconnect revokes the Vercel token with client credentials', async () => { + let revoked = null; + const { session } = setup(); + await session.fetch(req('/start', {method:'POST', headers:{'X-Pairing-Challenge':challenge}})); + const oldFetch = globalThis.fetch; + globalThis.fetch = async (input, options) => { + const url = new URL(input); + if (url.pathname === '/v2/oauth/access_token') return Response.json({access_token:'private-token', team_id:'team_123'}); + if (url.pathname === '/login/oauth/token/revoke') { + revoked = { auth: options.headers.Authorization, token: new URLSearchParams(options.body).get('token') }; + return new Response(null, {status: 200}); + } + throw new Error('unexpected request: ' + url.pathname); + }; + try { + assert.equal((await session.fetch(req('/callback?code=example'))).status, 200); + await session.fetch(req('/status', {headers:{Authorization:'Bearer ' + secret}})); + assert.equal((await session.fetch(req('/disconnect', {method:'POST', headers:{Authorization:'Bearer ' + secret}}))).status, 200); + assert.equal(revoked.token, 'private-token'); + assert.match(revoked.auth, /^Basic /); + assert.equal((await session.fetch(req('/status', {headers:{Authorization:'Bearer ' + secret}}))).status, 401); + } finally { globalThis.fetch = oldFetch; } +}); + +test('the callback renders a human page, including for an expired session', async () => { + const { session } = setup(); + await session.fetch(req('/start', {method:'POST', headers:{'X-Pairing-Challenge':challenge}})); + const oldFetch = globalThis.fetch; + globalThis.fetch = async () => Response.json({access_token:'private-token', team_id:'team_123'}); + try { + const success = await session.fetch(req('/callback?code=example')); + assert.equal(success.status, 200); + assert.match(success.headers.get('Content-Type') ?? '', /text\/html/); + assert.match(success.headers.get('Content-Security-Policy') ?? '', /style-src 'unsafe-inline'/); + const html = await success.text(); + assert.match(html, /Agent Notch is connected/); + assert.match(html, /close this tab/); + assert.doesNotMatch(html, /private-token/); + + const replay = await session.fetch(req('/callback?code=example')); + assert.equal(replay.status, 400); + assert.match(await replay.text(), /This connection expired/); + } finally { globalThis.fetch = oldFetch; } +}); + +test('disconnect still succeeds when revocation throws', async () => { + const { session } = setup(); + await session.fetch(req('/start', {method:'POST', headers:{'X-Pairing-Challenge':challenge}})); + const oldFetch = globalThis.fetch; + globalThis.fetch = async input => { + const path = new URL(input).pathname; + if (path === '/v2/oauth/access_token') return Response.json({access_token:'private-token', team_id:'team_123'}); + if (path === '/login/oauth/token/revoke') throw new TypeError('network down'); + throw new Error('unexpected request: ' + path); + }; + try { + assert.equal((await session.fetch(req('/callback?code=example'))).status, 200); + await session.fetch(req('/status', {headers:{Authorization:'Bearer ' + secret}})); + assert.equal((await session.fetch(req('/disconnect', {method:'POST', headers:{Authorization:'Bearer ' + secret}}))).status, 200); + assert.equal((await session.fetch(req('/status', {headers:{Authorization:'Bearer ' + secret}}))).status, 401); } finally { globalThis.fetch = oldFetch; } }); @@ -165,7 +229,9 @@ test('simulated installation stays isolated across devices and does not leak met globalThis.fetch = async (input, options) => { // Workers rejects "error"; manual is the only way to refuse redirects at the edge. assert.equal(options.redirect, 'manual'); - if (new URL(input).pathname === '/v2/oauth/access_token') return Response.json({access_token:'server-secret',team_id:'team_123'}); + const path = new URL(input).pathname; + if (path === '/v2/oauth/access_token') return Response.json({access_token:'server-secret',team_id:'team_123'}); + if (path === '/login/oauth/token/revoke') return new Response(null, {status: 200}); assert.equal(options.headers.Authorization, 'Bearer server-secret'); return Response.json({deployments:[{uid:'dpl_1',name:'demo',state:'BUILDING',meta:{githubRepo:'repo',privateField:'sensitive'},secret:'sensitive'}],pagination:{next:null}}); };