From f135fec0180c88bf4a74538c20ae30e6f59c4c34 Mon Sep 17 00:00:00 2001 From: "wizzoapp[bot]" <254688279+wizzoapp[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:05:36 +0100 Subject: [PATCH 1/6] fix(auth): honor pairing links for signed-in browsers and allow self sign-out A signed-in browser that opened a pairing link was redirected home without redeeming the credential, so minted links stayed unused. /pair now applies the credential, replaces that browser's session with the grant's scopes, and revokes the previous session after a successful redeem. Replacement is fail-closed: if the existing session cannot be read or revoked, or the replacement cookie cannot be constructed, the exchange fails, the original session stays usable, and no replacement cookie is installed. A session without access:write also had no way to sign itself out. Sign-out is now self-service on this device; pairing-link minting and other-session revoke stay behind access:write. --- apps/server/src/auth/EnvironmentAuth.test.ts | 26 +++ apps/server/src/auth/http.ts | 76 ++++++++- apps/server/src/server.test.ts | 159 +++++++++++++++++- apps/web/src/authBootstrap.test.ts | 69 +++++++- .../components/auth/PairingRouteSurface.tsx | 106 ++++++++++++ .../ConnectionsSettings.logic.test.ts | 16 ++ .../settings/ConnectionsSettings.logic.ts | 14 ++ .../settings/ConnectionsSettings.tsx | 86 +++++++++- .../settings/settingsSearch.test.ts | 8 + .../src/components/settings/settingsSearch.ts | 5 + apps/web/src/environments/primary/auth.ts | 48 ++++++ apps/web/src/environments/primary/index.ts | 3 + apps/web/src/routes/pair.logic.test.ts | 74 ++++++++ apps/web/src/routes/pair.logic.ts | 43 +++++ apps/web/src/routes/pair.tsx | 38 ++++- apps/web/test/environmentHttpTest.ts | 19 ++- docs/internals/environment-auth.md | 13 +- docs/user/remote-access.md | 4 + packages/contracts/src/environmentHttp.ts | 8 + 19 files changed, 793 insertions(+), 22 deletions(-) create mode 100644 apps/web/src/routes/pair.logic.test.ts create mode 100644 apps/web/src/routes/pair.logic.ts diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index dbea5980db1a..b10bed6d0a79 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -137,6 +137,32 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { }).pipe(Effect.provide(makeEnvironmentAuthLayer())), ); + it.effect("revokes a standard-scope browser session without requiring access:write", () => + Effect.gen(function* () { + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const sessions = yield* SessionStore.SessionStore; + const pairingCredential = yield* serverAuth.issuePairingCredential({ + audienceCeiling: "private", + }); + const exchanged = yield* serverAuth.createBrowserSession( + pairingCredential.credential, + requestMetadata, + ); + const verified = yield* serverAuth.authenticateHttpRequest( + makeCookieRequest(sessions.cookieName, exchanged.sessionToken), + ); + + expect(verified.scopes.includes("access:write")).toBe(false); + const revoked = yield* serverAuth.revokeSession(verified.sessionId); + const error = yield* serverAuth + .authenticateHttpRequest(makeCookieRequest(sessions.cookieName, exchanged.sessionToken)) + .pipe(Effect.flip); + + expect(revoked).toBe(true); + expect(error._tag).toBe("ServerAuthInvalidCredentialError"); + }).pipe(Effect.provide(makeEnvironmentAuthLayer())), + ); + it.effect("accepts the legacy hosted cookie during production loopback upgrades", () => Effect.gen(function* () { const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index 4306351c3320..09e2790ef96e 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -74,9 +74,8 @@ const appendDpopChallengeOnUnauthorized = (error: EnvironmentAuthInvalidError) = return yield* error; }); -function browserSessionCookieOptions(input: { +function browserSessionCookieAttributeOptions(input: { readonly request: HttpServerRequest.HttpServerRequest; - readonly expiresAt: DateTime.Utc; readonly hostedOrigins: ReadonlySet; }) { const hostedOrigin = @@ -86,7 +85,6 @@ function browserSessionCookieOptions(input: { trustedOrigins: input.hostedOrigins, }); return { - expires: DateTime.toDate(input.expiresAt), httpOnly: true, path: "/", sameSite: hostedOrigin ? ("none" as const) : ("lax" as const), @@ -94,6 +92,34 @@ function browserSessionCookieOptions(input: { }; } +function browserSessionCookieOptions(input: { + readonly request: HttpServerRequest.HttpServerRequest; + readonly expiresAt: DateTime.Utc; + readonly hostedOrigins: ReadonlySet; +}) { + return { + ...browserSessionCookieAttributeOptions(input), + expires: DateTime.toDate(input.expiresAt), + }; +} + +function expireBrowserSessionCookies(input: { + readonly request: HttpServerRequest.HttpServerRequest; + readonly cookieNames: ReadonlyArray; + readonly hostedOrigins: ReadonlySet; +}) { + return Effect.gen(function* () { + const options = browserSessionCookieAttributeOptions(input); + let cookies = Cookies.empty; + for (const cookieName of input.cookieNames) { + cookies = yield* Effect.fromResult(Cookies.expireCookie(cookies, cookieName, options)).pipe( + Effect.catch(() => failEnvironmentInternal("client_session_revoke_failed")), + ); + } + return cookies; + }); +} + export function configuredCookieAuthCsrfOrigins(config: ServerConfig.ServerConfig["Service"]) { return configuredBrowserCookieCredentialOrigins(config); } @@ -293,12 +319,47 @@ export const authHttpApiLayer = HttpApiBuilder.group( ), ), ) + .handle( + "signOut", + Effect.fn("environment.auth.signOut")( + function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + const session = yield* EnvironmentAuthenticatedPrincipal; + const revoked = yield* serverAuth.revokeSession(session.sessionId); + const request = yield* HttpServerRequest.HttpServerRequest; + const expiredCookies = yield* expireBrowserSessionCookies({ + request, + cookieNames: sessions.cookieNames, + hostedOrigins, + }); + yield* HttpEffect.appendPreResponseHandler((_request, response) => + Effect.succeed(HttpServerResponse.mergeCookies(response, expiredCookies)), + ); + yield* appendCredentialResponseHeaders; + return { revoked }; + }, + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentInternal("client_session_revoke_failed", error), + ), + ), + ) .handle( "browserSession", Effect.fn("environment.auth.browserSession")( function* (args) { yield* annotateEnvironmentRequest(args.endpoint.name); const request = yield* HttpServerRequest.HttpServerRequest; + // Replacement is a security boundary. Fail closed unless the + // previous session is confirmed gone: do not install a cookie when + // the existing session cannot be read or revoked. + const previousSession = yield* serverAuth.authenticateHttpRequest(request).pipe( + Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, () => + Effect.succeed(null), + ), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentInternal("browser_session_replacement_failed", error), + ), + ); const result = yield* serverAuth.createBrowserSession( args.payload.credential, deriveAuthClientMetadata({ request }), @@ -315,6 +376,15 @@ export const authHttpApiLayer = HttpApiBuilder.group( }), ), ).pipe(Effect.catch(() => failEnvironmentInternal("browser_session_cookie_failed"))); + if (previousSession !== null) { + yield* serverAuth + .revokeSession(previousSession.sessionId) + .pipe( + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentInternal("browser_session_replacement_failed", error), + ), + ); + } yield* HttpEffect.appendPreResponseHandler((_request, response) => Effect.succeed(HttpServerResponse.mergeCookies(response, sessionCookies)), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 133bfa3c0671..51a2e66013fa 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -317,11 +317,26 @@ const browserOtlpTracingLayer = Layer.mergeAll( Layer.succeed(HttpClient.TracerDisabledWhen, () => true), ); -const makeAuthTestLayer = () => - EnvironmentAuth.layer.pipe( +const makeAuthTestLayer = ( + wrap?: ( + inner: EnvironmentAuth.EnvironmentAuth["Service"], + ) => EnvironmentAuth.EnvironmentAuth["Service"], +) => { + const base = EnvironmentAuth.layer.pipe( Layer.provide(SqlitePersistenceMemory), Layer.provide(ServerSecretStore.layer), ); + if (wrap === undefined) { + return base; + } + return Layer.effect( + EnvironmentAuth.EnvironmentAuth, + Effect.gen(function* () { + const inner = yield* EnvironmentAuth.EnvironmentAuth; + return wrap(inner); + }), + ).pipe(Layer.provideMerge(base)); +}; const makeBrowserOtlpPayload = (spanName: string) => Effect.gen(function* () { @@ -424,6 +439,9 @@ const makeBrowserOtlpPayload = (spanName: string) => const buildAppUnderTest = (options?: { config?: Partial; + wrapEnvironmentAuth?: ( + inner: EnvironmentAuth.EnvironmentAuth["Service"], + ) => EnvironmentAuth.EnvironmentAuth["Service"]; layers?: { keybindings?: Partial; providerRegistry?: Partial; @@ -1138,7 +1156,7 @@ const buildAppUnderTest = (options?: { ...options?.layers?.cloudCliTokenManager, }), ), - Layer.provideMerge(makeAuthTestLayer()), + Layer.provideMerge(makeAuthTestLayer(options?.wrapEnvironmentAuth)), Layer.provideMerge(MatrixBridgeConfig.layer.pipe(Layer.provide(ServerSecretStore.layer))), Layer.provideMerge(ServerSecretStore.layer), Layer.provide(workspaceAndProjectServicesLayer), @@ -1803,6 +1821,141 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect( + "revokes the previous browser session after a pairing credential is redeemed on the same cookie", + () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const credentialResponse = yield* HttpClient.post("/api/auth/pairing-token", { + headers: { cookie: ownerCookie }, + body: yield* HttpBody.json({ audienceCeiling: "private" }), + }); + const credential = (yield* credentialResponse.json) as { readonly credential: string }; + const replacement = yield* bootstrapBrowserSession(credential.credential, { + headers: { cookie: ownerCookie }, + }); + const replacementCookie = replacement.cookie?.split(";")[0] ?? ""; + + const sessionUrl = yield* getHttpServerUrl("/api/auth/session"); + const displacedResponse = yield* fetchEffect(sessionUrl, { + headers: { cookie: ownerCookie }, + }); + const displacedBody = yield* responseJsonEffect<{ readonly authenticated: boolean }>( + displacedResponse, + ); + const nextResponse = yield* fetchEffect(sessionUrl, { + headers: { cookie: replacementCookie }, + }); + const nextBody = yield* responseJsonEffect<{ + readonly authenticated: boolean; + readonly scopes?: ReadonlyArray; + }>(nextResponse); + + assert.equal(credentialResponse.status, 200); + assert.equal(replacement.response.status, 200); + assert.equal(displacedBody.authenticated, false); + assert.equal(nextBody.authenticated, true); + assert.equal(nextBody.scopes?.includes("access:write"), false); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect( + "does not replace the browser session when reading the existing cookie session fails", + () => + Effect.gen(function* () { + let failSessionRead = false; + yield* buildAppUnderTest({ + wrapEnvironmentAuth: (inner) => ({ + ...inner, + authenticateHttpRequest: (request) => + failSessionRead + ? Effect.fail( + new EnvironmentAuth.ServerAuthSessionCredentialValidationError({ + cause: new Error("session lookup unavailable"), + }), + ) + : inner.authenticateHttpRequest(request), + }), + }); + + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const credentialResponse = yield* HttpClient.post("/api/auth/pairing-token", { + headers: { cookie: ownerCookie }, + body: yield* HttpBody.json({ audienceCeiling: "private" }), + }); + const credential = (yield* credentialResponse.json) as { readonly credential: string }; + failSessionRead = true; + const replacement = yield* bootstrapBrowserSession(credential.credential, { + headers: { cookie: ownerCookie }, + }); + failSessionRead = false; + const replacementBody = yield* responseJsonEffect<{ + readonly reason?: string; + }>(replacement.response); + + const sessionUrl = yield* getHttpServerUrl("/api/auth/session"); + const originalResponse = yield* fetchEffect(sessionUrl, { + headers: { cookie: ownerCookie }, + }); + const originalBody = yield* responseJsonEffect<{ readonly authenticated: boolean }>( + originalResponse, + ); + + assert.equal(credentialResponse.status, 200); + assert.equal(replacement.response.status, 500); + assert.equal(replacementBody.reason, "browser_session_replacement_failed"); + assert.isUndefined(replacement.cookie); + assert.equal(originalBody.authenticated, true); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect( + "does not replace the browser session when revoking the existing cookie session fails", + () => + Effect.gen(function* () { + yield* buildAppUnderTest({ + wrapEnvironmentAuth: (inner) => ({ + ...inner, + revokeSession: () => + Effect.fail( + new EnvironmentAuth.ServerAuthSessionRevocationError({ + cause: new Error("session revoke unavailable"), + }), + ), + }), + }); + + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const credentialResponse = yield* HttpClient.post("/api/auth/pairing-token", { + headers: { cookie: ownerCookie }, + body: yield* HttpBody.json({ audienceCeiling: "private" }), + }); + const credential = (yield* credentialResponse.json) as { readonly credential: string }; + const replacement = yield* bootstrapBrowserSession(credential.credential, { + headers: { cookie: ownerCookie }, + }); + const replacementBody = yield* responseJsonEffect<{ + readonly reason?: string; + }>(replacement.response); + + const sessionUrl = yield* getHttpServerUrl("/api/auth/session"); + const originalResponse = yield* fetchEffect(sessionUrl, { + headers: { cookie: ownerCookie }, + }); + const originalBody = yield* responseJsonEffect<{ readonly authenticated: boolean }>( + originalResponse, + ); + + assert.equal(credentialResponse.status, 200); + assert.equal(replacement.response.status, 500); + assert.equal(replacementBody.reason, "browser_session_replacement_failed"); + assert.isUndefined(replacement.cookie); + assert.equal(originalBody.authenticated, true); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("exchanges a bootstrap grant for a scoped bearer access token", () => Effect.gen(function* () { yield* buildAppUnderTest(); diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts index e241e7d70876..07e35f6314d4 100644 --- a/apps/web/src/authBootstrap.test.ts +++ b/apps/web/src/authBootstrap.test.ts @@ -1,6 +1,8 @@ import { EnvironmentAuthInvalidError, + EnvironmentInternalError, type AuthBrowserSessionResult, + type AuthClientSessionRevokeResult, type AuthCreatePairingCredentialInput, type AuthSessionState, type DesktopBridge, @@ -99,7 +101,10 @@ async function installAuthApi(input: { readonly session?: () => AuthSessionState; readonly browserSession?: ( credential: string, - ) => Effect.Effect; + ) => Effect.Effect< + AuthBrowserSessionResult, + EnvironmentAuthInvalidError | EnvironmentInternalError + >; readonly pairingCredential?: (payload: AuthCreatePairingCredentialInput) => Effect.Effect<{ readonly id: string; readonly credential: string; @@ -107,6 +112,7 @@ async function installAuthApi(input: { readonly label?: string; readonly expiresAt: DateTime.Utc; }>; + readonly signOut?: () => Effect.Effect; }) { const testApi = await installEnvironmentHttpTest({ ...(input.session ? { session: () => Effect.succeed(input.session!()) } : {}), @@ -116,6 +122,7 @@ async function installAuthApi(input: { ...(input.pairingCredential ? { pairingCredential: (payload) => input.pairingCredential!(payload) } : {}), + ...(input.signOut ? { signOut: input.signOut } : {}), }); disposeHttpTest = testApi.dispose; return testApi; @@ -365,6 +372,40 @@ describe("resolveInitialServerAuthGateState", () => { expect(testApi.calls.browserSession).toEqual([{ credential: "bad-token" }]); }); + it("surfaces a replacement failure without treating it as a consumed pairing token", async () => { + const cause = new EnvironmentInternalError({ + code: "internal_error", + reason: "browser_session_replacement_failed", + traceId: "trace-replacement-failed", + }); + const testApi = await installAuthApi({ + browserSession: () => Effect.fail(cause), + }); + + const { isPrimaryEnvironmentSessionReplacementError, submitServerAuthCredential } = + await import("./environments/primary"); + + const error = await submitServerAuthCredential("replace-token").then( + () => null, + (failure: unknown) => failure, + ); + expect(error).toMatchObject({ + _tag: "PrimaryEnvironmentSessionReplacementError", + message: "Could not replace the existing session, nothing changed.", + }); + expect(isPrimaryEnvironmentSessionReplacementError(error)).toBe(true); + if (!isPrimaryEnvironmentSessionReplacementError(error)) { + throw new Error("Expected a structured session replacement error."); + } + expect(error.cause).toMatchObject({ + _tag: "EnvironmentInternalError", + code: "internal_error", + reason: "browser_session_replacement_failed", + traceId: "trace-replacement-failed", + }); + expect(testApi.calls.browserSession).toEqual([{ credential: "replace-token" }]); + }); + it("derives primary request messages from structural request context", async () => { const cause = new Error("private transport detail"); const { PrimaryEnvironmentRequestError } = await import("./environments/primary"); @@ -484,4 +525,30 @@ describe("resolveInitialServerAuthGateState", () => { }, ]); }); + + it("redeems a pairing credential after the browser is already authenticated", async () => { + const testApi = await installAuthApi({ + session: () => authenticatedSession(LOOPBACK_AUTH), + browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])), + }); + const { resolveInitialServerAuthGateState, submitServerAuthCredential } = + await import("./environments/primary"); + + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "authenticated", + }); + await expect(submitServerAuthCredential("upgrade-token")).resolves.toBeUndefined(); + expect(testApi.calls.browserSession).toEqual([{ credential: "upgrade-token" }]); + }); + + it("lets a standard-scope session sign itself out without access:write", async () => { + const testApi = await installAuthApi({ + session: () => authenticatedSession(LOOPBACK_AUTH), + signOut: () => Effect.succeed({ revoked: true }), + }); + const { signOutCurrentServerSession } = await import("./environments/primary"); + + await expect(signOutCurrentServerSession()).resolves.toBeUndefined(); + expect(testApi.calls.signOut).toBe(1); + }); }); diff --git a/apps/web/src/components/auth/PairingRouteSurface.tsx b/apps/web/src/components/auth/PairingRouteSurface.tsx index 8287495dcd9b..6ab94b5cf6de 100644 --- a/apps/web/src/components/auth/PairingRouteSurface.tsx +++ b/apps/web/src/components/auth/PairingRouteSurface.tsx @@ -182,6 +182,112 @@ export function PairingRouteSurface({ ); } +export function AuthenticatedPairingApplySurface({ + onAuthenticated, + onContinueWithoutApplying, +}: { + onAuthenticated: () => void; + onContinueWithoutApplying: () => void; +}) { + const autoPairTokenRef = useRef(peekPairingTokenFromUrl()); + const [errorMessage, setErrorMessage] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const autoSubmitAttemptedRef = useRef(false); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const primaryEnvironmentIdRef = useRef(primaryEnvironmentId); + primaryEnvironmentIdRef.current = primaryEnvironmentId; + const retryPrimaryEnvironment = useAtomCommand(environmentCatalog.retryNow, { + reportFailure: false, + }); + + const submitCredential = useCallback( + async (nextCredential: string) => { + setIsSubmitting(true); + setErrorMessage(""); + + const submitError = await submitPairingCredentialAndUnblock( + { + submitServerAuthCredential, + retryPrimaryEnvironment, + getPrimaryEnvironmentId: () => primaryEnvironmentIdRef.current, + errorMessageFromUnknown, + }, + nextCredential, + ); + + setIsSubmitting(false); + + if (submitError) { + setErrorMessage(submitError); + return; + } + + startTransition(() => { + onAuthenticated(); + }); + }, + [onAuthenticated, retryPrimaryEnvironment], + ); + + useEffect(() => { + if (autoSubmitAttemptedRef.current) { + return; + } + autoSubmitAttemptedRef.current = true; + + const token = autoPairTokenRef.current; + if (!token) { + setErrorMessage("This pairing link is missing its token."); + return; + } + + stripPairingTokenFromUrl(); + void submitCredential(token); + }, [submitCredential]); + + return ( +
+
+
+
+
+
+ +
+

+ {APP_DISPLAY_NAME} +

+

+ {errorMessage ? "Pairing link was not applied" : "Applying pairing link"} +

+

+ {errorMessage + ? "This browser kept its current session. Request a new pairing link if you still need to change permissions." + : "This one-time link replaces the session on this browser with the permissions it grants."} +

+ + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + +
+ {isSubmitting && !errorMessage ? ( + + ) : ( + + )} +
+
+
+ ); +} + export function HostedPairingRouteSurface() { const connectPairingEnvironment = useAtomCommand(connectPairing, { reportFailure: false, diff --git a/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts b/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts index 88d2cbb4bcd3..900e3ddc3f87 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts +++ b/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts @@ -16,6 +16,7 @@ import { parseRemotePairingHostChange, parseRemotePairingFields, showMatrixBridgeDisconnect, + clientSessionRowAction, } from "./ConnectionsSettings.logic"; const baseWslState: DesktopWslState = { @@ -166,6 +167,21 @@ describe("remote pairing field parsing", () => { }); }); +describe("clientSessionRowAction", () => { + it("lets a session without access:write sign itself out", () => { + expect(clientSessionRowAction({ isCurrent: true, canManageAccess: false })).toBe("sign-out"); + }); + + it("lets an administrative session sign itself out", () => { + expect(clientSessionRowAction({ isCurrent: true, canManageAccess: true })).toBe("sign-out"); + }); + + it("scope-gates revoking other sessions behind access:write", () => { + expect(clientSessionRowAction({ isCurrent: false, canManageAccess: true })).toBe("revoke"); + expect(clientSessionRowAction({ isCurrent: false, canManageAccess: false })).toBeNull(); + }); +}); + describe("matrixBridgeSectionAccess", () => { it("hides the subsection on servers without the bridge capability", () => { expect(matrixBridgeSectionAccess({ supported: false, canManageAccess: true })).toBe("hidden"); diff --git a/apps/web/src/components/settings/ConnectionsSettings.logic.ts b/apps/web/src/components/settings/ConnectionsSettings.logic.ts index 5351de5b7aad..e509928aa35f 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.logic.ts +++ b/apps/web/src/components/settings/ConnectionsSettings.logic.ts @@ -15,6 +15,20 @@ export function isQrShareableEndpoint(endpoint: AdvertisedEndpoint): boolean { return endpoint.status !== "unavailable" && endpoint.reachability !== "loopback"; } +/** + * Current-device sign-out is self-service. Administrative revoke of other + * clients stays behind access:write. + */ +export function clientSessionRowAction(input: { + readonly isCurrent: boolean; + readonly canManageAccess: boolean; +}): "sign-out" | "revoke" | null { + if (input.isCurrent) { + return "sign-out"; + } + return input.canManageAccess ? "revoke" : null; +} + export type QrEndpointOption = { readonly id: string; readonly preferenceKey: string; diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 29c8683e582f..cfeb135c2fbe 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -65,6 +65,7 @@ import { parseRemotePairingHostChange, parseRemotePairingFields, selectQrEndpointOption, + clientSessionRowAction, type MatrixBridgePairingCode, } from "./ConnectionsSettings.logic"; import { @@ -115,6 +116,7 @@ import { revokeOtherServerClientSessions, revokeServerClientSession, revokeServerPairingLink, + signOutCurrentServerSession, isLoopbackHostname, usePrimarySessionState, type ServerClientSessionRecord, @@ -862,18 +864,44 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ ); }); +function CurrentDeviceSignOutRow({ + isSigningOut, + onSignOut, +}: { + readonly isSigningOut: boolean; + readonly onSignOut: () => void; +}) { + return ( + + {isSigningOut ? "Signing out…" : "Sign out"} + + } + /> + ); +} + type ConnectedClientListRowProps = { clientSession: ServerClientSessionRecord; presentation?: AccessSectionPresentation; + canManageAccess: boolean; revokingClientSessionId: string | null; + isSigningOut: boolean; onRevokeSession: (sessionId: ServerClientSessionRecord["sessionId"]) => void; + onSignOutCurrent: () => void; }; const ConnectedClientListRow = memo(function ConnectedClientListRow({ clientSession, presentation = "current", + canManageAccess, revokingClientSessionId, + isSigningOut, onRevokeSession, + onSignOutCurrent, }: ConnectedClientListRowProps) { const nowMs = useRelativeTimeTick(1_000); const isLive = clientSession.current || clientSession.connected; @@ -897,6 +925,10 @@ const ConnectedClientListRow = memo(function ConnectedClientListRow({ clientSession.client.label ?? ([clientSession.client.os, clientSession.client.browser].filter(Boolean).join(" · ") || clientSession.subject); + const rowAction = clientSessionRowAction({ + isCurrent: clientSession.current, + canManageAccess, + }); return (
@@ -926,7 +958,16 @@ const ConnectedClientListRow = memo(function ConnectedClientListRow({

- {!clientSession.current ? ( + {rowAction === "sign-out" ? ( + + ) : rowAction === "revoke" ? ( - ) : ( - - )} + ) : null} +
diff --git a/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts b/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts index 900e3ddc3f87..c313fd3cffcf 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts +++ b/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts @@ -17,6 +17,7 @@ import { parseRemotePairingFields, showMatrixBridgeDisconnect, clientSessionRowAction, + currentPrimarySignOutMode, } from "./ConnectionsSettings.logic"; const baseWslState: DesktopWslState = { @@ -168,12 +169,9 @@ describe("remote pairing field parsing", () => { }); describe("clientSessionRowAction", () => { - it("lets a session without access:write sign itself out", () => { - expect(clientSessionRowAction({ isCurrent: true, canManageAccess: false })).toBe("sign-out"); - }); - - it("lets an administrative session sign itself out", () => { - expect(clientSessionRowAction({ isCurrent: true, canManageAccess: true })).toBe("sign-out"); + it("does not duplicate Sign out on the current-device client row", () => { + expect(clientSessionRowAction({ isCurrent: true, canManageAccess: false })).toBeNull(); + expect(clientSessionRowAction({ isCurrent: true, canManageAccess: true })).toBeNull(); }); it("scope-gates revoking other sessions behind access:write", () => { @@ -182,6 +180,22 @@ describe("clientSessionRowAction", () => { }); }); +describe("currentPrimarySignOutMode", () => { + it("hides Sign out on desktop because the local backend would sign back in", () => { + expect(currentPrimarySignOutMode({ isDesktop: true, authenticated: true })).toBe( + "desktop-managed", + ); + expect(currentPrimarySignOutMode({ isDesktop: true, authenticated: false })).toBe( + "desktop-managed", + ); + }); + + it("offers Sign out only when a browser session is actually authenticated", () => { + expect(currentPrimarySignOutMode({ isDesktop: false, authenticated: true })).toBe("sign-out"); + expect(currentPrimarySignOutMode({ isDesktop: false, authenticated: false })).toBe("hidden"); + }); +}); + describe("matrixBridgeSectionAccess", () => { it("hides the subsection on servers without the bridge capability", () => { expect(matrixBridgeSectionAccess({ supported: false, canManageAccess: true })).toBe("hidden"); diff --git a/apps/web/src/components/settings/ConnectionsSettings.logic.ts b/apps/web/src/components/settings/ConnectionsSettings.logic.ts index e509928aa35f..2c924a86df5c 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.logic.ts +++ b/apps/web/src/components/settings/ConnectionsSettings.logic.ts @@ -16,19 +16,37 @@ export function isQrShareableEndpoint(endpoint: AdvertisedEndpoint): boolean { } /** - * Current-device sign-out is self-service. Administrative revoke of other - * clients stays behind access:write. + * Current-device sign-out lives on the dedicated Sign out row, not on each + * authorized-clients row. The list only revokes other clients, and that stays + * behind access:write. */ export function clientSessionRowAction(input: { readonly isCurrent: boolean; readonly canManageAccess: boolean; -}): "sign-out" | "revoke" | null { +}): "revoke" | null { if (input.isCurrent) { - return "sign-out"; + return null; } return input.canManageAccess ? "revoke" : null; } +/** + * Desktop's local backend re-creates an administrative session from the + * unbounded bootstrap credential on every load, so Sign out cannot leave the + * app signed out. Hide the action there rather than lying. Browsers only get + * the action when a primary session is actually authenticated; otherwise the + * request 401s and the page reloads in place. + */ +export function currentPrimarySignOutMode(input: { + readonly isDesktop: boolean; + readonly authenticated: boolean; +}): "sign-out" | "desktop-managed" | "hidden" { + if (input.isDesktop) { + return "desktop-managed"; + } + return input.authenticated ? "sign-out" : "hidden"; +} + export type QrEndpointOption = { readonly id: string; readonly preferenceKey: string; diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index cfeb135c2fbe..1c051300feaa 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -66,6 +66,7 @@ import { parseRemotePairingFields, selectQrEndpointOption, clientSessionRowAction, + currentPrimarySignOutMode, type MatrixBridgePairingCode, } from "./ConnectionsSettings.logic"; import { @@ -889,9 +890,7 @@ type ConnectedClientListRowProps = { presentation?: AccessSectionPresentation; canManageAccess: boolean; revokingClientSessionId: string | null; - isSigningOut: boolean; onRevokeSession: (sessionId: ServerClientSessionRecord["sessionId"]) => void; - onSignOutCurrent: () => void; }; const ConnectedClientListRow = memo(function ConnectedClientListRow({ @@ -899,9 +898,7 @@ const ConnectedClientListRow = memo(function ConnectedClientListRow({ presentation = "current", canManageAccess, revokingClientSessionId, - isSigningOut, onRevokeSession, - onSignOutCurrent, }: ConnectedClientListRowProps) { const nowMs = useRelativeTimeTick(1_000); const isLive = clientSession.current || clientSession.connected; @@ -958,16 +955,7 @@ const ConnectedClientListRow = memo(function ConnectedClientListRow({

- {rowAction === "sign-out" ? ( - - ) : rowAction === "revoke" ? ( + {rowAction === "revoke" ? ( +
+ +
+ ); +} + export function PairingRouteSurface({ auth, initialErrorMessage, diff --git a/apps/web/src/routes/pair.logic.test.ts b/apps/web/src/routes/pair.logic.test.ts index fa50890a3b21..7d1853d5cc3b 100644 --- a/apps/web/src/routes/pair.logic.test.ts +++ b/apps/web/src/routes/pair.logic.test.ts @@ -21,6 +21,16 @@ describe("pairRouteDisposition", () => { ).toBe("apply-pairing-credential"); }); + it("does not apply a pairing link against the desktop app's local session", () => { + expect( + pairRouteDisposition({ + authStatus: "authenticated", + pairingToken: "PAIRME12345", + isDesktop: true, + }), + ).toBe("desktop-local-session"); + }); + it("treats whitespace-only pairing credentials as absent", () => { expect( pairRouteDisposition({ diff --git a/apps/web/src/routes/pair.logic.ts b/apps/web/src/routes/pair.logic.ts index cbc79b95100b..065fb29bb3a4 100644 --- a/apps/web/src/routes/pair.logic.ts +++ b/apps/web/src/routes/pair.logic.ts @@ -8,6 +8,7 @@ export type PairRouteAuthStatus = export type PairRouteDisposition = | "hosted-pairing" | "apply-pairing-credential" + | "desktop-local-session" | "pairing-form" | "redirect-home"; @@ -21,6 +22,7 @@ export type PairRouteDisposition = export function pairRouteDisposition(input: { readonly authStatus: PairRouteAuthStatus; readonly pairingToken: string | null; + readonly isDesktop?: boolean; }): PairRouteDisposition { if (input.authStatus === "hosted-pairing") { return "hosted-pairing"; @@ -36,7 +38,13 @@ export function pairRouteDisposition(input: { const pairingToken = input.pairingToken?.trim() ?? ""; if (input.authStatus === "authenticated") { - return pairingToken.length > 0 ? "apply-pairing-credential" : "redirect-home"; + if (pairingToken.length === 0) { + return "redirect-home"; + } + // Desktop's primary connection is the main-process bearer, not the cookie + // pairing replace would install. Applying here would consume the link and + // report success while the administrative session stayed in place. + return input.isDesktop === true ? "desktop-local-session" : "apply-pairing-credential"; } return "pairing-form"; diff --git a/apps/web/src/routes/pair.tsx b/apps/web/src/routes/pair.tsx index bfe43dc839bb..10a6b7ab97c0 100644 --- a/apps/web/src/routes/pair.tsx +++ b/apps/web/src/routes/pair.tsx @@ -2,6 +2,7 @@ import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; import { AuthenticatedPairingApplySurface, + DesktopLocalPairingSurface, HostedPairingRouteSurface, PairingPendingSurface, PairingRouteSurface, @@ -15,6 +16,7 @@ export const Route = createFileRoute("/pair")({ const disposition = pairRouteDisposition({ authStatus: authGateState.status, pairingToken: peekPairingTokenFromUrl(), + isDesktop: window.desktopBridge !== undefined, }); if (disposition === "redirect-home") { @@ -23,6 +25,7 @@ export const Route = createFileRoute("/pair")({ return { authGateState, + pairDisposition: disposition, }; }, component: PairRouteView, @@ -30,14 +33,14 @@ export const Route = createFileRoute("/pair")({ }); function PairRouteView() { - const { authGateState } = Route.useRouteContext(); + const { authGateState, pairDisposition } = Route.useRouteContext(); const navigate = useNavigate(); if (!authGateState) { return null; } - if (authGateState.status === "hosted-pairing") { + if (pairDisposition === "hosted-pairing" || authGateState.status === "hosted-pairing") { return ; } @@ -45,7 +48,11 @@ function PairRouteView() { void navigate({ to: "/", replace: true }); }; - if (authGateState.status === "authenticated") { + if (pairDisposition === "desktop-local-session") { + return ; + } + + if (pairDisposition === "apply-pairing-credential" || authGateState.status === "authenticated") { return ( { diff --git a/docs/internals/environment-auth.md b/docs/internals/environment-auth.md index b3f1b00a7ab2..2562ff20c35d 100644 --- a/docs/internals/environment-auth.md +++ b/docs/internals/environment-auth.md @@ -41,8 +41,11 @@ grant's scopes replace that browser's session. Replacement is fail-closed: the previous cookie-backed session must be revoked and its live sockets signaled before the new cookie is installed. If displacement cannot be confirmed, the request fails, the original session stays usable, and nothing is replaced. -Bearer and DPoP sessions on the same request are left alone. It is not an -upgrade-only merge. +Bearer and DPoP sessions on the same request are left alone. The desktop app +does not offer pairing replacement against its local backend session: the +primary transport is the main-process bearer, so applying a cookie would +consume the link and report success without changing the live session. It is +not an upgrade-only merge. `POST /api/auth/session/sign-out` revokes the caller's own session and expires the browser session cookie. It does not require `access:write`. Administrative diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 138b3ae8d8e5..abcd28f493a2 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -58,7 +58,7 @@ available. You can set another endpoint as the default from the expanded endpoin If the copied link points directly at `http://192.168.x.y:3773`, open it from a client that can reach that LAN address. If it points at `https://app.t3.codes/pair?...`, the hosted web app will save the environment and connect directly to the backend URL in the link. -Opening a pairing link in a browser that is already signed in asks you to apply it. Applying replaces this browser's session with the permissions from the link. If the link grants fewer permissions than you have now, the confirmation says so before you apply. If replacement cannot be completed, that browser stays signed in as it was and the link is not applied. +Opening a pairing link in a browser that is already signed in asks you to apply it. Applying replaces this browser's session with the permissions from the link. If the link grants fewer permissions than you have now, the confirmation says so before you apply. If replacement cannot be completed, that browser stays signed in as it was and the link is not applied. The desktop app does not apply a pairing link against its local backend session; use the link from a browser or another device. To sign this browser out without affecting other devices, open **Settings** → **Connections** and choose **Sign out**. That works even when this session cannot create pairing links or manage other clients. Signing out, or applying a pairing link that replaces this browser's session, disconnects other tabs that were using that session. The desktop app does not offer Sign out for its local backend, because it would sign itself back in immediately. The mobile app does not yet offer a way to sign this device out of a saved environment. From ff527788c9c7bdbfffdc6719e0c622b1ee92b25c Mon Sep 17 00:00:00 2001 From: "wizzoapp[bot]" <254688279+wizzoapp[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 21:34:04 +0100 Subject: [PATCH 6/6] fix(auth): roll back pairing sessions when displacement fails Fail-closed replacement created the new session first, so a later socket close or revoke failure spent the one-time link, left an unreachable session, and still told the UI that nothing changed. If revoke persisted and MCP cleanup then failed, the browser was signed out with no cookie. Keep creation first so the credential is the reservation against concurrent applies. On displacement failure, revoke the new session, keep the original cookie, and report that the link was used up. If revoke persisted and only cleanup failed, install the replacement cookie and log the cleanup failure. Pairing copy now matches whether the session was kept, the link was already used, or the browser was signed out. Grok 4.6 (T3 Code / Grok harness) --- apps/server/src/auth/http.ts | 137 ++++++++-- apps/server/src/server.test.ts | 249 +++++++++++++++++- apps/web/src/authBootstrap.test.ts | 52 ++++ .../auth/PairingRouteSurface.logic.ts | 77 +++++- .../auth/PairingRouteSurface.test.tsx | 100 ++++++- .../components/auth/PairingRouteSurface.tsx | 50 +++- apps/web/src/environments/primary/auth.ts | 50 ++++ apps/web/src/environments/primary/index.ts | 4 + packages/contracts/src/environmentHttp.ts | 1 + 9 files changed, 679 insertions(+), 41 deletions(-) diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index 99afd664e40f..a18999884ec7 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -253,6 +253,100 @@ export function failEnvironmentInternal(reason: EnvironmentInternalErrorReason, }); } +type CookieDisplacementOutcome = + | { readonly _tag: "displaced" } + | { readonly _tag: "kept"; readonly error: unknown }; + +// Pairing replacement consumes the one-time credential first so concurrent +// applies cannot both revoke live sessions. If displacement then cannot +// finish, the just-created session is rolled back and the original cookie +// stays. Persist-ok plus later MCP cleanup failure is still displacement +// success: the previous session is gone, so the new cookie must be installed. +const displacePreviousCookieSession = Effect.fn("environment.auth.displacePreviousCookieSession")( + function* (input: { + readonly sessions: SessionStore.SessionStore["Service"]; + readonly serverAuth: EnvironmentAuth.EnvironmentAuth["Service"]; + readonly sessionId: EnvironmentAuth.AuthenticatedSession["sessionId"]; + }): Effect.fn.Return { + const interruptError = yield* input.sessions.interruptSockets(input.sessionId).pipe( + Effect.as(null), + Effect.catchIf(SessionStore.isSessionCredentialInternalError, (error) => + Effect.succeed(error), + ), + ); + if (interruptError !== null) { + return { _tag: "kept", error: interruptError }; + } + + const revokeError = yield* input.serverAuth.revokeSession(input.sessionId).pipe( + Effect.as(null), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => Effect.succeed(error)), + ); + if (revokeError === null) { + return { _tag: "displaced" }; + } + + const previousStillActive = yield* input.sessions.getActive(input.sessionId).pipe( + Effect.map(Option.isSome), + Effect.catchIf(SessionStore.isSessionCredentialInternalError, (error) => + Effect.logWarning( + "Could not confirm whether pairing displacement persisted; keeping the original session", + ).pipe( + Effect.annotateLogs({ + sessionId: input.sessionId, + cause: error, + }), + Effect.as(true), + ), + ), + ); + if (previousStillActive) { + return { _tag: "kept", error: revokeError }; + } + + yield* Effect.logWarning("Pairing displacement persisted; MCP credential cleanup failed").pipe( + Effect.annotateLogs({ + sessionId: input.sessionId, + cause: revokeError, + }), + ); + return { _tag: "displaced" }; + }, +); + +const rollBackIssuedBrowserSession = Effect.fn("environment.auth.rollBackIssuedBrowserSession")( + function* (input: { + readonly sessions: SessionStore.SessionStore["Service"]; + readonly serverAuth: EnvironmentAuth.EnvironmentAuth["Service"]; + readonly sessionToken: string; + }) { + const issued = yield* input.sessions + .verify(input.sessionToken) + .pipe( + Effect.catch((error) => + Effect.logWarning("Failed to read a pairing session for rollback").pipe( + Effect.annotateLogs({ cause: error }), + Effect.as(null), + ), + ), + ); + if (issued === null) { + return; + } + + yield* input.serverAuth.revokeSession(issued.sessionId).pipe( + Effect.catch((error) => + Effect.logWarning("Failed to roll back a pairing session after displacement failure").pipe( + Effect.annotateLogs({ + sessionId: issued.sessionId, + cause: error, + }), + ), + ), + ); + }, +); + export const requireEnvironmentScope = Effect.fn("environment.auth.requireScope")(function* ( scope: AuthEnvironmentScope, ) { @@ -352,7 +446,10 @@ export const authHttpApiLayer = HttpApiBuilder.group( // Replacement is a security boundary. Fail closed unless the // previous cookie session is confirmed gone: do not install a cookie // when the existing session cannot be read, its live sockets cannot - // be signaled, or it cannot be revoked. + // be signaled, or it cannot be revoked. Create first so the + // one-time credential is the reservation: a concurrent loser never + // displaces. If displacement then fails, roll the new session back + // so the original cookie stays and no unreachable session remains. const previousSession = yield* serverAuth.authenticateHttpRequest(request).pipe( Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, () => Effect.succeed(null), @@ -376,22 +473,32 @@ export const authHttpApiLayer = HttpApiBuilder.group( hostedOrigins, }), ), - ).pipe(Effect.catch(() => failEnvironmentInternal("browser_session_cookie_failed"))); + ).pipe( + Effect.catch(() => + rollBackIssuedBrowserSession({ + sessions, + serverAuth, + sessionToken: result.sessionToken, + }).pipe(Effect.andThen(failEnvironmentInternal("browser_session_cookie_failed"))), + ), + ); if (previousSession !== null && previousSession.method === "browser-session-cookie") { - yield* sessions - .interruptSockets(previousSession.sessionId) - .pipe( - Effect.catchIf(SessionStore.isSessionCredentialInternalError, (error) => - failEnvironmentInternal("browser_session_replacement_failed", error), - ), - ); - yield* serverAuth - .revokeSession(previousSession.sessionId) - .pipe( - Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => - failEnvironmentInternal("browser_session_replacement_failed", error), - ), + const displacement = yield* displacePreviousCookieSession({ + sessions, + serverAuth, + sessionId: previousSession.sessionId, + }); + if (displacement._tag === "kept") { + yield* rollBackIssuedBrowserSession({ + sessions, + serverAuth, + sessionToken: result.sessionToken, + }); + return yield* failEnvironmentInternal( + "browser_session_replacement_reverted", + displacement.error, ); + } } yield* HttpEffect.appendPreResponseHandler((_request, response) => diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 632acf296cb3..a2f9beabf2d9 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -1942,15 +1942,21 @@ it.layer(NodeServices.layer)("server router seam", (it) => { "does not replace the browser session when revoking the existing cookie session fails", () => Effect.gen(function* () { + let remainingForcedRevokeFailures = 1; yield* buildAppUnderTest({ wrapEnvironmentAuth: (inner) => ({ ...inner, - revokeSession: () => - Effect.fail( - new EnvironmentAuth.ServerAuthSessionRevocationError({ - cause: new Error("session revoke unavailable"), - }), - ), + revokeSession: (sessionId) => { + if (remainingForcedRevokeFailures > 0) { + remainingForcedRevokeFailures -= 1; + return Effect.fail( + new EnvironmentAuth.ServerAuthSessionRevocationError({ + cause: new Error("session revoke unavailable"), + }), + ); + } + return inner.revokeSession(sessionId); + }, }), }); @@ -1977,12 +1983,178 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(credentialResponse.status, 200); assert.equal(replacement.response.status, 500); - assert.equal(replacementBody.reason, "browser_session_replacement_failed"); + assert.equal(replacementBody.reason, "browser_session_replacement_reverted"); assert.isUndefined(replacement.cookie); assert.equal(originalBody.authenticated, true); + + const retryWhileBlocked = yield* bootstrapBrowserSession(credential.credential, { + headers: { cookie: ownerCookie }, + }); + const retryWhileBlockedBody = yield* responseJsonEffect<{ + readonly reason?: string; + }>(retryWhileBlocked.response); + assert.equal(retryWhileBlocked.response.status, 401); + assert.equal(retryWhileBlockedBody.reason, "consumed_credential"); + assert.isUndefined(retryWhileBlocked.cookie); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect( + "rolls back the new pairing session after displacement failure so the original cookie stays", + () => + Effect.gen(function* () { + let remainingForcedRevokeFailures = 1; + yield* buildAppUnderTest({ + wrapEnvironmentAuth: (inner) => ({ + ...inner, + revokeSession: (sessionId) => { + if (remainingForcedRevokeFailures > 0) { + remainingForcedRevokeFailures -= 1; + return Effect.fail( + new EnvironmentAuth.ServerAuthSessionRevocationError({ + cause: new Error("session revoke unavailable"), + }), + ); + } + return inner.revokeSession(sessionId); + }, + }), + }); + + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const credentialResponse = yield* HttpClient.post("/api/auth/pairing-token", { + headers: { cookie: ownerCookie }, + body: yield* HttpBody.json({ audienceCeiling: "private" }), + }); + const credential = (yield* credentialResponse.json) as { readonly credential: string }; + const blocked = yield* bootstrapBrowserSession(credential.credential, { + headers: { cookie: ownerCookie }, + }); + const blockedBody = yield* responseJsonEffect<{ + readonly reason?: string; + }>(blocked.response); + const clientsWhileBlockedResponse = yield* HttpClient.get("/api/auth/clients", { + headers: { cookie: ownerCookie }, + }); + const clientsWhileBlocked = (yield* clientsWhileBlockedResponse.json) as ReadonlyArray<{ + readonly current: boolean; + }>; + const retry = yield* bootstrapBrowserSession(credential.credential, { + headers: { cookie: ownerCookie }, + }); + const retryBody = yield* responseJsonEffect<{ + readonly reason?: string; + }>(retry.response); + + const sessionUrl = yield* getHttpServerUrl("/api/auth/session"); + const originalResponse = yield* fetchEffect(sessionUrl, { + headers: { cookie: ownerCookie }, + }); + const originalBody = yield* responseJsonEffect<{ readonly authenticated: boolean }>( + originalResponse, + ); + + assert.equal(credentialResponse.status, 200); + assert.equal(blocked.response.status, 500); + assert.equal(blockedBody.reason, "browser_session_replacement_reverted"); + assert.isUndefined(blocked.cookie); + assert.equal(clientsWhileBlockedResponse.status, 200); + assert.equal(clientsWhileBlocked.filter((client) => client.current).length, 1); + assert.equal(retry.response.status, 401); + assert.equal(retryBody.reason, "consumed_credential"); + assert.isUndefined(retry.cookie); + assert.equal(originalBody.authenticated, true); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect( + "installs the replacement cookie when session revoke persisted but MCP cleanup failed", + () => + Effect.gen(function* () { + yield* buildAppUnderTest({ + wrapEnvironmentAuth: (inner) => ({ + ...inner, + revokeSession: (sessionId) => + inner.revokeSession(sessionId).pipe( + Effect.flatMap(() => + Effect.fail( + new EnvironmentAuth.ServerAuthSessionRevocationError({ + cause: new Error("MCP peer credential cleanup unavailable"), + }), + ), + ), + ), + }), + }); + + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const credentialResponse = yield* HttpClient.post("/api/auth/pairing-token", { + headers: { cookie: ownerCookie }, + body: yield* HttpBody.json({ audienceCeiling: "private" }), + }); + const credential = (yield* credentialResponse.json) as { readonly credential: string }; + const replacement = yield* bootstrapBrowserSession(credential.credential, { + headers: { cookie: ownerCookie }, + }); + const replacementCookie = replacement.cookie?.split(";")[0] ?? ""; + + const sessionUrl = yield* getHttpServerUrl("/api/auth/session"); + const originalResponse = yield* fetchEffect(sessionUrl, { + headers: { cookie: ownerCookie }, + }); + const originalBody = yield* responseJsonEffect<{ readonly authenticated: boolean }>( + originalResponse, + ); + const nextResponse = yield* fetchEffect(sessionUrl, { + headers: { cookie: replacementCookie }, + }); + const nextBody = yield* responseJsonEffect<{ readonly authenticated: boolean }>( + nextResponse, + ); + + assert.equal(credentialResponse.status, 200); + assert.equal(replacement.response.status, 200); + assert.isDefined(replacement.cookie); + assert.equal(originalBody.authenticated, false); + assert.equal(nextBody.authenticated, true); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("does not sign out a live cookie session when the pairing token is already dead", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const credentialResponse = yield* HttpClient.post("/api/auth/pairing-token", { + headers: { cookie: ownerCookie }, + body: yield* HttpBody.json({ audienceCeiling: "private" }), + }); + const credential = (yield* credentialResponse.json) as { readonly credential: string }; + const first = yield* bootstrapBrowserSession(credential.credential, { + headers: { cookie: ownerCookie }, + }); + const firstCookie = first.cookie?.split(";")[0] ?? ""; + const reused = yield* bootstrapBrowserSession(credential.credential, { + headers: { cookie: firstCookie }, + }); + const reusedBody = yield* responseJsonEffect<{ + readonly reason?: string; + }>(reused.response); + + const sessionUrl = yield* getHttpServerUrl("/api/auth/session"); + const liveResponse = yield* fetchEffect(sessionUrl, { + headers: { cookie: firstCookie }, + }); + const liveBody = yield* responseJsonEffect<{ readonly authenticated: boolean }>(liveResponse); + + assert.equal(first.response.status, 200); + assert.equal(reused.response.status, 401); + assert.equal(reusedBody.reason, "consumed_credential"); + assert.isUndefined(reused.cookie); + assert.equal(liveBody.authenticated, true); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("does not replace the browser session when interrupting displaced sockets fails", () => Effect.gen(function* () { yield* buildAppUnderTest({ @@ -2021,12 +2193,73 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(credentialResponse.status, 200); assert.equal(replacement.response.status, 500); - assert.equal(replacementBody.reason, "browser_session_replacement_failed"); + assert.equal(replacementBody.reason, "browser_session_replacement_reverted"); assert.isUndefined(replacement.cookie); assert.equal(originalBody.authenticated, true); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect( + "does not replace the browser session when displacement cannot be confirmed after revoke fails", + () => + Effect.gen(function* () { + let remainingForcedRevokeFailures = 1; + yield* buildAppUnderTest({ + wrapEnvironmentAuth: (inner) => ({ + ...inner, + revokeSession: (sessionId) => { + if (remainingForcedRevokeFailures > 0) { + remainingForcedRevokeFailures -= 1; + return Effect.fail( + new EnvironmentAuth.ServerAuthSessionRevocationError({ + cause: new Error("session revoke unavailable"), + }), + ); + } + return inner.revokeSession(sessionId); + }, + }), + wrapSessionStore: (inner) => ({ + ...inner, + getActive: (sessionId) => + Effect.fail( + new SessionStore.SessionCredentialVerificationError({ + sessionId, + cause: new Error("session lookup unavailable"), + }), + ), + }), + }); + + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const credentialResponse = yield* HttpClient.post("/api/auth/pairing-token", { + headers: { cookie: ownerCookie }, + body: yield* HttpBody.json({ audienceCeiling: "private" }), + }); + const credential = (yield* credentialResponse.json) as { readonly credential: string }; + const replacement = yield* bootstrapBrowserSession(credential.credential, { + headers: { cookie: ownerCookie }, + }); + const replacementBody = yield* responseJsonEffect<{ + readonly reason?: string; + }>(replacement.response); + + const sessionUrl = yield* getHttpServerUrl("/api/auth/session"); + const originalResponse = yield* fetchEffect(sessionUrl, { + headers: { cookie: ownerCookie }, + }); + const originalBody = yield* responseJsonEffect<{ readonly authenticated: boolean }>( + originalResponse, + ); + + assert.equal(credentialResponse.status, 200); + assert.equal(replacement.response.status, 500); + assert.equal(replacementBody.reason, "browser_session_replacement_reverted"); + assert.isUndefined(replacement.cookie); + assert.equal(originalBody.authenticated, true); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("does not revoke a bearer session when pairing installs a browser cookie", () => Effect.gen(function* () { yield* buildAppUnderTest(); diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts index 07e35f6314d4..3d76903b1a6d 100644 --- a/apps/web/src/authBootstrap.test.ts +++ b/apps/web/src/authBootstrap.test.ts @@ -406,6 +406,58 @@ describe("resolveInitialServerAuthGateState", () => { expect(testApi.calls.browserSession).toEqual([{ credential: "replace-token" }]); }); + it("surfaces a reverted replacement as a spent link that kept the current session", async () => { + const cause = new EnvironmentInternalError({ + code: "internal_error", + reason: "browser_session_replacement_reverted", + traceId: "trace-replacement-reverted", + }); + const testApi = await installAuthApi({ + browserSession: () => Effect.fail(cause), + }); + + const { isPrimaryEnvironmentSessionReplacementRevertedError, submitServerAuthCredential } = + await import("./environments/primary"); + + const error = await submitServerAuthCredential("revert-token").then( + () => null, + (failure: unknown) => failure, + ); + expect(error).toMatchObject({ + _tag: "PrimaryEnvironmentSessionReplacementRevertedError", + message: + "Could not replace the existing session. This browser kept its current session, but the pairing link was used up. Get a new pairing link if you still need to replace it.", + }); + expect(isPrimaryEnvironmentSessionReplacementRevertedError(error)).toBe(true); + expect(testApi.calls.browserSession).toEqual([{ credential: "revert-token" }]); + }); + + it("surfaces a consumed pairing token as already used rather than invalid", async () => { + const cause = new EnvironmentAuthInvalidError({ + code: "auth_invalid", + reason: "consumed_credential", + traceId: "trace-consumed-credential", + }); + const testApi = await installAuthApi({ + browserSession: () => Effect.fail(cause), + }); + + const { isPrimaryEnvironmentPairingCredentialConsumedError, submitServerAuthCredential } = + await import("./environments/primary"); + + const error = await submitServerAuthCredential("used-token").then( + () => null, + (failure: unknown) => failure, + ); + expect(error).toMatchObject({ + _tag: "PrimaryEnvironmentPairingCredentialConsumedError", + providedLength: 10, + message: "That pairing link has already been used. Get a new pairing link and try again.", + }); + expect(isPrimaryEnvironmentPairingCredentialConsumedError(error)).toBe(true); + expect(testApi.calls.browserSession).toEqual([{ credential: "used-token" }]); + }); + it("derives primary request messages from structural request context", async () => { const cause = new Error("private transport detail"); const { PrimaryEnvironmentRequestError } = await import("./environments/primary"); diff --git a/apps/web/src/components/auth/PairingRouteSurface.logic.ts b/apps/web/src/components/auth/PairingRouteSurface.logic.ts index 6908d7a0bfa7..c9d1d0fe3f1b 100644 --- a/apps/web/src/components/auth/PairingRouteSurface.logic.ts +++ b/apps/web/src/components/auth/PairingRouteSurface.logic.ts @@ -11,21 +11,49 @@ export interface PairingCredentialSubmitDependencies { readonly errorMessageFromUnknown: (error: unknown) => string; } +export type PairingApplyFailureKind = "replacement-failed" | "consumed" | "rejected" | "generic"; + +export type PairingSubmitFailure = { + readonly message: string; + readonly kind: PairingApplyFailureKind; +}; + +export function pairingApplyFailureKindFromUnknown(error: unknown): PairingApplyFailureKind { + if (typeof error !== "object" || error === null || !("_tag" in error)) { + return "generic"; + } + + switch (error._tag) { + case "PrimaryEnvironmentSessionReplacementError": + return "replacement-failed"; + case "PrimaryEnvironmentSessionReplacementRevertedError": + case "PrimaryEnvironmentPairingCredentialConsumedError": + return "consumed"; + case "PrimaryEnvironmentPairingCredentialRejectedError": + return "rejected"; + default: + return "generic"; + } +} + // Submit a pairing credential and, on success, kick the primary environment's // connection supervisor. The supervisor may already be parked in the "blocked" // phase: its first connection attempt ran before this session cookie existed, // failed authentication, and blocked-phase supervisors wait for an explicit // signal instead of retrying. Without the kick the freshly paired app stays on // an empty shell (no projects, no websocket) until a manual page reload. -// Returns the user-facing error message, or null when authentication succeeded. +// Returns the user-facing failure, or null when authentication succeeded. export async function submitPairingCredentialAndUnblock( deps: PairingCredentialSubmitDependencies, credential: string, -): Promise { +): Promise { try { await deps.submitServerAuthCredential(credential); } catch (error) { - return deps.errorMessageFromUnknown(error); + return { + message: deps.errorMessageFromUnknown(error), + kind: pairingApplyFailureKindFromUnknown(error), + }; } // Re-read after the exchange: the poll may have registered (and parked) the @@ -118,3 +146,46 @@ export function describeAuthenticatedPairingApply(input: { downgradeWarning: UNKNOWN_GRANT_WARNING, }; } + +export function describeAuthenticatedPairingFailure(input: { + readonly kind: PairingApplyFailureKind; + readonly stillAuthenticated: boolean; +}): { + readonly title: string; + readonly explanation: string; + readonly retryLabel: string | null; + readonly continueLabel: string; +} { + const title = "Pairing link was not applied"; + + if (input.kind === "consumed") { + return { + title, + explanation: input.stillAuthenticated + ? "This link was already used. This browser kept its current session. Get a new pairing link if you still need to replace it." + : "This link was already used, and this browser is no longer signed in. Get a new pairing link to sign in again.", + retryLabel: null, + continueLabel: input.stillAuthenticated ? "Continue with current session" : "Continue", + }; + } + + if (input.stillAuthenticated) { + return { + title, + explanation: + input.kind === "rejected" + ? "This pairing token was rejected. This browser kept its current session. Check the link and try again, or continue as you are." + : "This browser kept its current session. You can retry with this link or continue as you are.", + retryLabel: "Retry", + continueLabel: "Continue with current session", + }; + } + + return { + title, + explanation: + "This browser is no longer signed in. You can retry with this link to sign in again.", + retryLabel: "Retry", + continueLabel: "Continue", + }; +} diff --git a/apps/web/src/components/auth/PairingRouteSurface.test.tsx b/apps/web/src/components/auth/PairingRouteSurface.test.tsx index 82288f4ba677..d2339787a782 100644 --- a/apps/web/src/components/auth/PairingRouteSurface.test.tsx +++ b/apps/web/src/components/auth/PairingRouteSurface.test.tsx @@ -12,10 +12,12 @@ import { describe, expect, it } from "vite-plus/test"; import { describeAuthenticatedPairingApply, + describeAuthenticatedPairingFailure, errorMessageFromUnknown, incomingGrantFromPairingLinks, incomingDropsCurrentScopes, isNarrowerAudienceCeiling, + pairingApplyFailureKindFromUnknown, submitPairingCredentialAndUnblock, } from "./PairingRouteSurface.logic"; @@ -98,7 +100,10 @@ describe("submitPairingCredentialAndUnblock", () => { const error = await submitPairingCredentialAndUnblock(deps, "BADCODE"); - expect(error).toBe("Invalid pairing token."); + expect(error).toEqual({ + message: "Invalid pairing token.", + kind: "generic", + }); expect(calls.retried).toEqual([]); }); @@ -107,7 +112,25 @@ describe("submitPairingCredentialAndUnblock", () => { const error = await submitPairingCredentialAndUnblock(deps, "BADCODE"); - expect(error).toBe("Authentication failed."); + expect(error).toEqual({ + message: "Authentication failed.", + kind: "generic", + }); + }); + + it("classifies tagged pairing failures so the apply surface can match copy to the outcome", async () => { + const { deps } = makeDeps({ + submitError: Object.assign(new Error("used"), { + _tag: "PrimaryEnvironmentPairingCredentialConsumedError", + }), + }); + + const error = await submitPairingCredentialAndUnblock(deps, "USEDCODE"); + + expect(error).toEqual({ + message: "used", + kind: "consumed", + }); }); }); @@ -228,3 +251,76 @@ describe("describeAuthenticatedPairingApply", () => { ).toBeNull(); }); }); + +describe("pairingApplyFailureKindFromUnknown", () => { + it("maps tagged primary pairing errors", () => { + expect( + pairingApplyFailureKindFromUnknown({ + _tag: "PrimaryEnvironmentSessionReplacementError", + }), + ).toBe("replacement-failed"); + expect( + pairingApplyFailureKindFromUnknown({ + _tag: "PrimaryEnvironmentSessionReplacementRevertedError", + }), + ).toBe("consumed"); + expect( + pairingApplyFailureKindFromUnknown({ + _tag: "PrimaryEnvironmentPairingCredentialConsumedError", + }), + ).toBe("consumed"); + expect( + pairingApplyFailureKindFromUnknown({ + _tag: "PrimaryEnvironmentPairingCredentialRejectedError", + }), + ).toBe("rejected"); + expect(pairingApplyFailureKindFromUnknown(new Error("nope"))).toBe("generic"); + }); +}); + +describe("describeAuthenticatedPairingFailure", () => { + it("keeps retry and current-session copy when replacement failed and the browser is still signed in", () => { + const copy = describeAuthenticatedPairingFailure({ + kind: "replacement-failed", + stillAuthenticated: true, + }); + + expect(copy.title).toBe("Pairing link was not applied"); + expect(copy.explanation).toMatch(/kept its current session/i); + expect(copy.explanation).toMatch(/retry with this link/i); + expect(copy.retryLabel).toBe("Retry"); + expect(copy.continueLabel).toBe("Continue with current session"); + }); + + it("does not offer retry when the link was already used", () => { + const signedIn = describeAuthenticatedPairingFailure({ + kind: "consumed", + stillAuthenticated: true, + }); + const signedOut = describeAuthenticatedPairingFailure({ + kind: "consumed", + stillAuthenticated: false, + }); + + expect(signedIn.explanation).toMatch(/already used/i); + expect(signedIn.explanation).toMatch(/kept its current session/i); + expect(signedIn.retryLabel).toBeNull(); + expect(signedOut.explanation).toMatch(/already used/i); + expect(signedOut.explanation).toMatch(/no longer signed in/i); + expect(signedOut.retryLabel).toBeNull(); + expect(signedOut.continueLabel).toBe("Continue"); + }); + + it("tells the truth when displacement ended the current session", () => { + const copy = describeAuthenticatedPairingFailure({ + kind: "generic", + stillAuthenticated: false, + }); + + expect(copy.explanation).toMatch(/no longer signed in/i); + expect(copy.explanation).toMatch(/retry with this link/i); + expect(copy.retryLabel).toBe("Retry"); + expect(copy.continueLabel).toBe("Continue"); + expect(copy.explanation).not.toMatch(/kept its current session/i); + }); +}); diff --git a/apps/web/src/components/auth/PairingRouteSurface.tsx b/apps/web/src/components/auth/PairingRouteSurface.tsx index 0aa364ddcafc..da64d47a44da 100644 --- a/apps/web/src/components/auth/PairingRouteSurface.tsx +++ b/apps/web/src/components/auth/PairingRouteSurface.tsx @@ -19,8 +19,10 @@ import { Input } from "../ui/input"; import { useAtomCommand } from "../../state/use-atom-command"; import { describeAuthenticatedPairingApply, + describeAuthenticatedPairingFailure, errorMessageFromUnknown, incomingGrantFromPairingLinks, + type PairingApplyFailureKind, type PairingGrantView, submitPairingCredentialAndUnblock, } from "./PairingRouteSurface.logic"; @@ -120,7 +122,7 @@ export function PairingRouteSurface({ setIsSubmitting(false); if (submitError) { - setErrorMessage(submitError); + setErrorMessage(submitError.message); return; } @@ -232,6 +234,10 @@ export function AuthenticatedPairingApplySurface({ const [errorMessage, setErrorMessage] = useState(() => credential.length > 0 ? "" : "This pairing link is missing its token.", ); + const [applyFailure, setApplyFailure] = useState<{ + readonly kind: PairingApplyFailureKind; + readonly stillAuthenticated: boolean; + } | null>(null); const [isSubmitting, setIsSubmitting] = useState(false); const strippedUrlRef = useRef(false); const primaryEnvironmentId = usePrimaryEnvironmentId(); @@ -244,11 +250,23 @@ export function AuthenticatedPairingApplySurface({ current: currentGrant, incoming: incomingGrant, }); + const failureCopy = + applyFailure !== null + ? describeAuthenticatedPairingFailure(applyFailure) + : errorMessage && credential.length === 0 + ? { + title: "Pairing link was not applied", + explanation: "This pairing link is missing its token.", + retryLabel: null, + continueLabel: "Continue with current session", + } + : null; const submitCredential = useCallback( async (nextCredential: string) => { setIsSubmitting(true); setErrorMessage(""); + setApplyFailure(null); const submitError = await submitPairingCredentialAndUnblock( { @@ -260,13 +278,23 @@ export function AuthenticatedPairingApplySurface({ nextCredential, ); - setIsSubmitting(false); - if (submitError) { - setErrorMessage(submitError); + let stillAuthenticated = true; + try { + const session = await fetchSessionState(); + stillAuthenticated = session.authenticated; + } catch { + // Pairing errors on this surface keep or restore the original cookie. + // A probe failure must not claim the browser was signed out. + } + setErrorMessage(submitError.message); + setApplyFailure({ kind: submitError.kind, stillAuthenticated }); + setIsSubmitting(false); return; } + setIsSubmitting(false); + startTransition(() => { onAuthenticated(); }); @@ -331,12 +359,10 @@ export function AuthenticatedPairingApplySurface({ {APP_DISPLAY_NAME}

- {errorMessage ? "Pairing link was not applied" : copy.title} + {failureCopy?.title ?? copy.title}

- {errorMessage - ? "This browser kept its current session. You can retry with this link or continue as you are." - : copy.explanation} + {failureCopy?.explanation ?? copy.explanation}

{scopeProbeReady && copy.downgradeWarning ? ( @@ -352,7 +378,7 @@ export function AuthenticatedPairingApplySurface({ ) : null}
- {credential.length > 0 ? ( + {credential.length > 0 && failureCopy?.retryLabel !== null ? (
diff --git a/apps/web/src/environments/primary/auth.ts b/apps/web/src/environments/primary/auth.ts index 9b1d7627c09b..32db3605ae9d 100644 --- a/apps/web/src/environments/primary/auth.ts +++ b/apps/web/src/environments/primary/auth.ts @@ -82,6 +82,18 @@ export class PrimaryEnvironmentPairingCredentialRejectedError extends Schema.Tag } } +export class PrimaryEnvironmentPairingCredentialConsumedError extends Schema.TaggedErrorClass()( + "PrimaryEnvironmentPairingCredentialConsumedError", + { + providedLength: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "That pairing link has already been used. Get a new pairing link and try again."; + } +} + export class PrimaryEnvironmentSessionReplacementError extends Schema.TaggedErrorClass()( "PrimaryEnvironmentSessionReplacementError", { @@ -93,14 +105,33 @@ export class PrimaryEnvironmentSessionReplacementError extends Schema.TaggedErro } } +export class PrimaryEnvironmentSessionReplacementRevertedError extends Schema.TaggedErrorClass()( + "PrimaryEnvironmentSessionReplacementRevertedError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Could not replace the existing session. This browser kept its current session, but the pairing link was used up. Get a new pairing link if you still need to replace it."; + } +} + export const isPrimaryEnvironmentSessionReplacementError = Schema.is( PrimaryEnvironmentSessionReplacementError, ); +export const isPrimaryEnvironmentSessionReplacementRevertedError = Schema.is( + PrimaryEnvironmentSessionReplacementRevertedError, +); + export const isPrimaryEnvironmentPairingCredentialRejectedError = Schema.is( PrimaryEnvironmentPairingCredentialRejectedError, ); +export const isPrimaryEnvironmentPairingCredentialConsumedError = Schema.is( + PrimaryEnvironmentPairingCredentialConsumedError, +); + export class PrimaryEnvironmentAuthSessionTimeoutError extends Schema.TaggedErrorClass()( "PrimaryEnvironmentAuthSessionTimeoutError", { @@ -266,6 +297,16 @@ async function exchangeBootstrapCredential(credential: string): Promise