From 5ac13a5cf7f32788b7cc37428aca3c7636d23e1d Mon Sep 17 00:00:00 2001 From: Ales Rechtorik Date: Tue, 1 Sep 2026 15:56:18 +0200 Subject: [PATCH] feat(zerops): the hand-over delivers a personal token, not a session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit app.zerops.io now mints a personal access token for this client instead of redirecting the account's own refresh token. It is already a bearer, so there is nothing to exchange — `adoptPersonalToken` replaces the refresh call. What it does instead is prove the token before storing it: a dead or revoked token that reached storage would render a signed-in-looking UI that fails on its first real call. So it is held in memory, spent on one `/user/info` read, and persisted only once that comes back. It carries no refresh token, which is correct rather than a gap: on a 401 the request path clears the session instead of trying to refresh, which is exactly what should happen to a token the user revoked. --- apps/web/src/routes/zerops_.authorized.tsx | 5 +- apps/web/src/zerops/ZeropsSessionProvider.tsx | 7 ++- apps/web/src/zerops/handover.test.ts | 16 +++--- .../client-runtime/src/zerops/api.test.ts | 56 +++++++------------ packages/client-runtime/src/zerops/api.ts | 44 +++++++-------- .../src/zerops/handover.test.ts | 8 +-- .../client-runtime/src/zerops/handover.ts | 27 +++++---- 7 files changed, 74 insertions(+), 89 deletions(-) diff --git a/apps/web/src/routes/zerops_.authorized.tsx b/apps/web/src/routes/zerops_.authorized.tsx index 5ca3db0aa..5afbc684e 100644 --- a/apps/web/src/routes/zerops_.authorized.tsx +++ b/apps/web/src/routes/zerops_.authorized.tsx @@ -86,10 +86,7 @@ function ZeropsHandoverCallback() { return; } - void adoptHandover({ - refreshToken: outcome.refreshToken, - zcpClaimed: outcome.zcpClaimed, - }) + void adoptHandover({ token: outcome.token, zcpClaimed: outcome.zcpClaimed }) .then(() => navigate({ to: "/", replace: true })) .catch((cause: unknown) => { setState({ kind: "failed", message: zeropsErrorMessage(cause) }); diff --git a/apps/web/src/zerops/ZeropsSessionProvider.tsx b/apps/web/src/zerops/ZeropsSessionProvider.tsx index ba2eea189..2f699bc88 100644 --- a/apps/web/src/zerops/ZeropsSessionProvider.tsx +++ b/apps/web/src/zerops/ZeropsSessionProvider.tsx @@ -43,7 +43,8 @@ export interface ZeropsSessionValue { * session arrives without a password ever being typed here. */ readonly adoptHandover: (input: { - readonly refreshToken: string; + /** A personal access token minted for this client by app.zerops.io. */ + readonly token: string; /** True when the account just claimed a pool project, so the picker is skipped. */ readonly zcpClaimed: boolean; }) => Promise; @@ -130,8 +131,8 @@ export function ZeropsSessionProvider({ status, user, organizations: user ? zeropsClientsFromUser(user) : [], - adoptHandover: async ({ refreshToken, zcpClaimed }) => { - const session = await client.adoptHandedOverSession(refreshToken); + adoptHandover: async ({ token, zcpClaimed }) => { + const session = await client.adoptPersonalToken(token); const adopted = await client.fetchUser(); setUser(adopted); setStatus("signed-in"); diff --git a/apps/web/src/zerops/handover.test.ts b/apps/web/src/zerops/handover.test.ts index 81fbb9216..1c344932b 100644 --- a/apps/web/src/zerops/handover.test.ts +++ b/apps/web/src/zerops/handover.test.ts @@ -63,13 +63,13 @@ describe("completeZeropsHandover", () => { it("accepts a callback answering the nonce this browser stored", () => { const store = fakeStore("nonce-1"); const outcome = completeZeropsHandover({ - fragment: "#refreshToken=rt-1&state=nonce-1&clientId=org-1&zcpClaimed=true", + fragment: "#token=rt-1&state=nonce-1&clientId=org-1&zcpClaimed=true", store, }); expect(outcome).toEqual({ kind: "session", - refreshToken: "rt-1", + token: "rt-1", clientId: "org-1", zcpClaimed: true, }); @@ -79,7 +79,7 @@ describe("completeZeropsHandover", () => { // A back button, a restored tab or a copied link must not sign anyone in // a second time off one authorization. const store = fakeStore("nonce-1"); - const fragment = "#refreshToken=rt-1&state=nonce-1"; + const fragment = "#token=rt-1&state=nonce-1"; expect(completeZeropsHandover({ fragment, store })).toMatchObject({ kind: "session" }); expect(completeZeropsHandover({ fragment, store })).toEqual({ kind: "mismatched" }); @@ -88,7 +88,7 @@ describe("completeZeropsHandover", () => { it("refuses a credential this browser never asked for, and reads nothing out of it", () => { const store = fakeStore(null); const outcome = completeZeropsHandover({ - fragment: "#refreshToken=attacker-token&state=whatever", + fragment: "#token=attacker-token&state=whatever", store, }); @@ -155,7 +155,7 @@ describe("reading the callback exactly once", () => { // server: run 1 `session`, run 2 `absent`. it("returns the first outcome to every later caller, and reads only once", () => { const outcomes: ZeropsHandoverOutcome[] = [ - { kind: "session", refreshToken: "rt-1", clientId: null, zcpClaimed: false }, + { kind: "session", token: "rt-1", clientId: null, zcpClaimed: false }, { kind: "absent" }, ]; let reads = 0; @@ -164,9 +164,9 @@ describe("reading the callback exactly once", () => { return outcomes[reads - 1] ?? { kind: "absent" }; }); - expect(read()).toMatchObject({ kind: "session", refreshToken: "rt-1" }); - expect(read()).toMatchObject({ kind: "session", refreshToken: "rt-1" }); - expect(read()).toMatchObject({ kind: "session", refreshToken: "rt-1" }); + expect(read()).toMatchObject({ kind: "session", token: "rt-1" }); + expect(read()).toMatchObject({ kind: "session", token: "rt-1" }); + expect(read()).toMatchObject({ kind: "session", token: "rt-1" }); expect(reads).toBe(1); }); diff --git a/packages/client-runtime/src/zerops/api.test.ts b/packages/client-runtime/src/zerops/api.test.ts index 6eca325e6..920e49fd7 100644 --- a/packages/client-runtime/src/zerops/api.test.ts +++ b/packages/client-runtime/src/zerops/api.test.ts @@ -415,19 +415,14 @@ describe("ZeropsApiClient project reads", () => { }); }); -describe("ZeropsApiClient.adoptHandedOverSession", () => { - // The hand-over from app.zerops.io delivers one string. Everything else the - // session needs comes back from the exchange. - it("exchanges a bare refresh token with no bearer, and stores what comes back", async () => { +describe("ZeropsApiClient.adoptPersonalToken", () => { + // The hand-over from app.zerops.io delivers a personal access token, which is + // already a bearer — there is nothing to exchange. What there is to do is + // prove it works before storing it, so a dead token never becomes a + // signed-in-looking UI. + it("proves the token before storing it, and stores exactly it", async () => { const stored: Array = []; - const stub = recordingFetch(() => - jsonResponse(200, { - accessToken: "access-9", - refreshToken: "refresh-9", - userId: "user-9", - expiresIn: 900, - }), - ); + const stub = recordingFetch(() => jsonResponse(200, { id: "user-9", email: "a@b.c" })); const client = new ZeropsApiClient({ fetch: stub.fetch, onSessionChange: (session) => { @@ -435,25 +430,20 @@ describe("ZeropsApiClient.adoptHandedOverSession", () => { }, }); - const session = await client.adoptHandedOverSession("handed-over-1"); + const session = await client.adoptPersonalToken("pt-abc"); + // One call, and it carried the token as the bearer. expect(stub.requests).toHaveLength(1); - expect(stub.requests[0]?.url).toBe(`${DEFAULT_ZEROPS_API_BASE}/api/rest/public/auth/refresh`); - expect(stub.requests[0]?.method).toBe("POST"); - // `/authorize` in the platform's own GUI performs this exchange straight - // after logging out, so the call is proven not to need one. - expect(stub.requests[0]?.authorization).toBeNull(); - expect(JSON.parse(stub.requests[0]?.body ?? "{}")).toEqual({ - refreshTokenId: "handed-over-1", - }); - // `/auth/refresh` answers with the session fields at the top level, unlike - // `/auth/login`, which wraps them in `auth`. - expect(session.accessToken).toBe("access-9"); - expect(client.session?.accessToken).toBe("access-9"); + expect(stub.requests[0]?.url).toBe(`${DEFAULT_ZEROPS_API_BASE}/api/rest/public/user/info`); + expect(stub.requests[0]?.authorization).toBe("Bearer pt-abc"); + expect(session.accessToken).toBe("pt-abc"); + // No refresh token: a personal token does not have one, and the 401 path + // must clear the session rather than try to refresh it. + expect(session.refreshToken).toBeUndefined(); expect(stored).toEqual([session]); }); - it("refuses a token the platform will not exchange, and stores nothing", async () => { + it("stores nothing when the token is refused", async () => { const stored: Array = []; const stub = recordingFetch(() => jsonResponse(401, { error: { code: "notAuthorized" } })); const client = new ZeropsApiClient({ @@ -463,24 +453,16 @@ describe("ZeropsApiClient.adoptHandedOverSession", () => { }, }); - await expect(client.adoptHandedOverSession("stale")).rejects.toBeInstanceOf(ZeropsApiError); - expect(client.session).toBeNull(); - expect(stored).toEqual([]); - }); - - it("refuses a response that is not a usable session rather than half-signing in", async () => { - const stub = recordingFetch(() => jsonResponse(200, { accessToken: "" })); - const client = new ZeropsApiClient({ fetch: stub.fetch }); - - await expect(client.adoptHandedOverSession("rt")).rejects.toBeInstanceOf(ZeropsApiError); + await expect(client.adoptPersonalToken("dead")).rejects.toBeInstanceOf(ZeropsApiError); expect(client.session).toBeNull(); + expect(stored.filter((s) => s !== null)).toEqual([]); }); it("will not spend a request on an empty hand-over", async () => { const stub = recordingFetch(() => jsonResponse(200, {})); const client = new ZeropsApiClient({ fetch: stub.fetch }); - await expect(client.adoptHandedOverSession(" ")).rejects.toBeInstanceOf(ZeropsApiError); + await expect(client.adoptPersonalToken(" ")).rejects.toBeInstanceOf(ZeropsApiError); expect(stub.requests).toHaveLength(0); }); }); diff --git a/packages/client-runtime/src/zerops/api.ts b/packages/client-runtime/src/zerops/api.ts index 5731e22ff..d0e2cb201 100644 --- a/packages/client-runtime/src/zerops/api.ts +++ b/packages/client-runtime/src/zerops/api.ts @@ -344,36 +344,34 @@ export class ZeropsApiClient { } /** - * Turns a refresh token handed over by `app.zerops.io` into a stored - * session — the client end of the sign-in hand-over (`zerops/handover.ts`). + * Adopts a personal access token handed over by `app.zerops.io` — the client + * end of the sign-in hand-over (`zerops/handover.ts`). * - * The hand-over delivers one string and nothing else, which is enough: the - * platform's own `/authorize` route performs this exchange immediately after - * logging out, so it is proven to need no bearer. Unlike `#refreshSession` - * this never clears on failure: there was no session to lose, and a failed - * hand-over must leave a signed-out client signed out rather than looking - * like an expiry. + * A personal token is already a bearer, so there is nothing to exchange. What + * matters is that it is **proven before it is stored**: a dead or revoked + * token that reached storage would render a signed-in-looking UI that fails + * on its first real call. So it is held in memory, spent on one read, and + * only persisted once that read comes back. + * + * It carries no refresh token, which is correct rather than a gap: on a 401 + * the request path clears the session instead of trying to refresh, which is + * exactly what should happen to a token the user revoked. */ - async adoptHandedOverSession(refreshToken: string): Promise { - const refreshTokenId = refreshToken.trim(); - if (!refreshTokenId) { + async adoptPersonalToken(token: string): Promise { + const accessToken = token.trim(); + if (!accessToken) { throw new ZeropsApiError( "That Zerops sign-in carried no credential. Start again.", "invalid-input", ); } - const response = await this.#fetch(`${this.#baseUrl}${PUBLIC_API_PREFIX}/auth/refresh`, { - method: "POST", - headers: { Accept: "application/json", "Content-Type": "application/json" }, - body: JSON.stringify({ refreshTokenId }), - }); - if (!response.ok) { - throw await apiErrorFromResponse(response); - } - // Top level, not wrapped in `auth` the way `/auth/login` answers. - const session = (await response.json()) as ZeropsSession; - if (!isUsableZeropsSession(session)) { - throw new ZeropsApiError("Zerops returned an invalid sign-in session.", "unexpected"); + const session: ZeropsSession = { accessToken }; + this.#session = session; + try { + await this.fetchUser(); + } catch (cause) { + this.#session = null; + throw cause; } await this.#setSession(session); return session; diff --git a/packages/client-runtime/src/zerops/handover.test.ts b/packages/client-runtime/src/zerops/handover.test.ts index daadc17cd..3acd5b1b5 100644 --- a/packages/client-runtime/src/zerops/handover.test.ts +++ b/packages/client-runtime/src/zerops/handover.test.ts @@ -44,7 +44,7 @@ describe("buildZeropsAuthorizeUrl", () => { describe("readZeropsHandover", () => { const session = (over: Record = {}) => new URLSearchParams({ - refreshToken: "rt-abc", + token: "rt-abc", state: "nonce-1", clientId: "org-1", ...over, @@ -55,7 +55,7 @@ describe("readZeropsHandover", () => { expect(outcome).toEqual({ kind: "session", - refreshToken: "rt-abc", + token: "rt-abc", clientId: "org-1", zcpClaimed: false, }); @@ -71,7 +71,7 @@ describe("readZeropsHandover", () => { it("reports no organization rather than an empty one when the platform named none", () => { const outcome = readZeropsHandover( - `#${new URLSearchParams({ refreshToken: "rt", state: "n" }).toString()}`, + `#${new URLSearchParams({ token: "rt", state: "n" }).toString()}`, "n", ); expect(outcome).toMatchObject({ kind: "session", clientId: null }); @@ -92,7 +92,7 @@ describe("readZeropsHandover", () => { }> = [ { name: "a nonce this tab never issued", fragment: `#${session()}`, expected: "other-nonce" }, { name: "no nonce stored at all", fragment: `#${session()}`, expected: null }, - { name: "no nonce echoed back", fragment: "#refreshToken=rt-abc", expected: "nonce-1" }, + { name: "no nonce echoed back", fragment: "#token=rt-abc", expected: "nonce-1" }, { name: "an empty echoed nonce", fragment: `#${session({ state: "" })}`, diff --git a/packages/client-runtime/src/zerops/handover.ts b/packages/client-runtime/src/zerops/handover.ts index 1ebbcc558..d031bbd76 100644 --- a/packages/client-runtime/src/zerops/handover.ts +++ b/packages/client-runtime/src/zerops/handover.ts @@ -3,9 +3,15 @@ * * Sign-up and third-party sign-in can only run on `app.zerops.io`: Turnstile's * site key is bound to that hostname, and the GitHub OAuth callback is a fixed - * URL registered on Zerops' own OAuth App. So the user authenticates there and - * the platform redirects their refresh token back here, which this client - * exchanges for a session the way it exchanges any other. + * URL registered on Zerops' own OAuth App. So the user authenticates there, the + * platform mints them a **personal access token** for this client, and + * redirects it back here. + * + * A personal token rather than the account's own session: it is minted for this + * client alone, it is revocable on its own from Settings without touching the + * browser session, and nothing durable belonging to the account's own sign-in + * ever crosses. It is user-scoped, so it still spans every organization the + * account belongs to — which the picker needs. * * Two rules shape the contract, and both live in this file so neither side can * drift from them: @@ -16,8 +22,8 @@ * to get wrong. The single exception is a dev server's loopback port, which * is a number on a hostname the platform fixes — see * `ZEROPS_HANDOVER_DEV_APP_MODE`. - * 2. **The credential comes back in the fragment, and only against a nonce - * this browser issued.** A fragment never reaches a server, so it stays out + * 2. **The token comes back in the fragment, and only against a nonce this + * browser issued.** A fragment never reaches a server, so it stays out * of access logs and `Referer`. The nonce is what stops a crafted * `#refreshToken=…` link signing this browser into someone else's account — * which is why `readZeropsHandover` takes the expected nonce as a parameter @@ -97,7 +103,8 @@ export type ZeropsHandoverOutcome = /** Verified: a credential addressed to a request this browser made. */ | { readonly kind: "session"; - readonly refreshToken: string; + /** A personal access token, usable directly as the session's bearer. */ + readonly token: string; /** The organization the platform signed in, or null when it named none. */ readonly clientId: string | null; /** True when a pool project was claimed, so the picker can be skipped. */ @@ -125,13 +132,13 @@ export function readZeropsHandover( expectedState: string | null, ): ZeropsHandoverOutcome { const params = new URLSearchParams(fragment.startsWith("#") ? fragment.slice(1) : fragment); - const refreshToken = params.get("refreshToken")?.trim() ?? ""; + const token = params.get("token")?.trim() ?? ""; const error = params.get("error")?.trim() ?? ""; const echoedState = params.get("state")?.trim() ?? ""; // Anything carrying none of the three is somebody else's fragment, or none // at all — a plain visit to the route, not a failed hand-over. - if (!refreshToken && !error && !echoedState) { + if (!token && !error && !echoedState) { return { kind: "absent" }; } @@ -143,14 +150,14 @@ export function readZeropsHandover( if (error) { return { kind: "declined", code: error }; } - if (!refreshToken) { + if (!token) { return { kind: "declined", code: ZEROPS_HANDOVER_INVALID_CODE }; } const clientId = params.get("clientId")?.trim() ?? ""; return { kind: "session", - refreshToken, + token, clientId: clientId || null, zcpClaimed: params.get("zcpClaimed") === "true", };