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
102 changes: 76 additions & 26 deletions apps/web/src/authBootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,18 @@ function installTestBrowser(url: string) {
return testWindow;
}

function installDesktopBootstrap() {
const testWindow = installTestBrowser("http://localhost/");
testWindow.desktopBridge = {
getLocalEnvironmentBootstrap: () => ({
label: "Local environment",
httpBaseUrl: "http://localhost:3773",
wsBaseUrl: "ws://localhost:3773",
bootstrapToken: "desktop-bootstrap-token",
}),
} as DesktopBridge;
}

function sequence<A>(...values: ReadonlyArray<A>) {
let index = 0;
return () => values[Math.min(index++, values.length - 1)]!;
Expand Down Expand Up @@ -131,15 +143,7 @@ describe("resolveInitialServerAuthGateState", () => {
browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])),
});

const testWindow = installTestBrowser("http://localhost/");
testWindow.desktopBridge = {
getLocalEnvironmentBootstrap: () => ({
label: "Local environment",
httpBaseUrl: "http://localhost:3773",
wsBaseUrl: "ws://localhost:3773",
bootstrapToken: "desktop-bootstrap-token",
}),
} as DesktopBridge;
installDesktopBootstrap();

const { resolveInitialServerAuthGateState } = await import("./environments/primary");

Expand Down Expand Up @@ -289,6 +293,23 @@ describe("resolveInitialServerAuthGateState", () => {
expect(testApi.calls.session).toBe(2);
});

it("rejects a blank pairing token with a structured validation error", async () => {
const { PrimaryEnvironmentPairingCredentialRequiredError, submitServerAuthCredential } =
await import("./environments/primary/auth");

const error = await submitServerAuthCredential(" ").then(
() => null,
(failure: unknown) => failure,
);

expect(error).toBeInstanceOf(PrimaryEnvironmentPairingCredentialRequiredError);
expect(error).toMatchObject({
_tag: "PrimaryEnvironmentPairingCredentialRequiredError",
providedLength: 3,
message: "Enter a pairing token to continue.",
});
});

