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
153 changes: 147 additions & 6 deletions cli/shared/deployment/deploy-project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -722,7 +853,7 @@ describe("environment URL readiness", () => {
},
{
url: "https://my-project.production.veryfront.com/",
cookie: "authToken=test-token",
cookie: `authToken=${sessionToken}`,
},
]);
});
Expand All @@ -749,7 +880,7 @@ describe("environment URL readiness", () => {

assertEquals(requests, [{
url: "https://my-project.production.veryfront.org/",
cookie: "authToken=test-token",
cookie: `authToken=${sessionToken}`,
}]);
});

Expand Down Expand Up @@ -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}`,
},
]);
});
Expand Down Expand Up @@ -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 })),
Expand Down
93 changes: 79 additions & 14 deletions cli/shared/deployment/deploy-project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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 [
Expand All @@ -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,
}];
}

Expand All @@ -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;
}
Expand Down Expand Up @@ -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 },
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
await wait(Math.min(pollIntervalMs, remainingMs));
}
Expand Down