diff --git a/cli/shared/deployment/deploy-project.test.ts b/cli/shared/deployment/deploy-project.test.ts index 8f88657f84..b6386b3e03 100644 --- a/cli/shared/deployment/deploy-project.test.ts +++ b/cli/shared/deployment/deploy-project.test.ts @@ -582,12 +582,16 @@ describe("pushed source provenance", () => { }); describe("environment URL readiness", () => { + // Must be JWT-shaped, or the authenticated path stops being exercised. + const sessionToken = "eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiJ1XzEifQ.test-signature"; + const apiKeyToken = "vf_d157f0000000000000000000000000000000000"; + const hostedTarget = { projectSlug: "my-project", environmentName: "production", url: "https://my-project.production.veryfront.com", protected: false, - apiToken: "test-token", + apiToken: sessionToken, }; it("retries a transient 404 before accepting the environment URL", async () => { @@ -662,7 +666,134 @@ describe("environment URL readiness", () => { }), ); - assertEquals(cookie, "authToken=test-token"); + assertEquals(cookie, `authToken=${sessionToken}`); + }); + + it("does not send an API key to the protected environment gate", async () => { + const requests: Array<{ url: string; cookie: string | null }> = []; + + await withMockFetch( + (input: string | URL | Request, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init); + requests.push({ url: request.url, cookie: request.headers.get("cookie") }); + return Promise.resolve( + new Response(null, { + status: 302, + headers: { location: "https://veryfront.com/sign-in" }, + }), + ); + }, + () => + waitForEnvironmentReady({ + ...hostedTarget, + protected: true, + apiToken: apiKeyToken, + }), + ); + + assertEquals(requests, [{ + url: "https://my-project.production.veryfront.com/", + cookie: null, + }]); + }); + + it("does not send an opaque credential that merely contains dots", async () => { + const requests: Array<{ url: string; cookie: string | null }> = []; + + await withMockFetch( + (input: string | URL | Request, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init); + requests.push({ url: request.url, cookie: request.headers.get("cookie") }); + return Promise.resolve( + new Response(null, { + status: 302, + headers: { location: "https://veryfront.com/sign-in" }, + }), + ); + }, + () => + waitForEnvironmentReady({ + ...hostedTarget, + protected: true, + apiToken: "opaque.segment.value", + }), + ); + + assertEquals(requests, [{ + url: "https://my-project.production.veryfront.com/", + cookie: null, + }]); + }); + + it("does not send a JWT-shaped credential whose payload carries no userId", async () => { + const requests: Array<{ url: string; cookie: string | null }> = []; + // {"alg":"HS256"} . {"sub":"u_1"} . sig — decodes, but carries no userId. + const withoutUserId = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1XzEifQ.test-signature"; + + await withMockFetch( + (input: string | URL | Request, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init); + requests.push({ url: request.url, cookie: request.headers.get("cookie") }); + return Promise.resolve( + new Response(null, { + status: 302, + headers: { location: "https://veryfront.com/sign-in" }, + }), + ); + }, + () => + waitForEnvironmentReady({ + ...hostedTarget, + protected: true, + apiToken: withoutUserId, + }), + ); + + assertEquals(requests, [{ + url: "https://my-project.production.veryfront.com/", + cookie: null, + }]); + }); + + it("treats a sign-in redirect as ready when the credential is an API key", async () => { + await withMockFetch( + () => + Promise.resolve( + new Response(null, { + status: 302, + headers: { location: "https://veryfront.com/sign-in" }, + }), + ), + () => + waitForEnvironmentReady({ + ...hostedTarget, + protected: true, + apiToken: apiKeyToken, + }), + ); + }); + + it("classifies a rejected session credential as a deployment error", async () => { + const error = await assertRejects(() => + withMockFetch( + () => + Promise.resolve( + new Response(null, { + status: 302, + headers: { location: "https://veryfront.com/sign-in" }, + }), + ), + () => + waitForEnvironmentReady({ + ...hostedTarget, + protected: true, + }), + ) + ); + + // A bare Error here surfaces to the operator as `unknown-error`. + assertStrictEquals(error instanceof VeryfrontError, true); + assertEquals((error as VeryfrontError).slug, DEPLOYMENT_ERROR.slug); }); it("upgrades authenticated Veryfront environment probes to HTTPS", async () => { @@ -685,7 +816,7 @@ describe("environment URL readiness", () => { ); assertEquals(requestedUrl, "https://my-project.production.veryfront.com/"); - assertEquals(cookie, "authToken=test-token"); + assertEquals(cookie, `authToken=${sessionToken}`); }); it("does not send credentials to a mismatched Veryfront project host", async () => { @@ -722,7 +853,7 @@ describe("environment URL readiness", () => { }, { url: "https://my-project.production.veryfront.com/", - cookie: "authToken=test-token", + cookie: `authToken=${sessionToken}`, }, ]); }); @@ -749,7 +880,7 @@ describe("environment URL readiness", () => { assertEquals(requests, [{ url: "https://my-project.production.veryfront.org/", - cookie: "authToken=test-token", + cookie: `authToken=${sessionToken}`, }]); }); @@ -784,7 +915,7 @@ describe("environment URL readiness", () => { { url: "https://app.example.com/", cookie: null }, { url: "https://my-project.production.veryfront.com/", - cookie: "authToken=test-token", + cookie: `authToken=${sessionToken}`, }, ]); }); @@ -858,6 +989,16 @@ describe("environment URL readiness", () => { ); }); + it("names the status when a challenge was not a sign-in redirect", async () => { + const message = await withMockFetch( + () => Promise.resolve(new Response(null, { status: 403 })), + () => + expectErrorMessage(() => waitForEnvironmentReady({ ...hostedTarget, protected: false })), + ); + + assertMatch(message ?? "", /returned HTTP 403/); + }); + it("reports the URL and last status when readiness times out", async () => { const error = await withMockFetch( () => Promise.resolve(new Response("not ready", { status: 404 })), diff --git a/cli/shared/deployment/deploy-project.ts b/cli/shared/deployment/deploy-project.ts index a7d0a634b0..3604e8cc87 100644 --- a/cli/shared/deployment/deploy-project.ts +++ b/cli/shared/deployment/deploy-project.ts @@ -829,12 +829,58 @@ function isMatchingVeryfrontHostedUrl( parsed.environment === target.environmentName.toLowerCase(); } +const MAX_JWT_SEGMENT_CODE_UNITS = 8 * 1024; + +/** Decode one base64url JWT segment into a plain object, or null if it is not one. */ +function decodeJwtSegment(segment: string | undefined): object | null { + if (segment === undefined) return null; + if (segment.length === 0 || segment.length > MAX_JWT_SEGMENT_CODE_UNITS) return null; + try { + const base64 = segment.replaceAll("-", "+").replaceAll("_", "/"); + const binary = atob(base64 + "=".repeat((4 - base64.length % 4) % 4)); + const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0)); + const parsed = JSON.parse(new TextDecoder().decode(bytes)); + return typeof parsed === "object" && parsed !== null ? parsed : null; + } catch { + return null; + } +} + +/** + * Whether a stored credential could satisfy the protected-environment gate. + * + * The gate reads `userId` from the verified JWT payload and has no API-key + * branch, so presenting an opaque key only leaks it. Reads what the gate + * reads, and withholds anything it cannot recognise. + */ +function isSessionCredential(apiToken: string): boolean { + const segments = apiToken.split("."); + if (segments.length !== 3) return false; + + const header = decodeJwtSegment(segments[0]); + if (header === null || typeof readUntrustedOwnDataProperty(header, "alg") !== "string") { + return false; + } + + const payload = decodeJwtSegment(segments[1]); + if (payload === null) return false; + const userId = readUntrustedOwnDataProperty(payload, "userId"); + return typeof userId === "string" && userId.length > 0; +} + function buildEnvironmentReadinessProbes( target: EnvironmentReadinessTarget, ): EnvironmentReadinessProbe[] { const route = target.route === undefined ? "/" : target.route; if (route === null) return []; + // Without a session credential the probe cannot get past the gate. Its + // challenge still proves routing resolves and the proxy is serving this + // environment — the most this step can establish, and the deployment it + // would otherwise fail is already committed and verified. Same allowance the + // protected custom-domain probe below already makes. + const canAuthenticate = isSessionCredential(target.apiToken); + const targetUrl = buildEnvironmentProbeUrl(target.url, route); if (target.protected && !isMatchingVeryfrontHostedUrl(new URL(targetUrl), target)) { return [ @@ -848,15 +894,15 @@ function buildEnvironmentReadinessProbes( buildCanonicalEnvironmentUrl(target.projectSlug, target.environmentName), route, ), - authenticate: true, - acceptAuthenticationChallenge: false, + authenticate: canAuthenticate, + acceptAuthenticationChallenge: !canAuthenticate, }, ]; } return [{ url: target.protected ? secureEnvironmentProbeUrl(targetUrl) : targetUrl, - authenticate: target.protected, - acceptAuthenticationChallenge: false, + authenticate: target.protected && canAuthenticate, + acceptAuthenticationChallenge: target.protected && !canAuthenticate, }]; } @@ -878,6 +924,21 @@ function isSignInRedirect(response: Response, requestUrl: string): boolean { } } +/** A challenge is a sign-in redirect, a 401, or a 403; say which one arrived. */ +function describeAuthenticationChallenge( + probe: EnvironmentReadinessProbe, + status: number, + signInRedirect: boolean, +): string { + if (probe.authenticate) { + return `Could not authenticate the protected environment URL ${probe.url}. Run veryfront login and deploy again.`; + } + if (signInRedirect) { + return `Environment URL ${probe.url} redirected to sign-in. Check its protection settings and deploy again.`; + } + return `Environment URL ${probe.url} returned HTTP ${status}. Check its protection settings and deploy again.`; +} + function isTransientEnvironmentStatus(status: number): boolean { return status === 404 || status === 408 || status === 425 || status === 429 || status >= 500; } @@ -935,25 +996,29 @@ export async function waitForEnvironmentReady( if (ready) break; if (authenticationChallenge) { - const message = probe.authenticate - ? `Could not authenticate the protected environment URL ${probe.url}. Run veryfront login and deploy again.` - : `Environment URL ${probe.url} redirected to sign-in. Check its protection settings and deploy again.`; - throw new Error(message); + // A bare Error here reaches the operator as `unknown-error`. + throw DEPLOYMENT_ERROR.create({ + detail: describeAuthenticationChallenge(probe, response.status, signInRedirect), + context: { url: probe.url, status: response.status }, + }); } if (!isTransientEnvironmentStatus(response.status)) { - throw new Error( - `Environment URL ${probe.url} returned HTTP ${response.status}. Check the environment configuration and deploy again.`, - ); + throw DEPLOYMENT_ERROR.create({ + detail: + `Environment URL ${probe.url} returned HTTP ${response.status}. Check the environment configuration and deploy again.`, + context: { url: probe.url, status: response.status }, + }); } } const remainingMs = deadline - Date.now(); if (remainingMs <= 0) { - throw new Error( - `Environment URL ${probe.url} did not become ready within ${ + throw DEPLOYMENT_ERROR.create({ + detail: `Environment URL ${probe.url} did not become ready within ${ Math.ceil(timeoutMs / 1000) }s (last response: ${lastResponse}). Check the deployment and run deploy again.`, - ); + context: { url: probe.url, timeoutMs }, + }); } await wait(Math.min(pollIntervalMs, remainingMs)); }