Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions apps/web/src/routes/zerops_.authorized.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) });
Expand Down
7 changes: 4 additions & 3 deletions apps/web/src/zerops/ZeropsSessionProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
Expand Down Expand Up @@ -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");
Expand Down
16 changes: 8 additions & 8 deletions apps/web/src/zerops/handover.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand All @@ -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" });
Expand All @@ -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,
});

Expand Down Expand Up @@ -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;
Expand All @@ -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);
});

Expand Down
56 changes: 19 additions & 37 deletions packages/client-runtime/src/zerops/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -415,45 +415,35 @@ 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<ZeropsSession | null> = [];
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) => {
stored.push(session);
},
});

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<ZeropsSession | null> = [];
const stub = recordingFetch(() => jsonResponse(401, { error: { code: "notAuthorized" } }));
const client = new ZeropsApiClient({
Expand All @@ -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);
});
});
44 changes: 21 additions & 23 deletions packages/client-runtime/src/zerops/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ZeropsSession> {
const refreshTokenId = refreshToken.trim();
if (!refreshTokenId) {
async adoptPersonalToken(token: string): Promise<ZeropsSession> {
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;
Expand Down
8 changes: 4 additions & 4 deletions packages/client-runtime/src/zerops/handover.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ describe("buildZeropsAuthorizeUrl", () => {
describe("readZeropsHandover", () => {
const session = (over: Record<string, string> = {}) =>
new URLSearchParams({
refreshToken: "rt-abc",
token: "rt-abc",
state: "nonce-1",
clientId: "org-1",
...over,
Expand All @@ -55,7 +55,7 @@ describe("readZeropsHandover", () => {

expect(outcome).toEqual({
kind: "session",
refreshToken: "rt-abc",
token: "rt-abc",
clientId: "org-1",
zcpClaimed: false,
});
Expand All @@ -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 });
Expand All @@ -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: "" })}`,
Expand Down
27 changes: 17 additions & 10 deletions packages/client-runtime/src/zerops/handover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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" };
}

Expand All @@ -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",
};
Expand Down
Loading