it("surfaces a friendly error message when an invalid pairing token is submitted", async () => {
const cause = new EnvironmentAuthInvalidError({
code: "auth_invalid",
Expand All @@ -299,22 +320,21 @@ describe("resolveInitialServerAuthGateState", () => {
browserSession: () => Effect.fail(cause),
});

const { isPrimaryEnvironmentRequestError, submitServerAuthCredential } =
const { isPrimaryEnvironmentPairingCredentialRejectedError, submitServerAuthCredential } =
await import("./environments/primary");

const error = await submitServerAuthCredential("bad-token").then(
() => null,
(failure: unknown) => failure,
);
expect(error).toMatchObject({
_tag: "PrimaryEnvironmentRequestError",
operation: "exchange-bootstrap-credential",
status: 401,
detail: "Invalid pairing token. Check the token and try again.",
_tag: "PrimaryEnvironmentPairingCredentialRejectedError",
providedLength: 9,
message: "Invalid pairing token. Check the token and try again.",
});
expect(isPrimaryEnvironmentRequestError(error)).toBe(true);
if (!isPrimaryEnvironmentRequestError(error)) {
throw new Error("Expected a structured primary environment request error.");
expect(isPrimaryEnvironmentPairingCredentialRejectedError(error)).toBe(true);
if (!isPrimaryEnvironmentPairingCredentialRejectedError(error)) {
throw new Error("Expected a structured rejected pairing credential error.");
}
expect(error.cause).toMatchObject({
_tag: "EnvironmentAuthInvalidError",
Expand All @@ -325,6 +345,22 @@ describe("resolveInitialServerAuthGateState", () => {
expect(testApi.calls.browserSession).toEqual([{ credential: "bad-token" }]);
});

it("derives primary request messages from structural request context", async () => {
const cause = new Error("private transport detail");
const { PrimaryEnvironmentRequestError } = await import("./environments/primary");
const error = PrimaryEnvironmentRequestError.fromCause({
operation: "list-pairing-links",
cause,
});

expect(error.status).toBe(500);
expect(error.cause).toBe(cause);
expect(error.message).toBe(
"Primary environment request failed during list-pairing-links (HTTP 500).",
);
expect(error.message).not.toContain(cause.message);
});

it("waits for the authenticated session to become observable after silent desktop bootstrap", async () => {
vi.useFakeTimers();
const nextSession = sequence(
Expand All @@ -337,15 +373,7 @@ describe("resolveInitialServerAuthGateState", () => {
browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])),
});

const testWindow = installTestBrowser("http://localhost/");
testWindow.desktopBridge = {
getLocalEnvironmentBootstrap: () => ({
label: "Local environment",
httpBaseUrl: "http://localhost:3773",
wsBaseUrl: "ws://localhost:3773",
bootstrapToken: "desktop-bootstrap-token",
}),
} as DesktopBridge;
installDesktopBootstrap();

const { resolveInitialServerAuthGateState } = await import("./environments/primary");

Expand All @@ -356,6 +384,28 @@ describe("resolveInitialServerAuthGateState", () => {
expect(testApi.calls.session).toBe(3);
});

it("preserves the timeout message when a bootstrapped session never becomes observable", async () => {
vi.useFakeTimers();
const testApi = await installAuthApi({
session: () => unauthenticatedSession(DESKTOP_AUTH),
browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])),
});

installDesktopBootstrap();

const { resolveInitialServerAuthGateState } = await import("./environments/primary");

const gateStatePromise = resolveInitialServerAuthGateState();
await vi.advanceTimersByTimeAsync(2_000);

await expect(gateStatePromise).resolves.toEqual({
status: "requires-auth",
auth: DESKTOP_AUTH,
errorMessage: "Timed out waiting for authenticated session after bootstrap.",
});
expect(testApi.calls.browserSession).toEqual([{ credential: "desktop-bootstrap-token" }]);
});

it("memoizes the authenticated gate state after the first successful read", async () => {
const testApi = await installAuthApi({
session: sequence(authenticatedSession(LOOPBACK_AUTH), unauthenticatedSession(LOOPBACK_AUTH)),
Expand Down
122 changes: 68 additions & 54 deletions apps/web/src/environments/primary/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ export class PrimaryEnvironmentRequestError extends Schema.TaggedErrorClass<Prim
{
operation: PrimaryEnvironmentRequestOperation,
status: Schema.Number,
detail: Schema.String,
pairingLinkId: Schema.optional(Schema.String),
sessionId: Schema.optional(Schema.String),
cause: Schema.Defect(),
Expand All @@ -49,29 +48,73 @@ export class PrimaryEnvironmentRequestError extends Schema.TaggedErrorClass<Prim
static fromCause(input: {
readonly operation: PrimaryEnvironmentRequestOperation;
readonly cause: unknown;
readonly fallbackMessage: (status: number) => string;
readonly formatDetail?: (detail: string, status: number) => string;
readonly pairingLinkId?: string;
readonly sessionId?: string;
}): PrimaryEnvironmentRequestError {
const status = readHttpApiStatus(input.cause) ?? 500;
const rawDetail = readHttpApiErrorMessage(input.cause, input.fallbackMessage(status));
return new PrimaryEnvironmentRequestError({
operation: input.operation,
status,
detail: input.formatDetail?.(rawDetail, status) ?? rawDetail,
...(input.pairingLinkId !== undefined ? { pairingLinkId: input.pairingLinkId } : {}),
...(input.sessionId !== undefined ? { sessionId: input.sessionId } : {}),
cause: input.cause,
});
}

override get message(): string {
return this.detail;
return `Primary environment request failed during ${this.operation} (HTTP ${this.status}).`;
}
}

export const isPrimaryEnvironmentRequestError = Schema.is(PrimaryEnvironmentRequestError);

export class PrimaryEnvironmentPairingCredentialRejectedError extends Schema.TaggedErrorClass<PrimaryEnvironmentPairingCredentialRejectedError>()(
"PrimaryEnvironmentPairingCredentialRejectedError",
{
providedLength: Schema.Number,
cause: Schema.Defect(),
},
) {
override get message(): string {
return "Invalid pairing token. Check the token and try again.";
}
}

export const isPrimaryEnvironmentPairingCredentialRejectedError = Schema.is(
PrimaryEnvironmentPairingCredentialRejectedError,
);

export class PrimaryEnvironmentAuthSessionTimeoutError extends Schema.TaggedErrorClass<PrimaryEnvironmentAuthSessionTimeoutError>()(
"PrimaryEnvironmentAuthSessionTimeoutError",
{
timeoutMs: Schema.Number,
elapsedMs: Schema.Number,
},
) {
override get message(): string {
return "Timed out waiting for authenticated session after bootstrap.";
}
}

export const isPrimaryEnvironmentAuthSessionTimeoutError = Schema.is(
PrimaryEnvironmentAuthSessionTimeoutError,
);

export class PrimaryEnvironmentPairingCredentialRequiredError extends Schema.TaggedErrorClass<PrimaryEnvironmentPairingCredentialRequiredError>()(
"PrimaryEnvironmentPairingCredentialRequiredError",
{
providedLength: Schema.Number,
},
) {
override get message(): string {
return "Enter a pairing token to continue.";
}
}

export const isPrimaryEnvironmentPairingCredentialRequiredError = Schema.is(
PrimaryEnvironmentPairingCredentialRequiredError,
);

const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError);

export interface ServerPairingLinkRecord {
Expand Down Expand Up @@ -151,7 +194,6 @@ export async function fetchSessionState(): Promise<AuthSessionState> {
throw PrimaryEnvironmentRequestError.fromCause({
operation: "fetch-session-state",
cause: error,
fallbackMessage: (status) => `Failed to load server auth session state (${status}).`,
});
}
});
Expand Down Expand Up @@ -180,42 +222,6 @@ function readEnvironmentHttpErrorStatus(error: EnvironmentHttpCommonErrorType):
}
}

function readHttpApiErrorMessage(error: unknown, fallbackMessage: string): string {
if (!isEnvironmentHttpCommonError(error)) {
return fallbackMessage;
}
switch (error._tag) {
case "EnvironmentAuthInvalidError":
return error.reason === "missing_credential"
? "Authentication required."
: "Invalid bootstrap credential.";
case "EnvironmentRequestInvalidError":
return error.reason === "invalid_scope"
? "Requested token scope is invalid."
: "Requested scope exceeds the bootstrap credential grant.";
case "EnvironmentScopeRequiredError":
return `The authenticated token is missing required scope: ${error.requiredScope}.`;
case "EnvironmentOperationForbiddenError":
return "This operation is not allowed for the current session.";
case "EnvironmentInternalError":
return fallbackMessage;
}
}

const INVALID_BOOTSTRAP_CREDENTIAL_MESSAGES = new Set([
"Invalid bootstrap credential.",
"Unknown bootstrap credential.",
]);

function toFriendlyBootstrapErrorMessage(status: number, message: string): string {
const trimmedMessage = message.trim();
if (status === 401 && INVALID_BOOTSTRAP_CREDENTIAL_MESSAGES.has(trimmedMessage)) {
return "Invalid pairing token. Check the token and try again.";
}

return trimmedMessage;
}

async function exchangeBootstrapCredential(credential: string): Promise<AuthBrowserSessionResult> {
return retryTransientBootstrap(async () => {
try {
Expand All @@ -225,11 +231,19 @@ async function exchangeBootstrapCredential(credential: string): Promise<AuthBrow
),
);
} catch (error) {
if (
isEnvironmentHttpCommonError(error) &&
error._tag === "EnvironmentAuthInvalidError" &&
error.reason === "invalid_credential"
) {
throw new PrimaryEnvironmentPairingCredentialRejectedError({
providedLength: credential.length,
cause: error,
});
}
throw PrimaryEnvironmentRequestError.fromCause({
operation: "exchange-bootstrap-credential",
cause: error,
fallbackMessage: (status) => `Failed to bootstrap auth session (${status}).`,
formatDetail: (detail, status) => toFriendlyBootstrapErrorMessage(status, detail),
});
}
});
Expand All @@ -244,8 +258,12 @@ async function waitForAuthenticatedSessionAfterBootstrap(): Promise<AuthSessionS
return session;
}

if (Date.now() - startedAt >= AUTH_SESSION_ESTABLISH_TIMEOUT_MS) {
throw new Error("Timed out waiting for authenticated session after bootstrap.");
const elapsedMs = Date.now() - startedAt;
if (elapsedMs >= AUTH_SESSION_ESTABLISH_TIMEOUT_MS) {
throw new PrimaryEnvironmentAuthSessionTimeoutError({
timeoutMs: AUTH_SESSION_ESTABLISH_TIMEOUT_MS,
elapsedMs,
});
}

await waitForBootstrapRetry(AUTH_SESSION_ESTABLISH_STEP_MS);
Expand Down Expand Up @@ -323,7 +341,9 @@ async function bootstrapServerAuth(): Promise<ServerAuthGateState> {
export async function submitServerAuthCredential(credential: string): Promise<void> {
const trimmedCredential = credential.trim();
if (!trimmedCredential) {
throw new Error("Enter a pairing token to continue.");
throw new PrimaryEnvironmentPairingCredentialRequiredError({
providedLength: credential.length,
});
}

resolvedAuthenticatedGateState = null;
Expand Down Expand Up @@ -355,7 +375,6 @@ export async function createServerPairingCredential(input?: {
throw PrimaryEnvironmentRequestError.fromCause({
operation: "create-pairing-credential",
cause: error,
fallbackMessage: (status) => `Failed to create pairing credential (${status}).`,
});
}
}
Expand Down Expand Up @@ -396,7 +415,6 @@ export async function listServerPairingLinks(): Promise<ReadonlyArray<ServerPair
throw PrimaryEnvironmentRequestError.fromCause({
operation: "list-pairing-links",
cause: error,
fallbackMessage: (status) => `Failed to load pairing links (${status}).`,
});
}
}
Expand All @@ -413,7 +431,6 @@ export async function revokeServerPairingLink(id: string): Promise<void> {
operation: "revoke-pairing-link",
pairingLinkId: id,
cause: error,
fallbackMessage: (status) => `Failed to revoke pairing link (${status}).`,
});
}
}
Expand Down Expand Up @@ -446,7 +463,6 @@ export async function listServerClientSessions(): Promise<
throw PrimaryEnvironmentRequestError.fromCause({
operation: "list-client-sessions",
cause: error,
fallbackMessage: (status) => `Failed to load paired clients (${status}).`,
});
}
}
Expand All @@ -465,7 +481,6 @@ export async function revokeServerClientSession(sessionId: AuthSessionId): Promi
operation: "revoke-client-session",
sessionId,
cause: error,
fallbackMessage: (status) => `Failed to revoke client session (${status}).`,
});
}
}
Expand All @@ -482,7 +497,6 @@ export async function revokeOtherServerClientSessions(): Promise<number> {
throw PrimaryEnvironmentRequestError.fromCause({
operation: "revoke-other-client-sessions",
cause: error,
fallbackMessage: (status) => `Failed to revoke other client sessions (${status}).`,
});
}
}
Expand Down
Loading
Loading