authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 20:20:12-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 20:20:12-07:00
log2a29de7502cdd02686bda7d12fb2671060fd1979
tree407720a2cc764f8010f61b9dffdb8ead453e847c
parent8c1605b38327176bae94c33bbc6de86e8b09fe02
signature Signed by PGP key B5690EEEBB952194

[codex] Structure primary auth validation failures (#3471)

Co-authored-by: codex <codex@users.noreply.github.com>

4 files changed, 146 insertions(+), 81 deletions(-)

apps/web/src/authBootstrap.test.ts+76-26
......@@ -71,6 +71,18 @@ function installTestBrowser(url: string) {
7171 return testWindow;
7272}
7373
74function installDesktopBootstrap() {
75 const testWindow = installTestBrowser("http://localhost/");
76 testWindow.desktopBridge = {
77 getLocalEnvironmentBootstrap: () => ({
78 label: "Local environment",
79 httpBaseUrl: "http://localhost:3773",
80 wsBaseUrl: "ws://localhost:3773",
81 bootstrapToken: "desktop-bootstrap-token",
82 }),
83 } as DesktopBridge;
84}
85
7486function sequence<A>(...values: ReadonlyArray<A>) {
7587 let index = 0;
7688 return () => values[Math.min(index++, values.length - 1)]!;
......@@ -131,15 +143,7 @@ describe("resolveInitialServerAuthGateState", () => {
131143 browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])),
132144 });
133145
134 const testWindow = installTestBrowser("http://localhost/");
135 testWindow.desktopBridge = {
136 getLocalEnvironmentBootstrap: () => ({
137 label: "Local environment",
138 httpBaseUrl: "http://localhost:3773",
139 wsBaseUrl: "ws://localhost:3773",
140 bootstrapToken: "desktop-bootstrap-token",
141 }),
142 } as DesktopBridge;
146 installDesktopBootstrap();
143147
144148 const { resolveInitialServerAuthGateState } = await import("./environments/primary");
145149
......@@ -289,6 +293,23 @@ describe("resolveInitialServerAuthGateState", () => {
289293 expect(testApi.calls.session).toBe(2);
290294 });
291295
296 it("rejects a blank pairing token with a structured validation error", async () => {
297 const { PrimaryEnvironmentPairingCredentialRequiredError, submitServerAuthCredential } =
298 await import("./environments/primary/auth");
299
300 const error = await submitServerAuthCredential(" ").then(
301 () => null,
302 (failure: unknown) => failure,
303 );
304
305 expect(error).toBeInstanceOf(PrimaryEnvironmentPairingCredentialRequiredError);
306 expect(error).toMatchObject({
307 _tag: "PrimaryEnvironmentPairingCredentialRequiredError",
308 providedLength: 3,
309 message: "Enter a pairing token to continue.",
310 });
311 });
312
292313 it("surfaces a friendly error message when an invalid pairing token is submitted", async () => {
293314 const cause = new EnvironmentAuthInvalidError({
294315 code: "auth_invalid",
......@@ -299,7 +320,7 @@ describe("resolveInitialServerAuthGateState", () => {
299320 browserSession: () => Effect.fail(cause),
300321 });
301322
302 const { isPrimaryEnvironmentRequestError, submitServerAuthCredential } =
323 const { isPrimaryEnvironmentPairingCredentialRejectedError, submitServerAuthCredential } =
303324 await import("./environments/primary");
304325
305326 const error = await submitServerAuthCredential("bad-token").then(
......@@ -307,14 +328,13 @@ describe("resolveInitialServerAuthGateState", () => {
307328 (failure: unknown) => failure,
308329 );
309330 expect(error).toMatchObject({
310 _tag: "PrimaryEnvironmentRequestError",
311 operation: "exchange-bootstrap-credential",
312 status: 401,
313 detail: "Invalid pairing token. Check the token and try again.",
331 _tag: "PrimaryEnvironmentPairingCredentialRejectedError",
332 providedLength: 9,
333 message: "Invalid pairing token. Check the token and try again.",
314334 });
315 expect(isPrimaryEnvironmentRequestError(error)).toBe(true);
316 if (!isPrimaryEnvironmentRequestError(error)) {
317 throw new Error("Expected a structured primary environment request error.");
335 expect(isPrimaryEnvironmentPairingCredentialRejectedError(error)).toBe(true);
336 if (!isPrimaryEnvironmentPairingCredentialRejectedError(error)) {
337 throw new Error("Expected a structured rejected pairing credential error.");
318338 }
319339 expect(error.cause).toMatchObject({
320340 _tag: "EnvironmentAuthInvalidError",
......@@ -325,6 +345,22 @@ describe("resolveInitialServerAuthGateState", () => {
325345 expect(testApi.calls.browserSession).toEqual([{ credential: "bad-token" }]);
326346 });
327347
348 it("derives primary request messages from structural request context", async () => {
349 const cause = new Error("private transport detail");
350 const { PrimaryEnvironmentRequestError } = await import("./environments/primary");
351 const error = PrimaryEnvironmentRequestError.fromCause({
352 operation: "list-pairing-links",
353 cause,
354 });
355
356 expect(error.status).toBe(500);
357 expect(error.cause).toBe(cause);
358 expect(error.message).toBe(
359 "Primary environment request failed during list-pairing-links (HTTP 500).",
360 );
361 expect(error.message).not.toContain(cause.message);
362 });
363
328364 it("waits for the authenticated session to become observable after silent desktop bootstrap", async () => {
329365 vi.useFakeTimers();
330366 const nextSession = sequence(
......@@ -337,15 +373,7 @@ describe("resolveInitialServerAuthGateState", () => {
337373 browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])),
338374 });
339375
340 const testWindow = installTestBrowser("http://localhost/");
341 testWindow.desktopBridge = {
342 getLocalEnvironmentBootstrap: () => ({
343 label: "Local environment",
344 httpBaseUrl: "http://localhost:3773",
345 wsBaseUrl: "ws://localhost:3773",
346 bootstrapToken: "desktop-bootstrap-token",
347 }),
348 } as DesktopBridge;
376 installDesktopBootstrap();
349377
350378 const { resolveInitialServerAuthGateState } = await import("./environments/primary");
351379
......@@ -356,6 +384,28 @@ describe("resolveInitialServerAuthGateState", () => {
356384 expect(testApi.calls.session).toBe(3);
357385 });
358386
387 it("preserves the timeout message when a bootstrapped session never becomes observable", async () => {
388 vi.useFakeTimers();
389 const testApi = await installAuthApi({
390 session: () => unauthenticatedSession(DESKTOP_AUTH),
391 browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])),
392 });
393
394 installDesktopBootstrap();
395
396 const { resolveInitialServerAuthGateState } = await import("./environments/primary");
397
398 const gateStatePromise = resolveInitialServerAuthGateState();
399 await vi.advanceTimersByTimeAsync(2_000);
400
401 await expect(gateStatePromise).resolves.toEqual({
402 status: "requires-auth",
403 auth: DESKTOP_AUTH,
404 errorMessage: "Timed out waiting for authenticated session after bootstrap.",
405 });
406 expect(testApi.calls.browserSession).toEqual([{ credential: "desktop-bootstrap-token" }]);
407 });
408
359409 it("memoizes the authenticated gate state after the first successful read", async () => {
360410 const testApi = await installAuthApi({
361411 session: sequence(authenticatedSession(LOOPBACK_AUTH), unauthenticatedSession(LOOPBACK_AUTH)),
apps/web/src/environments/primary/auth.ts+68-54
......@@ -40,7 +40,6 @@ export class PrimaryEnvironmentRequestError extends Schema.TaggedErrorClass<Prim
4040 {
4141 operation: PrimaryEnvironmentRequestOperation,
4242 status: Schema.Number,
43 detail: Schema.String,
4443 pairingLinkId: Schema.optional(Schema.String),
4544 sessionId: Schema.optional(Schema.String),
4645 cause: Schema.Defect(),
......@@ -49,17 +48,13 @@ export class PrimaryEnvironmentRequestError extends Schema.TaggedErrorClass<Prim
4948 static fromCause(input: {
5049 readonly operation: PrimaryEnvironmentRequestOperation;
5150 readonly cause: unknown;
52 readonly fallbackMessage: (status: number) => string;
53 readonly formatDetail?: (detail: string, status: number) => string;
5451 readonly pairingLinkId?: string;
5552 readonly sessionId?: string;
5653 }): PrimaryEnvironmentRequestError {
5754 const status = readHttpApiStatus(input.cause) ?? 500;
58 const rawDetail = readHttpApiErrorMessage(input.cause, input.fallbackMessage(status));
5955 return new PrimaryEnvironmentRequestError({
6056 operation: input.operation,
6157 status,
62 detail: input.formatDetail?.(rawDetail, status) ?? rawDetail,
6358 ...(input.pairingLinkId !== undefined ? { pairingLinkId: input.pairingLinkId } : {}),
6459 ...(input.sessionId !== undefined ? { sessionId: input.sessionId } : {}),
6560 cause: input.cause,
......@@ -67,11 +62,59 @@ export class PrimaryEnvironmentRequestError extends Schema.TaggedErrorClass<Prim
6762 }
6863
6964 override get message(): string {
70 return this.detail;
65 return `Primary environment request failed during ${this.operation} (HTTP ${this.status}).`;
7166 }
7267}
7368
7469export const isPrimaryEnvironmentRequestError = Schema.is(PrimaryEnvironmentRequestError);
70
71export class PrimaryEnvironmentPairingCredentialRejectedError extends Schema.TaggedErrorClass<PrimaryEnvironmentPairingCredentialRejectedError>()(
72 "PrimaryEnvironmentPairingCredentialRejectedError",
73 {
74 providedLength: Schema.Number,
75 cause: Schema.Defect(),
76 },
77) {
78 override get message(): string {
79 return "Invalid pairing token. Check the token and try again.";
80 }
81}
82
83export const isPrimaryEnvironmentPairingCredentialRejectedError = Schema.is(
84 PrimaryEnvironmentPairingCredentialRejectedError,
85);
86
87export class PrimaryEnvironmentAuthSessionTimeoutError extends Schema.TaggedErrorClass<PrimaryEnvironmentAuthSessionTimeoutError>()(
88 "PrimaryEnvironmentAuthSessionTimeoutError",
89 {
90 timeoutMs: Schema.Number,
91 elapsedMs: Schema.Number,
92 },
93) {
94 override get message(): string {
95 return "Timed out waiting for authenticated session after bootstrap.";
96 }
97}
98
99export const isPrimaryEnvironmentAuthSessionTimeoutError = Schema.is(
100 PrimaryEnvironmentAuthSessionTimeoutError,
101);
102
103export class PrimaryEnvironmentPairingCredentialRequiredError extends Schema.TaggedErrorClass<PrimaryEnvironmentPairingCredentialRequiredError>()(
104 "PrimaryEnvironmentPairingCredentialRequiredError",
105 {
106 providedLength: Schema.Number,
107 },
108) {
109 override get message(): string {
110 return "Enter a pairing token to continue.";
111 }
112}
113
114export const isPrimaryEnvironmentPairingCredentialRequiredError = Schema.is(
115 PrimaryEnvironmentPairingCredentialRequiredError,
116);
117
75118const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError);
76119
77120export interface ServerPairingLinkRecord {
......@@ -151,7 +194,6 @@ export async function fetchSessionState(): Promise<AuthSessionState> {
151194 throw PrimaryEnvironmentRequestError.fromCause({
152195 operation: "fetch-session-state",
153196 cause: error,
154 fallbackMessage: (status) => `Failed to load server auth session state (${status}).`,
155197 });
156198 }
157199 });
......@@ -180,42 +222,6 @@ function readEnvironmentHttpErrorStatus(error: EnvironmentHttpCommonErrorType):
180222 }
181223}
182224
183function readHttpApiErrorMessage(error: unknown, fallbackMessage: string): string {
184 if (!isEnvironmentHttpCommonError(error)) {
185 return fallbackMessage;
186 }
187 switch (error._tag) {
188 case "EnvironmentAuthInvalidError":
189 return error.reason === "missing_credential"
190 ? "Authentication required."
191 : "Invalid bootstrap credential.";
192 case "EnvironmentRequestInvalidError":
193 return error.reason === "invalid_scope"
194 ? "Requested token scope is invalid."
195 : "Requested scope exceeds the bootstrap credential grant.";
196 case "EnvironmentScopeRequiredError":
197 return `The authenticated token is missing required scope: ${error.requiredScope}.`;
198 case "EnvironmentOperationForbiddenError":
199 return "This operation is not allowed for the current session.";
200 case "EnvironmentInternalError":
201 return fallbackMessage;
202 }
203}
204
205const INVALID_BOOTSTRAP_CREDENTIAL_MESSAGES = new Set([
206 "Invalid bootstrap credential.",
207 "Unknown bootstrap credential.",
208]);
209
210function toFriendlyBootstrapErrorMessage(status: number, message: string): string {
211 const trimmedMessage = message.trim();
212 if (status === 401 && INVALID_BOOTSTRAP_CREDENTIAL_MESSAGES.has(trimmedMessage)) {
213 return "Invalid pairing token. Check the token and try again.";
214 }
215
216 return trimmedMessage;
217}
218
219225async function exchangeBootstrapCredential(credential: string): Promise<AuthBrowserSessionResult> {
220226 return retryTransientBootstrap(async () => {
221227 try {
......@@ -225,11 +231,19 @@ async function exchangeBootstrapCredential(credential: string): Promise<AuthBrow
225231 ),
226232 );
227233 } catch (error) {
234 if (
235 isEnvironmentHttpCommonError(error) &&
236 error._tag === "EnvironmentAuthInvalidError" &&
237 error.reason === "invalid_credential"
238 ) {
239 throw new PrimaryEnvironmentPairingCredentialRejectedError({
240 providedLength: credential.length,
241 cause: error,
242 });
243 }
228244 throw PrimaryEnvironmentRequestError.fromCause({
229245 operation: "exchange-bootstrap-credential",
230246 cause: error,
231 fallbackMessage: (status) => `Failed to bootstrap auth session (${status}).`,
232 formatDetail: (detail, status) => toFriendlyBootstrapErrorMessage(status, detail),
233247 });
234248 }
235249 });
......@@ -244,8 +258,12 @@ async function waitForAuthenticatedSessionAfterBootstrap(): Promise<AuthSessionS
244258 return session;
245259 }
246260
247 if (Date.now() - startedAt >= AUTH_SESSION_ESTABLISH_TIMEOUT_MS) {
248 throw new Error("Timed out waiting for authenticated session after bootstrap.");
261 const elapsedMs = Date.now() - startedAt;
262 if (elapsedMs >= AUTH_SESSION_ESTABLISH_TIMEOUT_MS) {
263 throw new PrimaryEnvironmentAuthSessionTimeoutError({
264 timeoutMs: AUTH_SESSION_ESTABLISH_TIMEOUT_MS,
265 elapsedMs,
266 });
249267 }
250268
251269 await waitForBootstrapRetry(AUTH_SESSION_ESTABLISH_STEP_MS);
......@@ -323,7 +341,9 @@ async function bootstrapServerAuth(): Promise<ServerAuthGateState> {
323341export async function submitServerAuthCredential(credential: string): Promise<void> {
324342 const trimmedCredential = credential.trim();
325343 if (!trimmedCredential) {
326 throw new Error("Enter a pairing token to continue.");
344 throw new PrimaryEnvironmentPairingCredentialRequiredError({
345 providedLength: credential.length,
346 });
327347 }
328348
329349 resolvedAuthenticatedGateState = null;
......@@ -355,7 +375,6 @@ export async function createServerPairingCredential(input?: {
355375 throw PrimaryEnvironmentRequestError.fromCause({
356376 operation: "create-pairing-credential",
357377 cause: error,
358 fallbackMessage: (status) => `Failed to create pairing credential (${status}).`,
359378 });
360379 }
361380}
......@@ -396,7 +415,6 @@ export async function listServerPairingLinks(): Promise<ReadonlyArray<ServerPair
396415 throw PrimaryEnvironmentRequestError.fromCause({
397416 operation: "list-pairing-links",
398417 cause: error,
399 fallbackMessage: (status) => `Failed to load pairing links (${status}).`,
400418 });
401419 }
402420}
......@@ -413,7 +431,6 @@ export async function revokeServerPairingLink(id: string): Promise<void> {
413431 operation: "revoke-pairing-link",
414432 pairingLinkId: id,
415433 cause: error,
416 fallbackMessage: (status) => `Failed to revoke pairing link (${status}).`,
417434 });
418435 }
419436}
......@@ -446,7 +463,6 @@ export async function listServerClientSessions(): Promise<
446463 throw PrimaryEnvironmentRequestError.fromCause({
447464 operation: "list-client-sessions",
448465 cause: error,
449 fallbackMessage: (status) => `Failed to load paired clients (${status}).`,
450466 });
451467 }
452468}
......@@ -465,7 +481,6 @@ export async function revokeServerClientSession(sessionId: AuthSessionId): Promi
465481 operation: "revoke-client-session",
466482 sessionId,
467483 cause: error,
468 fallbackMessage: (status) => `Failed to revoke client session (${status}).`,
469484 });
470485 }
471486}
......@@ -482,7 +497,6 @@ export async function revokeOtherServerClientSessions(): Promise<number> {
482497 throw PrimaryEnvironmentRequestError.fromCause({
483498 operation: "revoke-other-client-sessions",
484499 cause: error,
485 fallbackMessage: (status) => `Failed to revoke other client sessions (${status}).`,
486500 });
487501 }
488502}
apps/web/src/environments/primary/context.ts-1
......@@ -46,7 +46,6 @@ async function fetchPrimaryEnvironmentDescriptor(): Promise<ExecutionEnvironment
4646 throw PrimaryEnvironmentRequestError.fromCause({
4747 operation: "fetch-environment-descriptor",
4848 cause: error,
49 fallbackMessage: (status) => `Failed to load server environment descriptor (${status}).`,
5049 });
5150 }
5251
apps/web/src/environments/primary/index.ts+2
......@@ -16,10 +16,12 @@ export {
1616export {
1717 createServerPairingCredential,
1818 fetchSessionState,
19 isPrimaryEnvironmentPairingCredentialRejectedError,
1920 isPrimaryEnvironmentRequestError,
2021 listServerClientSessions,
2122 listServerPairingLinks,
2223 peekPairingTokenFromUrl,
24 PrimaryEnvironmentPairingCredentialRejectedError,
2325 PrimaryEnvironmentRequestError,
2426 resolveInitialServerAuthGateState,
2527 revokeOtherServerClientSessions,