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/SessionStore.test.ts b/apps/server/src/auth/SessionStore.test.ts index 05a23149e00f..dd862acb5888 100644 --- a/apps/server/src/auth/SessionStore.test.ts +++ b/apps/server/src/auth/SessionStore.test.ts @@ -4,7 +4,9 @@ import { expect, it } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; import * as SqlClient from "effect/unstable/sql/SqlClient"; @@ -435,4 +437,90 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { expect(afterReconnect[0]?.lastConnectedAt?.toString()).not.toBe(firstConnectedAt?.toString()); }).pipe(Effect.provide(Layer.merge(makeSessionStoreLayer(), TestClock.layer()))), ); + + it.effect("reads current persisted scopes for an active session", () => + Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + const sql = yield* SqlClient.SqlClient; + const issued = yield* sessions.issue({ + audienceCeiling: "private", + method: "bearer-access-token", + subject: "scope-downgrade", + scopes: ["orchestration:read", "access:write"], + }); + + yield* sql` + UPDATE auth_sessions + SET scopes = ${encodeEnvironmentScopes(["orchestration:read"])} + WHERE session_id = ${issued.sessionId} + `; + + const live = yield* sessions.getActive(issued.sessionId); + expect(Option.isSome(live)).toBe(true); + if (Option.isSome(live)) { + expect(live.value.scopes).toEqual(["orchestration:read"]); + } + }).pipe(Effect.provide(makeSessionStoreLayer())), + ); + + it.effect("removes revocation waiters when the waiter is interrupted", () => + Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + const issued = yield* sessions.issue({ + audienceCeiling: "private", + subject: "interrupted-waiter", + method: "bearer-access-token", + }); + const waiting = yield* sessions.awaitRevocation(issued.sessionId).pipe(Effect.forkChild); + yield* Fiber.interrupt(waiting); + yield* sessions.revoke(issued.sessionId); + yield* sessions.awaitRevocation(issued.sessionId); + }).pipe(Effect.provide(makeSessionStoreLayer())), + ); + + it.effect("unblocks revocation waiters when a session is revoked", () => + Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + const issued = yield* sessions.issue({ + audienceCeiling: "private", + subject: "revoke-waiter", + method: "bearer-access-token", + }); + const waiting = yield* sessions.awaitRevocation(issued.sessionId).pipe(Effect.forkChild); + const stillActive = yield* sessions.getActive(issued.sessionId); + + yield* sessions.revoke(issued.sessionId); + yield* Fiber.join(waiting); + const afterRevoke = yield* sessions.getActive(issued.sessionId); + yield* sessions.awaitRevocation(issued.sessionId); + + expect(Option.isSome(stillActive)).toBe(true); + expect(Option.isNone(afterRevoke)).toBe(true); + }).pipe(Effect.provide(makeSessionStoreLayer())), + ); + + it.effect("unblocks revocation waiters when other sessions are revoked", () => + Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + const kept = yield* sessions.issue({ + audienceCeiling: "private", + subject: "kept-session", + method: "bearer-access-token", + }); + const revoked = yield* sessions.issue({ + audienceCeiling: "private", + subject: "revoked-session", + method: "bearer-access-token", + }); + const waiting = yield* sessions.awaitRevocation(revoked.sessionId).pipe(Effect.forkChild); + + yield* sessions.revokeAllExcept(kept.sessionId); + yield* Fiber.join(waiting); + const keptActive = yield* sessions.getActive(kept.sessionId); + const revokedActive = yield* sessions.getActive(revoked.sessionId); + + expect(Option.isSome(keptActive)).toBe(true); + expect(Option.isNone(revokedActive)).toBe(true); + }).pipe(Effect.provide(makeSessionStoreLayer())), + ); }); diff --git a/apps/server/src/auth/SessionStore.ts b/apps/server/src/auth/SessionStore.ts index cff2dba712e0..a251c17b6511 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -11,6 +11,7 @@ import { import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -351,6 +352,18 @@ export class OtherSessionsRevocationError extends Schema.TaggedErrorClass()( + "SessionSocketInterruptError", + { + sessionId: AuthSessionId, + ...sessionCredentialInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to interrupt live sockets for the session."; + } +} + export const SessionCredentialInternalError = Schema.Union([ SessionClaimsEncodingError, SessionCredentialIssueError, @@ -360,6 +373,7 @@ export const SessionCredentialInternalError = Schema.Union([ ActiveSessionsListError, SessionRevocationError, OtherSessionsRevocationError, + SessionSocketInterruptError, ]); export type SessionCredentialInternalError = typeof SessionCredentialInternalError.Type; export const isSessionCredentialInternalError = Schema.is(SessionCredentialInternalError); @@ -408,6 +422,9 @@ export class SessionStore extends Context.Service< readonly isActive: ( sessionId: AuthSessionId, ) => Effect.Effect; + readonly getActive: ( + sessionId: AuthSessionId, + ) => Effect.Effect, SessionCredentialInternalError>; readonly streamChanges: Stream.Stream; readonly revoke: ( sessionId: AuthSessionId, @@ -417,6 +434,10 @@ export class SessionStore extends Context.Service< ) => Effect.Effect; readonly markConnected: (sessionId: AuthSessionId) => Effect.Effect; readonly markDisconnected: (sessionId: AuthSessionId) => Effect.Effect; + readonly awaitRevocation: (sessionId: AuthSessionId) => Effect.Effect; + readonly interruptSockets: ( + sessionId: AuthSessionId, + ) => Effect.Effect; } >()("t3/auth/SessionStore") {} @@ -490,6 +511,7 @@ export const make = Effect.gen(function* () { const authSessions = yield* AuthSessions.AuthSessionRepository; const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32); const connectedSessionsRef = yield* Ref.make(new Map()); + const revocationWaitersRef = yield* Ref.make(new Map>>()); const changesPubSub = yield* PubSub.unbounded(); const cookieNames = resolveSessionCookieNames({ mode: serverConfig.mode, @@ -512,6 +534,24 @@ export const make = Effect.gen(function* () { sessionId, }).pipe(Effect.asVoid); + const signalRevoked = (sessionId: AuthSessionId) => + Ref.modify(revocationWaitersRef, (current) => { + const waiters = current.get(sessionId); + if (waiters === undefined || waiters.size === 0) { + return [[], current] as const; + } + const next = new Map(current); + next.delete(sessionId); + return [[...waiters], next] as const; + }).pipe( + Effect.flatMap((waiters) => + Effect.forEach(waiters, (deferred) => Deferred.succeed(deferred, undefined), { + concurrency: "unbounded", + discard: true, + }), + ), + ); + const loadActiveSession = (sessionId: AuthSessionId) => Effect.gen(function* () { const row = yield* authSessions.getById({ sessionId }); @@ -923,24 +963,82 @@ export const make = Effect.gen(function* () { }, ); + const getActive: SessionStore["Service"]["getActive"] = Effect.fn("SessionStore.getActive")( + function* (sessionId) { + const now = yield* DateTime.now; + const session = yield* loadActiveSession(sessionId).pipe( + Effect.mapError((cause) => new SessionCredentialVerificationError({ sessionId, cause })), + ); + if ( + Option.isNone(session) || + session.value.expiresAt.epochMilliseconds <= now.epochMilliseconds + ) { + return Option.none(); + } + return session; + }, + ); + + const removeRevocationWaiter = (sessionId: AuthSessionId, deferred: Deferred.Deferred) => + Ref.update(revocationWaitersRef, (current) => { + const waiters = current.get(sessionId); + if (waiters === undefined) { + return current; + } + const nextWaiters = new Set(waiters); + nextWaiters.delete(deferred); + const next = new Map(current); + if (nextWaiters.size === 0) { + next.delete(sessionId); + } else { + next.set(sessionId, nextWaiters); + } + return next; + }); + + const awaitRevocation: SessionStore["Service"]["awaitRevocation"] = Effect.fn( + "SessionStore.awaitRevocation", + )(function* (sessionId) { + const deferred = yield* Deferred.make(); + yield* Effect.gen(function* () { + yield* Ref.update(revocationWaitersRef, (current) => { + const next = new Map(current); + const waiters = new Set(next.get(sessionId) ?? []); + waiters.add(deferred); + next.set(sessionId, waiters); + return next; + }); + const active = yield* isActive(sessionId).pipe(Effect.orElseSucceed(() => false)); + if (!active) { + yield* Deferred.succeed(deferred, undefined); + } + yield* Deferred.await(deferred); + }).pipe(Effect.ensuring(removeRevocationWaiter(sessionId, deferred))); + }); + const revoke: SessionStore["Service"]["revoke"] = Effect.fn("SessionStore.revoke")( function* (sessionId) { const revokedAt = yield* DateTime.now; - const revoked = yield* authSessions - .revoke({ - sessionId, - revokedAt, - }) - .pipe(Effect.mapError((cause) => new SessionRevocationError({ sessionId, cause }))); - if (revoked) { - yield* Ref.update(connectedSessionsRef, (current) => { - const next = new Map(current); - next.delete(sessionId); - return next; - }); - yield* emitRemoved(sessionId); - } - return revoked; + return yield* Effect.uninterruptible( + Effect.gen(function* () { + const revoked = yield* authSessions + .revoke({ + sessionId, + revokedAt, + }) + .pipe(Effect.mapError((cause) => new SessionRevocationError({ sessionId, cause }))); + if (revoked) { + yield* Ref.update(connectedSessionsRef, (current) => { + const next = new Map(current); + next.delete(sessionId); + return next; + }); + yield* signalRevoked(sessionId); + yield* emitRemoved(sessionId); + } + return revoked; + }), + ); }, ); @@ -948,34 +1046,42 @@ export const make = Effect.gen(function* () { "SessionStore.revokeAllExcept", )(function* (sessionId) { const revokedAt = yield* DateTime.now; - const revokedSessionIds = yield* authSessions - .revokeAllExcept({ - currentSessionId: sessionId, - revokedAt, - }) - .pipe( - Effect.mapError( - (cause) => new OtherSessionsRevocationError({ currentSessionId: sessionId, cause }), - ), - ); - if (revokedSessionIds.length > 0) { - yield* Ref.update(connectedSessionsRef, (current) => { - const next = new Map(current); - for (const revokedSessionId of revokedSessionIds) { - next.delete(revokedSessionId); + return yield* Effect.uninterruptible( + Effect.gen(function* () { + const revokedSessionIds = yield* authSessions + .revokeAllExcept({ + currentSessionId: sessionId, + revokedAt, + }) + .pipe( + Effect.mapError( + (cause) => new OtherSessionsRevocationError({ currentSessionId: sessionId, cause }), + ), + ); + if (revokedSessionIds.length > 0) { + yield* Ref.update(connectedSessionsRef, (current) => { + const next = new Map(current); + for (const revokedSessionId of revokedSessionIds) { + next.delete(revokedSessionId); + } + return next; + }); + yield* Effect.forEach(revokedSessionIds, signalRevoked, { + concurrency: "unbounded", + discard: true, + }); + yield* Effect.forEach( + revokedSessionIds, + (revokedSessionId) => emitRemoved(revokedSessionId), + { + concurrency: "unbounded", + discard: true, + }, + ); } - return next; - }); - yield* Effect.forEach( - revokedSessionIds, - (revokedSessionId) => emitRemoved(revokedSessionId), - { - concurrency: "unbounded", - discard: true, - }, - ); - } - return revokedSessionIds.length; + return revokedSessionIds.length; + }), + ); }); return SessionStore.of({ @@ -987,6 +1093,7 @@ export const make = Effect.gen(function* () { verifyWebSocketToken, listActive, isActive, + getActive, get streamChanges() { return Stream.fromPubSub(changesPubSub); }, @@ -994,6 +1101,8 @@ export const make = Effect.gen(function* () { revokeAllExcept, markConnected, markDisconnected, + awaitRevocation, + interruptSockets: (sessionId) => signalRevoked(sessionId), }); }); diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index 4306351c3320..a18999884ec7 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); } @@ -227,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, ) { @@ -293,12 +413,51 @@ 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 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. 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), + ), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentInternal("browser_session_replacement_failed", error), + ), + ); const result = yield* serverAuth.createBrowserSession( args.payload.credential, deriveAuthClientMetadata({ request }), @@ -314,7 +473,33 @@ 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") { + 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) => Effect.succeed(HttpServerResponse.mergeCookies(response, sessionCookies)), diff --git a/apps/server/src/mcp/McpSessionRegistry.test.ts b/apps/server/src/mcp/McpSessionRegistry.test.ts index 1682a71d4000..233845915a9b 100644 --- a/apps/server/src/mcp/McpSessionRegistry.test.ts +++ b/apps/server/src/mcp/McpSessionRegistry.test.ts @@ -36,6 +36,7 @@ const makeFakeSessionStore = ( verifyWebSocketToken: () => Effect.die("unused"), listActive: () => Effect.die("unused"), isActive, + getActive: () => Effect.die("unused"), get streamChanges() { return Stream.empty; }, @@ -43,6 +44,8 @@ const makeFakeSessionStore = ( revokeAllExcept: () => Effect.die("unused"), markConnected: () => Effect.die("unused"), markDisconnected: () => Effect.die("unused"), + awaitRevocation: () => Effect.die("unused"), + interruptSockets: () => Effect.die("unused"), }); const PersistedPeerTokenStoreFixture = Schema.Struct({ version: Schema.Literal(1), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 133bfa3c0671..a2f9beabf2d9 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -149,6 +149,7 @@ import * as ReviewService from "./review/ReviewService.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; +import * as SessionStore from "./auth/SessionStore.ts"; import * as MatrixBridgeConfig from "./matrix/MatrixBridgeConfig.ts"; import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts"; import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; @@ -317,11 +318,40 @@ const browserOtlpTracingLayer = Layer.mergeAll( Layer.succeed(HttpClient.TracerDisabledWhen, () => true), ); -const makeAuthTestLayer = () => - EnvironmentAuth.layer.pipe( +const makeAuthTestLayer = (options?: { + wrapAuth?: ( + inner: EnvironmentAuth.EnvironmentAuth["Service"], + ) => EnvironmentAuth.EnvironmentAuth["Service"]; + wrapSessions?: ( + inner: SessionStore.SessionStore["Service"], + ) => SessionStore.SessionStore["Service"]; +}) => { + let layer = EnvironmentAuth.layer.pipe( Layer.provide(SqlitePersistenceMemory), Layer.provide(ServerSecretStore.layer), ); + if (options?.wrapSessions !== undefined) { + const wrapSessions = options.wrapSessions; + layer = Layer.effect( + SessionStore.SessionStore, + Effect.gen(function* () { + const inner = yield* SessionStore.SessionStore; + return wrapSessions(inner); + }), + ).pipe(Layer.provideMerge(layer)); + } + if (options?.wrapAuth !== undefined) { + const wrapAuth = options.wrapAuth; + layer = Layer.effect( + EnvironmentAuth.EnvironmentAuth, + Effect.gen(function* () { + const inner = yield* EnvironmentAuth.EnvironmentAuth; + return wrapAuth(inner); + }), + ).pipe(Layer.provideMerge(layer)); + } + return layer; +}; const makeBrowserOtlpPayload = (spanName: string) => Effect.gen(function* () { @@ -424,6 +454,12 @@ const makeBrowserOtlpPayload = (spanName: string) => const buildAppUnderTest = (options?: { config?: Partial; + wrapEnvironmentAuth?: ( + inner: EnvironmentAuth.EnvironmentAuth["Service"], + ) => EnvironmentAuth.EnvironmentAuth["Service"]; + wrapSessionStore?: ( + inner: SessionStore.SessionStore["Service"], + ) => SessionStore.SessionStore["Service"]; layers?: { keybindings?: Partial; providerRegistry?: Partial; @@ -1138,7 +1174,16 @@ const buildAppUnderTest = (options?: { ...options?.layers?.cloudCliTokenManager, }), ), - Layer.provideMerge(makeAuthTestLayer()), + Layer.provideMerge( + makeAuthTestLayer({ + ...(options?.wrapEnvironmentAuth === undefined + ? {} + : { wrapAuth: options.wrapEnvironmentAuth }), + ...(options?.wrapSessionStore === undefined + ? {} + : { wrapSessions: options.wrapSessionStore }), + }), + ), Layer.provideMerge(MatrixBridgeConfig.layer.pipe(Layer.provide(ServerSecretStore.layer))), Layer.provideMerge(ServerSecretStore.layer), Layer.provide(workspaceAndProjectServicesLayer), @@ -1803,6 +1848,455 @@ 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* () { + 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 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); + + 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({ + wrapSessionStore: (inner) => ({ + ...inner, + interruptSockets: (sessionId) => + Effect.fail( + new SessionStore.SessionSocketInterruptError({ + sessionId, + cause: new Error("socket interrupt 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 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(); + + const { body: tokenBody } = yield* exchangeAccessToken(); + const bearer = tokenBody.access_token ?? ""; + const credentialResponse = yield* HttpClient.post("/api/auth/pairing-token", { + headers: { authorization: `Bearer ${bearer}` }, + body: yield* HttpBody.json({ audienceCeiling: "private" }), + }); + const credential = (yield* credentialResponse.json) as { readonly credential: string }; + const replacement = yield* bootstrapBrowserSession(credential.credential, { + headers: { authorization: `Bearer ${bearer}` }, + }); + + const sessionUrl = yield* getHttpServerUrl("/api/auth/session"); + const bearerResponse = yield* fetchEffect(sessionUrl, { + headers: { authorization: `Bearer ${bearer}` }, + }); + const bearerBody = yield* responseJsonEffect<{ readonly authenticated: boolean }>( + bearerResponse, + ); + const cookieHeader = replacement.cookie?.split(";")[0] ?? ""; + const cookieResponse = yield* fetchEffect(sessionUrl, { + headers: { cookie: cookieHeader }, + }); + const cookieBody = yield* responseJsonEffect<{ readonly authenticated: boolean }>( + cookieResponse, + ); + + assert.equal(credentialResponse.status, 200); + assert.equal(replacement.response.status, 200); + assert.equal(bearerBody.authenticated, true); + assert.equal(cookieBody.authenticated, true); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("exchanges a bootstrap grant for a scoped bearer access token", () => Effect.gen(function* () { yield* buildAppUnderTest(); @@ -4228,6 +4722,119 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("closes live websockets when the current session signs out", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const { response: bootstrapResponse, cookie } = yield* bootstrapBrowserSession(); + assert.equal(bootstrapResponse.status, 200); + assert.isDefined(cookie); + const cookieHeader = cookie?.split(";")[0] ?? ""; + const wsUrl = appendSessionCookieToWsUrl( + yield* getWsServerUrl("/ws", { authenticated: false }), + cookieHeader, + ); + + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const before = yield* client[WS_METHODS.serverGetConfig]({}); + assert.equal(before.environment.environmentId, testEnvironmentDescriptor.environmentId); + + const signOutResponse = yield* HttpClient.post("/api/auth/session/sign-out", { + headers: { cookie: cookieHeader }, + }); + assert.equal(signOutResponse.status, 200); + + const after = yield* client[WS_METHODS.serverGetConfig]({}).pipe(Effect.result); + assertTrue(after._tag === "Failure"); + }), + ), + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("closes live websockets when pairing replaces the browser session", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const cookieHeader = yield* getAuthenticatedSessionCookieHeader(); + const wsUrl = appendSessionCookieToWsUrl( + yield* getWsServerUrl("/ws", { authenticated: false }), + cookieHeader, + ); + const credentialResponse = yield* HttpClient.post("/api/auth/pairing-token", { + headers: { cookie: cookieHeader }, + body: yield* HttpBody.json({ audienceCeiling: "private" }), + }); + const credential = (yield* credentialResponse.json) as { readonly credential: string }; + assert.equal(credentialResponse.status, 200); + + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const before = yield* client[WS_METHODS.serverGetConfig]({}); + assert.equal(before.environment.environmentId, testEnvironmentDescriptor.environmentId); + + const replacement = yield* bootstrapBrowserSession(credential.credential, { + headers: { cookie: cookieHeader }, + }); + assert.equal(replacement.response.status, 200); + + const after = yield* client[WS_METHODS.serverGetConfig]({}).pipe(Effect.result); + assertTrue(after._tag === "Failure"); + }), + ), + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("does not keep privileged websocket scopes after a weaker pairing grant", () => + Effect.gen(function* () { + const snapshotPath = "/test/userdata/diagnostics/heap-snapshots/displaced.heapsnapshot"; + yield* buildAppUnderTest({ + layers: { + heapDiagnostics: { + writeSnapshot: () => Effect.succeed({ path: snapshotPath }), + }, + }, + }); + + const cookieHeader = yield* getAuthenticatedSessionCookieHeader(); + const wsUrl = appendSessionCookieToWsUrl( + yield* getWsServerUrl("/ws", { authenticated: false }), + cookieHeader, + ); + const credentialResponse = yield* HttpClient.post("/api/auth/pairing-token", { + headers: { cookie: cookieHeader }, + body: yield* HttpBody.json({ audienceCeiling: "private" }), + }); + const credential = (yield* credentialResponse.json) as { readonly credential: string }; + assert.equal(credentialResponse.status, 200); + + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const privileged = yield* client[WS_METHODS.serverWriteHeapSnapshot]({ + filename: "displaced.heapsnapshot", + }); + assert.equal(privileged.path, snapshotPath); + + const replacement = yield* bootstrapBrowserSession(credential.credential, { + headers: { cookie: cookieHeader }, + }); + assert.equal(replacement.response.status, 200); + + const after = yield* client[WS_METHODS.serverWriteHeapSnapshot]({ + filename: "displaced-after.heapsnapshot", + }).pipe(Effect.result); + assertTrue(after._tag === "Failure"); + }), + ), + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("restricts heap snapshot RPCs to access-write sessions", () => Effect.gen(function* () { const snapshotPath = "/test/userdata/diagnostics/heap-snapshots/admin.heapsnapshot"; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 7888fdf12ddd..39dd4fbdd461 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -67,7 +67,12 @@ import { } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; -import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http"; +import { + HttpRouter, + HttpServerRequest, + HttpServerRespondable, + HttpServerResponse, +} from "effect/unstable/http"; import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; @@ -127,6 +132,7 @@ import { requiredScopeForRpcMethod } from "./auth/RpcAuthorization.ts"; import { isAudienceScopedReadRpcMethod, isRpcMethodAllowedForAudienceCeiling, + restrictScopesForAudienceCeiling, } from "./auth/audienceScopePolicy.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as HeapDiagnostics from "./diagnostics/HeapDiagnostics.ts"; @@ -516,31 +522,55 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => } } }); - const authorizationError = (requiredScope: AuthEnvironmentScope, method: string) => + const authorizationError = ( + requiredScope: AuthEnvironmentScope, + method: string, + scopes: ReadonlyArray, + ) => new EnvironmentAuthorizationError({ - message: currentSession.scopes.includes(requiredScope) + message: scopes.includes(requiredScope) ? `RPC method ${method} is unavailable for the authenticated audience ceiling.` : `The authenticated token is missing required scope: ${requiredScope}.`, requiredScope, }); + const sessionInactiveError = (requiredScope: AuthEnvironmentScope, method: string) => + new EnvironmentAuthorizationError({ + message: `RPC method ${method} is unavailable because the authenticated session is no longer active.`, + requiredScope, + }); + const authorizeLiveSession = (method: string, requiredScope: AuthEnvironmentScope) => + sessions.getActive(currentSessionId).pipe( + Effect.orElseSucceed(() => Option.none()), + Effect.flatMap((liveSession) => { + if (Option.isNone(liveSession)) { + return Effect.fail(sessionInactiveError(requiredScope, method)); + } + const audienceCeiling = + currentSession.audienceCeiling === "factory" + ? ("factory" as const) + : liveSession.value.audienceCeiling; + const scopes = restrictScopesForAudienceCeiling( + liveSession.value.scopes, + audienceCeiling, + ); + return scopes.includes(requiredScope) && + isRpcMethodAllowedForAudienceCeiling(method, requiredScope, audienceCeiling) + ? Effect.void + : Effect.fail(authorizationError(requiredScope, method, scopes)); + }), + ); const authorizeEffect = ( method: string, requiredScope: AuthEnvironmentScope, effect: Effect.Effect, ): Effect.Effect => - currentSession.scopes.includes(requiredScope) && - isRpcMethodAllowedForAudienceCeiling(method, requiredScope, currentSession.audienceCeiling) - ? effect - : Effect.fail(authorizationError(requiredScope, method)); + authorizeLiveSession(method, requiredScope).pipe(Effect.andThen(effect)); const authorizeStream = ( method: string, requiredScope: AuthEnvironmentScope, stream: Stream.Stream, ): Stream.Stream => - currentSession.scopes.includes(requiredScope) && - isRpcMethodAllowedForAudienceCeiling(method, requiredScope, currentSession.audienceCeiling) - ? stream - : Stream.fail(authorizationError(requiredScope, method)); + Stream.unwrap(authorizeLiveSession(method, requiredScope).pipe(Effect.as(stream))); const requiredScopeForMethod = (method: string): AuthEnvironmentScope => requiredScopeForRpcMethod(method); const observeRpcEffect = ( @@ -2809,7 +2839,12 @@ export const websocketRpcRouteLayer = HttpRouter.add( ); return yield* Effect.acquireUseRelease( sessions.markConnected(session.sessionId), - () => rpcWebSocketHttpEffect, + () => + rpcWebSocketHttpEffect.pipe( + Effect.raceFirst( + sessions.awaitRevocation(session.sessionId).pipe(Effect.as(HttpServerResponse.empty())), + ), + ), () => sessions.markDisconnected(session.sessionId), ); }).pipe( diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts index e241e7d70876..3d76903b1a6d 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,92 @@ 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("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"); @@ -484,4 +577,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.logic.ts b/apps/web/src/components/auth/PairingRouteSurface.logic.ts index 1da7fa418870..c9d1d0fe3f1b 100644 --- a/apps/web/src/components/auth/PairingRouteSurface.logic.ts +++ b/apps/web/src/components/auth/PairingRouteSurface.logic.ts @@ -1,4 +1,4 @@ -import type { EnvironmentId } from "@t3tools/contracts"; +import type { AuthAudienceCeiling, AuthEnvironmentScope, EnvironmentId } from "@t3tools/contracts"; export interface PairingCredentialSubmitDependencies { readonly submitServerAuthCredential: (credential: string) => Promise; @@ -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 @@ -49,3 +77,115 @@ export function errorMessageFromUnknown(error: unknown): string { return "Authentication failed."; } + +export function incomingDropsCurrentScopes( + incoming: ReadonlyArray, + current: ReadonlyArray, +): boolean { + const incomingSet = new Set(incoming); + return current.some((scope) => !incomingSet.has(scope)); +} + +export type PairingGrantView = { + readonly scopes: ReadonlyArray | ReadonlyArray; + readonly audienceCeiling: AuthAudienceCeiling; +}; + +export function incomingGrantFromPairingLinks( + credential: string, + links: ReadonlyArray<{ + readonly credential: string; + readonly scopes: ReadonlyArray; + readonly audienceCeiling: AuthAudienceCeiling; + }>, +): PairingGrantView | null { + const match = links.find((link) => link.credential === credential); + return match === undefined + ? null + : { scopes: match.scopes, audienceCeiling: match.audienceCeiling }; +} + +export function isNarrowerAudienceCeiling( + incoming: AuthAudienceCeiling, + current: AuthAudienceCeiling, +): boolean { + return current === "private" && incoming === "factory"; +} + +const AUTHENTICATED_PAIRING_APPLY_EXPLANATION = + "This one-time link replaces the session on this browser with the permissions it grants."; + +const KNOWN_DOWNGRADE_WARNING = + "This link grants less access than this browser has now. Applying it replaces this session with the weaker grant. If you lose administrative access, on a headless server it only comes back from the startup pairing URL after a restart."; + +const UNKNOWN_GRANT_WARNING = + "This browser could not confirm what this link grants before applying. If the link is weaker than the current session, those permissions will be lost. If you lose administrative access, on a headless server it only comes back from the startup pairing URL after a restart."; + +export function describeAuthenticatedPairingApply(input: { + readonly current: PairingGrantView | null; + readonly incoming: PairingGrantView | null; +}): { + readonly title: string; + readonly explanation: string; + readonly downgradeWarning: string | null; +} { + if (input.current !== null && input.incoming !== null) { + const weaker = + incomingDropsCurrentScopes(input.incoming.scopes, input.current.scopes) || + isNarrowerAudienceCeiling(input.incoming.audienceCeiling, input.current.audienceCeiling); + return { + title: "Apply this pairing link?", + explanation: AUTHENTICATED_PAIRING_APPLY_EXPLANATION, + downgradeWarning: weaker ? KNOWN_DOWNGRADE_WARNING : null, + }; + } + + return { + title: "Apply this pairing link?", + explanation: AUTHENTICATED_PAIRING_APPLY_EXPLANATION, + 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 8a13dc4668cc..d2339787a782 100644 --- a/apps/web/src/components/auth/PairingRouteSurface.test.tsx +++ b/apps/web/src/components/auth/PairingRouteSurface.test.tsx @@ -6,11 +6,18 @@ // environmentCatalog.retryNow before the app navigates off /pair. Without the // kick the freshly paired app renders an empty shell (no projects, no // websocket) until the user manually reloads the page. -import { describe, expect, it } from "vite-plus/test"; +import { AuthAdministrativeScopes, AuthStandardClientScopes } from "@t3tools/contracts"; import type { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; import { + describeAuthenticatedPairingApply, + describeAuthenticatedPairingFailure, errorMessageFromUnknown, + incomingGrantFromPairingLinks, + incomingDropsCurrentScopes, + isNarrowerAudienceCeiling, + pairingApplyFailureKindFromUnknown, submitPairingCredentialAndUnblock, } from "./PairingRouteSurface.logic"; @@ -93,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([]); }); @@ -102,6 +112,215 @@ 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", + }); + }); +}); + +describe("incomingDropsCurrentScopes", () => { + it("is true when incoming omits any current scope, even if it also adds scopes", () => { + expect( + incomingDropsCurrentScopes([...AuthStandardClientScopes], [...AuthAdministrativeScopes]), + ).toBe(true); + expect(incomingDropsCurrentScopes(["relay:write"], ["access:write"])).toBe(true); + }); + + it("is false when incoming keeps every current scope", () => { + expect( + incomingDropsCurrentScopes([...AuthStandardClientScopes], [...AuthStandardClientScopes]), + ).toBe(false); + expect( + incomingDropsCurrentScopes([...AuthAdministrativeScopes], [...AuthStandardClientScopes]), + ).toBe(false); + }); +}); + +describe("incomingGrantFromPairingLinks", () => { + it("returns the matching grant without consuming it", () => { + expect( + incomingGrantFromPairingLinks("PAIRME12345", [ + { + credential: "OTHER", + scopes: [...AuthAdministrativeScopes], + audienceCeiling: "private", + }, + { + credential: "PAIRME12345", + scopes: [...AuthStandardClientScopes], + audienceCeiling: "factory", + }, + ]), + ).toEqual({ + scopes: [...AuthStandardClientScopes], + audienceCeiling: "factory", + }); + }); + + it("returns null when the credential is not a listed pairing link", () => { + expect(incomingGrantFromPairingLinks("PAIRME12345", [])).toBeNull(); + }); +}); + +describe("isNarrowerAudienceCeiling", () => { + it("treats factory as narrower than private", () => { + expect(isNarrowerAudienceCeiling("factory", "private")).toBe(true); + expect(isNarrowerAudienceCeiling("private", "private")).toBe(false); + expect(isNarrowerAudienceCeiling("factory", "factory")).toBe(false); + expect(isNarrowerAudienceCeiling("private", "factory")).toBe(false); + }); +}); + +describe("describeAuthenticatedPairingApply", () => { + it("warns plainly when the incoming grant is a strict subset of the current session", () => { + const copy = describeAuthenticatedPairingApply({ + current: { scopes: [...AuthAdministrativeScopes], audienceCeiling: "private" }, + incoming: { scopes: [...AuthStandardClientScopes], audienceCeiling: "private" }, + }); + + expect(copy.title).toBe("Apply this pairing link?"); + expect(copy.downgradeWarning).toMatch(/less access/i); + expect(copy.downgradeWarning).toMatch(/startup pairing URL/i); + }); + + it("warns when an incoming grant adds scopes but still drops existing ones", () => { + const copy = describeAuthenticatedPairingApply({ + current: { scopes: ["access:read", "access:write"], audienceCeiling: "private" }, + incoming: { scopes: ["access:read", "relay:write"], audienceCeiling: "private" }, + }); + + expect(copy.downgradeWarning).toMatch(/less access/i); + }); + + it("warns when the incoming grant narrows data access even if scopes match", () => { + const copy = describeAuthenticatedPairingApply({ + current: { scopes: [...AuthStandardClientScopes], audienceCeiling: "private" }, + incoming: { scopes: [...AuthStandardClientScopes], audienceCeiling: "factory" }, + }); + + expect(copy.downgradeWarning).toMatch(/less access/i); + }); + + it("warns any authenticated session when the incoming grant cannot be inspected", () => { + const copy = describeAuthenticatedPairingApply({ + current: { scopes: [...AuthStandardClientScopes], audienceCeiling: "private" }, + incoming: null, + }); + + expect(copy.downgradeWarning).toMatch(/could not confirm/i); + expect(copy.downgradeWarning).toMatch(/startup pairing URL/i); + }); + + it("warns when the current grant cannot be read, including omitted scopes", () => { + const copy = describeAuthenticatedPairingApply({ + current: null, + incoming: { scopes: [...AuthStandardClientScopes], audienceCeiling: "factory" }, + }); + + expect(copy.downgradeWarning).toMatch(/could not confirm/i); + }); + + it("does not warn when both grants are known and the incoming grant is not a downgrade", () => { + expect( + describeAuthenticatedPairingApply({ + current: { scopes: [...AuthStandardClientScopes], audienceCeiling: "private" }, + incoming: { scopes: [...AuthAdministrativeScopes], audienceCeiling: "private" }, + }).downgradeWarning, + ).toBeNull(); + expect( + describeAuthenticatedPairingApply({ + current: { scopes: [...AuthStandardClientScopes], audienceCeiling: "factory" }, + incoming: { scopes: [...AuthStandardClientScopes], audienceCeiling: "factory" }, + }).downgradeWarning, + ).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 8287495dcd9b..da64d47a44da 100644 --- a/apps/web/src/components/auth/PairingRouteSurface.tsx +++ b/apps/web/src/components/auth/PairingRouteSurface.tsx @@ -6,6 +6,8 @@ import { APP_DISPLAY_NAME } from "../../branding"; import { environmentCatalog } from "../../connection/catalog"; import { connectPairing } from "../../connection/onboarding"; import { + fetchSessionState, + listServerPairingLinks, peekPairingTokenFromUrl, stripPairingTokenFromUrl, submitServerAuthCredential, @@ -16,7 +18,12 @@ import { Button } from "../ui/button"; 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"; @@ -44,6 +51,36 @@ export function PairingPendingSurface() { ); } +export function DesktopLocalPairingSurface({ onContinue }: { onContinue: () => void }) { + return ( +
+
+
+
+
+
+ +
+

+ {APP_DISPLAY_NAME} +

+

+ This desktop app stays signed in +

+

+ Pairing links are for other devices and browsers. Applying one here would not replace this + app's local session, so the link was not used. +

+
+ +
+
+
+ ); +} + export function PairingRouteSurface({ auth, initialErrorMessage, @@ -85,7 +122,7 @@ export function PairingRouteSurface({ setIsSubmitting(false); if (submitError) { - setErrorMessage(submitError); + setErrorMessage(submitError.message); return; } @@ -182,6 +219,192 @@ export function PairingRouteSurface({ ); } +export function AuthenticatedPairingApplySurface({ + onAuthenticated, + onContinueWithoutApplying, +}: { + onAuthenticated: () => void; + onContinueWithoutApplying: () => void; +}) { + const autoPairTokenRef = useRef(peekPairingTokenFromUrl()); + const [credential] = useState(() => autoPairTokenRef.current ?? ""); + const [currentGrant, setCurrentGrant] = useState(null); + const [incomingGrant, setIncomingGrant] = useState(null); + const [scopeProbeReady, setScopeProbeReady] = useState(credential.length === 0); + 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(); + const primaryEnvironmentIdRef = useRef(primaryEnvironmentId); + primaryEnvironmentIdRef.current = primaryEnvironmentId; + const retryPrimaryEnvironment = useAtomCommand(environmentCatalog.retryNow, { + reportFailure: false, + }); + const copy = describeAuthenticatedPairingApply({ + 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( + { + submitServerAuthCredential, + retryPrimaryEnvironment, + getPrimaryEnvironmentId: () => primaryEnvironmentIdRef.current, + errorMessageFromUnknown, + }, + nextCredential, + ); + + if (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(); + }); + }, + [onAuthenticated, retryPrimaryEnvironment], + ); + + useEffect(() => { + if (strippedUrlRef.current) { + return; + } + strippedUrlRef.current = true; + stripPairingTokenFromUrl(); + }, []); + + useEffect(() => { + if (credential.length === 0) { + return; + } + + let cancelled = false; + void (async () => { + const [sessionResult, linksResult] = await Promise.allSettled([ + fetchSessionState(), + listServerPairingLinks(), + ]); + if (cancelled) { + return; + } + if ( + sessionResult.status === "fulfilled" && + sessionResult.value.authenticated && + sessionResult.value.scopes !== undefined && + sessionResult.value.audienceCeiling !== undefined + ) { + setCurrentGrant({ + scopes: sessionResult.value.scopes, + audienceCeiling: sessionResult.value.audienceCeiling, + }); + } + if (linksResult.status === "fulfilled") { + setIncomingGrant(incomingGrantFromPairingLinks(credential, linksResult.value)); + } + setScopeProbeReady(true); + })(); + + return () => { + cancelled = true; + }; + }, [credential]); + + return ( +
+
+
+
+
+
+ +
+

+ {APP_DISPLAY_NAME} +

+

+ {failureCopy?.title ?? copy.title} +

+

+ {failureCopy?.explanation ?? copy.explanation} +

+ + {scopeProbeReady && copy.downgradeWarning ? ( +
+ {copy.downgradeWarning} +
+ ) : null} + + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + +
+ {credential.length > 0 && failureCopy?.retryLabel !== null ? ( + + ) : null} + +
+
+
+ ); +} + 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..c313fd3cffcf 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts +++ b/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts @@ -16,6 +16,8 @@ import { parseRemotePairingHostChange, parseRemotePairingFields, showMatrixBridgeDisconnect, + clientSessionRowAction, + currentPrimarySignOutMode, } from "./ConnectionsSettings.logic"; const baseWslState: DesktopWslState = { @@ -166,6 +168,34 @@ describe("remote pairing field parsing", () => { }); }); +describe("clientSessionRowAction", () => { + 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", () => { + expect(clientSessionRowAction({ isCurrent: false, canManageAccess: true })).toBe("revoke"); + expect(clientSessionRowAction({ isCurrent: false, canManageAccess: false })).toBeNull(); + }); +}); + +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 5351de5b7aad..2c924a86df5c 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.logic.ts +++ b/apps/web/src/components/settings/ConnectionsSettings.logic.ts @@ -15,6 +15,38 @@ export function isQrShareableEndpoint(endpoint: AdvertisedEndpoint): boolean { return endpoint.status !== "unavailable" && endpoint.reachability !== "loopback"; } +/** + * 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; +}): "revoke" | null { + if (input.isCurrent) { + 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 29c8683e582f..1c051300feaa 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -65,6 +65,8 @@ import { parseRemotePairingHostChange, parseRemotePairingFields, selectQrEndpointOption, + clientSessionRowAction, + currentPrimarySignOutMode, type MatrixBridgePairingCode, } from "./ConnectionsSettings.logic"; import { @@ -115,6 +117,7 @@ import { revokeOtherServerClientSessions, revokeServerClientSession, revokeServerPairingLink, + signOutCurrentServerSession, isLoopbackHostname, usePrimarySessionState, type ServerClientSessionRecord, @@ -862,9 +865,30 @@ 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; onRevokeSession: (sessionId: ServerClientSessionRecord["sessionId"]) => void; }; @@ -872,6 +896,7 @@ type ConnectedClientListRowProps = { const ConnectedClientListRow = memo(function ConnectedClientListRow({ clientSession, presentation = "current", + canManageAccess, revokingClientSessionId, onRevokeSession, }: ConnectedClientListRowProps) { @@ -897,6 +922,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 +955,7 @@ const ConnectedClientListRow = memo(function ConnectedClientListRow({

- {!clientSession.current ? ( + {rowAction === "revoke" ? (