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
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,9 @@ components, named exports from `src/index.ts`) and
`FirstRunWizard` (`src/components/first-run/`). `src/lib/bootstrap.ts` keeps the session secret
in module memory only and sends it as the `Fleetos-Bootstrap-Session` header; never put it in
web storage, a cookie or a URL. Mock mode keeps first run closed; set
`VITE_BOOTSTRAP_MOCK_STATE=open` to try the wizard offline.
`VITE_BOOTSTRAP_MOCK_STATE=open` to try the wizard offline. With it open, finishing or skipping
the admin step calls `closeMockBootstrap()` and the install step's button signs straight in as
the mock admin, since mock mode has no browser certificate for a reload to pick up.
- Operator CA registration is a preview then a confirm with `confirm_sha256`.
`OperatorCARegisterForm` takes the submit call as a prop, so the admin page and first run share
it; `registerRequestFields()` builds the body both RegisterOperatorCA RPCs take.
Expand Down
45 changes: 39 additions & 6 deletions apps/console/src/components/first-run/first-run-wizard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ import { fireEvent, render, screen, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { FirstRunWizard } from "@/components/first-run/first-run-wizard";
import { __resetBootstrap } from "@/lib/bootstrap";
import { AuthProvider, useAuth } from "@/context/auth";
import { __resetBootstrap, isMockFirstRunOpen } from "@/lib/bootstrap";
import { base64 } from "@/lib/crypto/leaf-key";
import { colonFingerprint } from "@/lib/fingerprint";
import * as modeMod from "@/lib/fleet/mode";
Expand Down Expand Up @@ -91,6 +92,14 @@ const signLeaf = async (ca: Awaited<ReturnType<typeof makeCA>>, publicKey: Crypt
const fill = (label: RegExp, value: string, root: HTMLElement = document.body) =>
fireEvent.change(within(root).getByLabelText(label), { target: { value } });

// Reads the auth status next to the wizard, the way the real console gates on
// it, so a test can prove the install step actually signs the operator in
// rather than only checking which button it renders.
const AuthStatusProbe = () => {
const { status } = useAuth();
return <span data-testid="auth-status">{status}</span>;
};

const captureKeys = () => {
const generated: CryptoKeyPair[] = [];
const real = crypto.subtle.generateKey.bind(crypto.subtle);
Expand Down Expand Up @@ -218,10 +227,15 @@ describe("FirstRunWizard (mock mode)", () => {
await expectNoA11yViolations(container);
}, 30_000);

it("path A: token, CA with fingerprint confirm, key backup before the CSR, upload, PKCS#12, install", async () => {
it("path A: token, CA with fingerprint confirm, key backup before the CSR, upload, PKCS#12, install, then continue signs in", async () => {
const generated = captureKeys();
const ca = await makeCA();
render(<FirstRunWizard inProgress={false} tokenExpiresAt="" />);
render(
<AuthProvider>
<AuthStatusProbe />
<FirstRunWizard inProgress={false} tokenExpiresAt="" />
</AuthProvider>,
);
await startSession();
await registerCA(ca);
const passphrase = await pathA(ca, generated);
Expand All @@ -234,12 +248,23 @@ describe("FirstRunWizard (mock mode)", () => {
expect(args.friendlyName).toBe(`FleetOS admin (${EMAIL})`);
expect(downloadBytes.mock.calls[0][0]).toBe(`${EMAIL}.p12`);
expect(screen.getByText(/Current User/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /reload/i })).toBeInTheDocument();

// Finishing path A already closed mock first run (#198): no reload is
// needed, and continuing signs straight in as the mock admin.
expect(isMockFirstRunOpen()).toBe(false);
expect(screen.getByTestId("auth-status")).toHaveTextContent("anonymous");
fireEvent.click(screen.getByRole("button", { name: /continue to the console/i }));
expect(screen.getByTestId("auth-status")).toHaveTextContent("authenticated");
}, 60_000);

it("path B: the recipe, an optional pre-flight, then install", async () => {
it("path B: the recipe, an optional pre-flight, then skipping to install closes mock first run too", async () => {
const ca = await makeCA();
render(<FirstRunWizard inProgress={false} tokenExpiresAt="" />);
render(
<AuthProvider>
<AuthStatusProbe />
<FirstRunWizard inProgress={false} tokenExpiresAt="" />
</AuthProvider>,
);
await startSession();
await registerCA(ca);
fireEvent.click(screen.getByLabelText(/path b/i));
Expand All @@ -250,6 +275,10 @@ describe("FirstRunWizard (mock mode)", () => {
).toContain("op_admin");
fireEvent.click(screen.getByRole("button", { name: /skip to install/i }));
await screen.findByRole("heading", { name: /install your admin certificate/i });

expect(isMockFirstRunOpen()).toBe(false);
fireEvent.click(screen.getByRole("button", { name: /continue to the console/i }));
expect(screen.getByTestId("auth-status")).toHaveTextContent("authenticated");
}, 60_000);
});

Expand Down Expand Up @@ -307,5 +336,9 @@ describe("FirstRunWizard (live mode)", () => {
].join("\n");
expect(stored).not.toContain("fos_bsess_live");
expect(stored).not.toContain(passphrase);

// Live mode keeps needing a real reload for the browser to present the
// newly installed certificate (#198 is mock-only).
expect(screen.getByRole("button", { name: /reload and sign in/i })).toBeInTheDocument();
}, 60_000);
});
54 changes: 34 additions & 20 deletions apps/console/src/components/first-run/first-run-wizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ import { AdminStep } from "@/components/first-run/admin-step";
import { CaStep } from "@/components/first-run/ca-step";
import { TokenStep } from "@/components/first-run/token-step";
import { CertificateHelp } from "@/components/layout/certificate-help";
import { endBootstrapSession } from "@/lib/bootstrap";
import { useOptionalAuth } from "@/context/auth";
import { closeMockBootstrap, endBootstrapSession } from "@/lib/bootstrap";
import { fleetMode } from "@/lib/fleet/mode";
import { levelExtfileSection } from "@/lib/operators";

type Step = "admin" | "ca" | "install" | "token";
Expand All @@ -33,24 +35,33 @@ const STEPS: { id: Step; label: string }[] = [
{ id: "install", label: "Install" },
];

const InstallStep = () => (
<div className="space-y-4">
<h2 className="text-lg font-bold">Install your admin certificate</h2>
<p className="text-sm text-muted-foreground">
Import the PKCS#12 with its passphrase (the key backup&apos;s), then reload this page and
choose the FleetOS certificate when the browser asks. If it doesn&apos;t ask, quit the browser
fully and start it again: Chrome remembers a &quot;no certificate&quot; answer per site. Your
first sign-in as admin closes first run.
</p>
<CertificateHelp />
<p className="text-xs text-muted-foreground">
Keep the key backup somewhere safe, or delete it once the PKCS#12 is imported.
</p>
<Button onClick={() => globalThis.location.reload()} size="sm">
Reload and sign in
</Button>
</div>
);
// In mock mode there is no browser certificate for a reload to pick up, so
// finishing or skipping the admin step (FirstRunWizard's onDone) already
// closed mock first run; the button here signs straight in instead of
// reloading, which would only reset the mock state back to open.
const InstallStep = () => {
const auth = useOptionalAuth();
const mock = fleetMode() === "mock";

return (
<div className="space-y-4">
<h2 className="text-lg font-bold">Install your admin certificate</h2>
<p className="text-sm text-muted-foreground">
Import the PKCS#12 with its passphrase (the key backup&apos;s), then reload this page and
choose the FleetOS certificate when the browser asks. If it doesn&apos;t ask, quit the
browser fully and start it again: Chrome remembers a &quot;no certificate&quot; answer per
site. Your first sign-in as admin closes first run.
</p>
<CertificateHelp />
<p className="text-xs text-muted-foreground">
Keep the key backup somewhere safe, or delete it once the PKCS#12 is imported.
</p>
<Button onClick={() => (mock ? auth?.login() : globalThis.location.reload())} size="sm">
{mock ? "Continue to the console" : "Reload and sign in"}
</Button>
</div>
);
};

// FirstRunWizard stands a Fleet Manager up from nothing: the bootstrap token,
// then the external operator CA (with the fingerprint confirm), then the
Expand Down Expand Up @@ -115,7 +126,10 @@ export const FirstRunWizard = ({
<AdminStep
adminExtfile={adminExtfile}
anchorDer={anchorDer}
onDone={() => setStep("install")}
onDone={() => {
if (fleetMode() === "mock") closeMockBootstrap();
setStep("install");
}}
/>
</>
) : null}
Expand Down
42 changes: 42 additions & 0 deletions apps/console/src/context/auth.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@ limitations under the License.
*/

import { Code, ConnectError } from "@connectrpc/connect";
import { BootstrapState } from "@cryptos-pki/api-client/cryptos/fleet/v1/bootstrap_pb";
import { render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";

import { AuthProvider, useAuth } from "@/context/auth";
import { __resetBootstrap, __setMockBootstrapState, closeMockBootstrap } from "@/lib/bootstrap";

const whoAmI = vi.fn();
const fleetMode = vi.fn(() => "live");
Expand Down Expand Up @@ -57,6 +59,7 @@ describe("AuthProvider", () => {
probe.mockReset();
fleetMode.mockReturnValue("live");
vi.stubGlobal("fetch", probe);
__resetBootstrap();
});

// The point of #68: arriving at the page must not sign you in, even holding a
Expand Down Expand Up @@ -231,4 +234,43 @@ describe("AuthProvider", () => {
await waitFor(() => expect(state()).toBe("authenticated:admin:none"));
expect(whoAmI).not.toHaveBeenCalled();
});

// #198: with mock first run forced open, login used to always act like a
// browser holding no certificate, even after the wizard finished, so a
// reviewer could never get past it.
it("acts like a missing certificate while mock first run is open", async () => {
fleetMode.mockReturnValue("mock");
__setMockBootstrapState(BootstrapState.OPEN);
renderProbe();

screen.getByRole("button", { name: /sign in/i }).click();

await waitFor(() => expect(state()).toBe("denied:none:no-certificate"));
});

it("signs in as the mock admin once the wizard closes mock first run", async () => {
fleetMode.mockReturnValue("mock");
__setMockBootstrapState(BootstrapState.OPEN);
renderProbe();

screen.getByRole("button", { name: /sign in/i }).click();
await waitFor(() => expect(state()).toBe("denied:none:no-certificate"));

closeMockBootstrap();
screen.getByRole("button", { name: /sign in/i }).click();

await waitFor(() => expect(state()).toBe("authenticated:admin:none"));
});

// Live mode never reads the mock bootstrap state: closing it has no effect
// here, and a real certificate is still required.
it("ignores mock first-run state in live mode", async () => {
closeMockBootstrap();
whoAmI.mockRejectedValue(new ConnectError("no cert", Code.Unauthenticated));
renderProbe();

screen.getByRole("button", { name: /sign in/i }).click();

await waitFor(() => expect(state()).toBe("denied:none:certificate-not-sent"));
});
});
9 changes: 6 additions & 3 deletions apps/console/src/context/auth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ limitations under the License.
import { Code, ConnectError } from "@connectrpc/connect";
import { createContext, type ReactNode, useCallback, useContext, useState } from "react";

import { isMockFirstRunOpen } from "@/lib/bootstrap";
import { fleetClient } from "@/lib/fleet/client";
import { fleetMode } from "@/lib/fleet/mode";

Expand Down Expand Up @@ -162,9 +163,11 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
setReason(null);

if (fleetMode() === "mock") {
// With mock first run open, act like a browser holding no certificate,
// so the first-run wizard can be tried offline.
if (import.meta.env.VITE_BOOTSTRAP_MOCK_STATE === "open") {
// With mock first run still open, act like a browser holding no
// certificate, so the first-run wizard can be tried offline. Once the
// wizard closes it (closeMockBootstrap, on finishing or skipping the
// admin step), sign in below like any other mock login.
if (isMockFirstRunOpen()) {
setOperator(null);
setReason("no-certificate");
setStatus("denied");
Expand Down
16 changes: 16 additions & 0 deletions apps/console/src/lib/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,22 @@ export const getBootstrapState = async (): Promise<BootstrapStatus> => {

export const hasBootstrapSession = (): boolean => sessionSecret !== null;

// isMockFirstRunOpen tells mock-mode sign-in whether first run is still open,
// so it knows to act like a browser holding no certificate. Reading the live
// mockState rather than the env var means it reflects closeMockBootstrap()
// once the wizard reaches the end.
export const isMockFirstRunOpen = (): boolean =>
mockState === BootstrapState.OPEN || mockState === BootstrapState.OPEN_IN_PROGRESS;

// closeMockBootstrap closes mock first run the way a live admin's first
// authenticated sign-in closes the real thing. Mock mode has no browser
// certificate to present on a reload, so the wizard calls this directly once
// the operator finishes or skips the admin step, instead of relying on a
// reload that would only reset the mock state back to open.
export const closeMockBootstrap = (): void => {
mockState = BootstrapState.CLOSED;
};

export const endBootstrapSession = (): void => {
sessionSecret = null;
};
Expand Down
Loading