diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index 6e5f22fa3af6..028fe53e0191 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -52,6 +52,18 @@ const makeCookieRequest = ( EnvironmentAuth.EnvironmentAuth["Service"]["authenticateHttpRequest"] >[0]; +const makeBearerRequest = ( + token: string, +): Parameters[0] => + ({ + cookies: {}, + headers: { + authorization: `Bearer ${token}`, + }, + }) as unknown as Parameters< + EnvironmentAuth.EnvironmentAuth["Service"]["authenticateHttpRequest"] + >[0]; + const requestMetadata = { deviceType: "desktop" as const, os: "macOS", @@ -159,6 +171,71 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { }).pipe(Effect.provide(makeEnvironmentAuthLayer())), ); + it.effect("rotates desktop bearer sessions without accumulating authorized clients", () => + Effect.gen(function* () { + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const sessions = yield* SessionStore.SessionStore; + const browser = yield* serverAuth.createBrowserSession( + "desktop-bootstrap-token", + requestMetadata, + ); + const browserSession = yield* serverAuth.authenticateHttpRequest( + makeCookieRequest(sessions.cookieName, browser.sessionToken), + ); + const staleSessions = yield* Effect.forEach([1, 2, 3], () => + sessions.issue({ subject: "desktop-bootstrap", method: "bearer-access-token" }), + ); + const pairing = yield* serverAuth.issuePairingCredential(); + const paired = yield* serverAuth.exchangeBootstrapCredentialForAccessToken( + pairing.credential, + undefined, + { ...requestMetadata, label: "T3 Code Desktop" }, + ); + const first = yield* serverAuth.exchangeBootstrapCredentialForAccessToken( + "desktop-bootstrap-token", + undefined, + requestMetadata, + ); + const firstSession = yield* serverAuth.authenticateHttpRequest( + makeBearerRequest(first.access_token), + ); + const second = yield* serverAuth.exchangeBootstrapCredentialForAccessToken( + "desktop-bootstrap-token", + undefined, + requestMetadata, + ); + + const active = yield* serverAuth.listSessions(); + const firstError = yield* serverAuth + .authenticateHttpRequest(makeBearerRequest(first.access_token)) + .pipe(Effect.flip); + const secondSession = yield* serverAuth.authenticateHttpRequest( + makeBearerRequest(second.access_token), + ); + + expect(active).toHaveLength(3); + expect(active.map((entry) => entry.sessionId)).toContain(browserSession.sessionId); + expect(active.map((entry) => entry.sessionId)).toContain(secondSession.sessionId); + expect(active.map((entry) => entry.sessionId)).not.toContain(firstSession.sessionId); + expect(firstError._tag).toBe("ServerAuthInvalidCredentialError"); + for (const stale of staleSessions) { + const error = yield* sessions.verify(stale.token).pipe(Effect.flip); + expect(error._tag).toBe("SessionTokenRevokedError"); + } + const pairedSession = yield* serverAuth.authenticateHttpRequest( + makeBearerRequest(paired.access_token), + ); + expect(pairedSession.subject).toBe("one-time-token"); + expect(active.map((entry) => entry.sessionId)).toContain(pairedSession.sessionId); + }).pipe( + Effect.provide( + makeEnvironmentAuthLayer({ + desktopBootstrapToken: "desktop-bootstrap-token", + }), + ), + ), + ); + it.effect("keeps user-issued administrative pairing links manageable", () => Effect.gen(function* () { const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index 2d0f02274de9..b0406b6e6ecd 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -750,6 +750,9 @@ export const make = Effect.gen(function* () { ttl: Duration.hours(1), } : {}), + // Desktop restarts forget the previous bearer token. Replace + // its session, including stale entries left by older versions. + replaceActiveForSubjectAndMethod: grant.method === "desktop-bootstrap", client: { ...requestMetadata, ...(grant.label ? { label: grant.label } : {}), diff --git a/apps/server/src/auth/SessionStore.test.ts b/apps/server/src/auth/SessionStore.test.ts index aa3b2d199148..fa87c5ce4e84 100644 --- a/apps/server/src/auth/SessionStore.test.ts +++ b/apps/server/src/auth/SessionStore.test.ts @@ -4,6 +4,7 @@ import { expect, it } from "@effect/vitest"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as TestClock from "effect/testing/TestClock"; import * as SqlClient from "effect/unstable/sql/SqlClient"; @@ -50,6 +51,7 @@ const repositoryFailure = new PersistenceSqlError({ const failingSessionLookupRepositoryLayer = Layer.succeed(AuthSessions.AuthSessionRepository, { create: () => Effect.void, + createReplacingActive: () => Effect.succeed([]), getById: () => Effect.fail(repositoryFailure), listActive: () => Effect.succeed([]), revoke: () => Effect.fail(repositoryFailure), @@ -181,6 +183,78 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { }).pipe(Effect.provide(Layer.merge(makeSessionStoreLayer(), TestClock.layer()))), ); + it.effect("atomically replaces active sessions with the same subject and method", () => + Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + const browser = yield* sessions.issue({ + subject: "desktop-bootstrap", + method: "browser-session-cookie", + }); + const [firstBearer, secondBearer] = yield* Effect.all( + [ + sessions.issue({ + subject: "desktop-bootstrap", + method: "bearer-access-token", + replaceActiveForSubjectAndMethod: true, + }), + sessions.issue({ + subject: "desktop-bootstrap", + method: "bearer-access-token", + replaceActiveForSubjectAndMethod: true, + }), + ], + { concurrency: "unbounded" }, + ); + + const active = yield* sessions.listActive(); + const bearerVerification = yield* Effect.all([ + sessions.verify(firstBearer.token).pipe(Effect.option), + sessions.verify(secondBearer.token).pipe(Effect.option), + ]); + + expect(active).toHaveLength(2); + expect(active.find((entry) => entry.sessionId === browser.sessionId)).toBeDefined(); + expect( + active.filter( + (entry) => + entry.subject === "desktop-bootstrap" && entry.method === "bearer-access-token", + ), + ).toHaveLength(1); + expect(bearerVerification.filter(Option.isSome)).toHaveLength(1); + }).pipe(Effect.provide(makeSessionStoreLayer())), + ); + + it.effect("keeps the previous desktop session valid when replacement fails", () => + Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + const sql = yield* SqlClient.SqlClient; + const previous = yield* sessions.issue({ + subject: "desktop-bootstrap", + method: "bearer-access-token", + }); + yield* sql` + CREATE TRIGGER reject_auth_session_insert BEFORE INSERT ON auth_sessions + BEGIN + SELECT RAISE(ABORT, 'simulated insert failure'); + END + `; + + const error = yield* sessions + .issue({ + subject: "desktop-bootstrap", + method: "bearer-access-token", + replaceActiveForSubjectAndMethod: true, + }) + .pipe(Effect.flip); + + expect(error._tag).toBe("SessionCredentialIssueError"); + expect((yield* sessions.verify(previous.token)).sessionId).toBe(previous.sessionId); + expect((yield* sessions.listActive()).map((session) => session.sessionId)).toEqual([ + previous.sessionId, + ]); + }).pipe(Effect.provide(Layer.mergeAll(makeSessionStoreLayer(), SqlitePersistenceMemory))), + ); + it.effect("rejects websocket tokens once the parent session has expired", () => Effect.gen(function* () { const sessions = yield* SessionStore.SessionStore; diff --git a/apps/server/src/auth/SessionStore.ts b/apps/server/src/auth/SessionStore.ts index d4fbe445edf6..b315bdf87c7f 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -370,6 +370,11 @@ export class SessionStore extends Context.Service< readonly scopes?: ReadonlyArray; readonly client?: AuthClientMetadata; readonly proofKeyThumbprint?: string; + /** + * Atomically revoke active sessions with the same subject and method + * before storing this session. + */ + readonly replaceActiveForSubjectAndMethod?: boolean; }) => Effect.Effect; readonly verify: (token: string) => Effect.Effect; readonly issueWebSocketToken: ( @@ -647,24 +652,40 @@ export const make = Effect.gen(function* () { ); const signature = signPayload(encodedPayload, signingSecret); const client = input?.client ?? createDefaultClientMetadata(); - yield* authSessions - .create({ - sessionId, - subject: claims.sub, - scopes: claims.scopes, - method: claims.method, - client: { - label: client.label ?? null, - ipAddress: client.ipAddress ?? null, - userAgent: client.userAgent ?? null, - deviceType: client.deviceType, - os: client.os ?? null, - browser: client.browser ?? null, - }, - issuedAt, - expiresAt, - }) - .pipe(Effect.mapError((cause) => new SessionCredentialIssueError({ sessionId, cause }))); + const sessionRecord = { + sessionId, + subject: claims.sub, + scopes: claims.scopes, + method: claims.method, + client: { + label: client.label ?? null, + ipAddress: client.ipAddress ?? null, + userAgent: client.userAgent ?? null, + deviceType: client.deviceType, + os: client.os ?? null, + browser: client.browser ?? null, + }, + issuedAt, + expiresAt, + } satisfies AuthSessions.CreateAuthSessionInput; + const replacedSessionIds = yield* ( + input?.replaceActiveForSubjectAndMethod + ? authSessions.createReplacingActive({ session: sessionRecord, revokedAt: issuedAt }) + : authSessions.create(sessionRecord).pipe(Effect.as([] as ReadonlyArray)) + ).pipe(Effect.mapError((cause) => new SessionCredentialIssueError({ sessionId, cause }))); + if (replacedSessionIds.length > 0) { + yield* Ref.update(connectedSessionsRef, (current) => { + const next = new Map(current); + for (const replacedSessionId of replacedSessionIds) { + next.delete(replacedSessionId); + } + return next; + }); + yield* Effect.forEach(replacedSessionIds, emitRemoved, { + concurrency: "unbounded", + discard: true, + }); + } yield* emitUpsert( toAuthClientSession({ sessionId, diff --git a/apps/server/src/persistence/AuthSessions.ts b/apps/server/src/persistence/AuthSessions.ts index 579d3a608190..b47f148f0761 100644 --- a/apps/server/src/persistence/AuthSessions.ts +++ b/apps/server/src/persistence/AuthSessions.ts @@ -55,6 +55,13 @@ export const CreateAuthSessionInput = Schema.Struct({ }); export type CreateAuthSessionInput = typeof CreateAuthSessionInput.Type; +export const CreateReplacingActiveAuthSessionInput = Schema.Struct({ + session: CreateAuthSessionInput, + revokedAt: Schema.DateTimeUtcFromString, +}); +export type CreateReplacingActiveAuthSessionInput = + typeof CreateReplacingActiveAuthSessionInput.Type; + export const GetAuthSessionByIdInput = Schema.Struct({ sessionId: AuthSessionId, }); @@ -96,6 +103,9 @@ export class AuthSessionRepository extends Context.Service< readonly create: ( input: CreateAuthSessionInput, ) => Effect.Effect; + readonly createReplacingActive: ( + input: CreateReplacingActiveAuthSessionInput, + ) => Effect.Effect, AuthSessionRepositoryError>; readonly getById: ( input: GetAuthSessionByIdInput, ) => Effect.Effect, AuthSessionRepositoryError>; @@ -254,6 +264,21 @@ export const make = Effect.gen(function* () { `, }); + const revokeActiveSessionsForReplacement = SqlSchema.findAll({ + Request: CreateReplacingActiveAuthSessionInput, + Result: Schema.Struct({ sessionId: AuthSessionId }), + execute: ({ session, revokedAt }) => + sql` + UPDATE auth_sessions + SET revoked_at = ${revokedAt} + WHERE subject = ${session.subject} + AND method = ${session.method} + AND revoked_at IS NULL + AND expires_at > ${revokedAt} + RETURNING session_id AS "sessionId" + `, + }); + const listActiveSessionRows = SqlSchema.findAll({ Request: ListActiveAuthSessionsInput, Result: AuthSessionRawDbRow, @@ -343,6 +368,29 @@ export const make = Effect.gen(function* () { ), ); + const createReplacingActive: AuthSessionRepository["Service"]["createReplacingActive"] = ( + input, + ) => + sql + .withTransaction( + revokeActiveSessionsForReplacement(input).pipe( + Effect.flatMap((revokedRows) => + createSessionRow(input.session).pipe( + Effect.as(revokedRows.map((row) => row.sessionId)), + ), + ), + ), + ) + .pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthSessionRepository.createReplacingActive:query", + "AuthSessionRepository.createReplacingActive:encodeRequest", + { sessionId: input.session.sessionId }, + ), + ), + ); + const getById: AuthSessionRepository["Service"]["getById"] = (input) => getSessionRowById(input).pipe( Effect.mapError( @@ -442,6 +490,7 @@ export const make = Effect.gen(function* () { return { create, + createReplacingActive, getById, listActive, revoke, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 85afe00cb52a..07aee5e861d3 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -1905,6 +1905,38 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("replaces the local desktop credential on repeated bootstrap exchanges", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + const first = yield* exchangeAccessToken(); + const second = yield* exchangeAccessToken(); + const third = yield* exchangeAccessToken(); + assert.equal(first.response.status, 200); + assert.equal(second.response.status, 200); + assert.equal(third.response.status, 200); + + const clientsResponse = yield* HttpClient.get("/api/auth/clients", { + headers: { authorization: `Bearer ${third.body.access_token}` }, + }); + const clients = (yield* clientsResponse.json) as ReadonlyArray<{ + readonly current: boolean; + readonly subject: string; + }>; + assert.equal(clientsResponse.status, 200); + assert.equal(clients.length, 1); + assert.equal(clients[0]?.current, true); + assert.equal(clients[0]?.subject, "desktop-bootstrap"); + + for (const previous of [first, second]) { + const response = yield* HttpClient.get("/api/auth/session", { + headers: { authorization: `Bearer ${previous.body.access_token}` }, + }); + const state = (yield* response.json) as { readonly authenticated: boolean }; + assert.equal(state.authenticated, false); + } + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("persists token exchange client display metadata for authorized-client listings", () => Effect.gen(function* () { yield* buildAppUnderTest({ diff --git a/docs/internals/environment-auth.md b/docs/internals/environment-auth.md index 98b3df0a0dc8..068fcfa68a90 100644 --- a/docs/internals/environment-auth.md +++ b/docs/internals/environment-auth.md @@ -121,6 +121,12 @@ Sessions issued from a plain bearer exchange use the store's only to DPoP-bound exchanges, where the token is additionally constrained by a proof key. See `SessionStore.ts` and `EnvironmentAuth.ts`. +The reusable `desktop-bootstrap` grant replaces active sessions with the same +subject and authentication method. Revocation and insertion share one database +transaction, so a failed insertion preserves the previous credential. This also +removes stale local desktop entries from earlier launches. Browser-cookie sessions +and sessions issued through pairing links are not replaced. + Requested scopes must be a subset of the one-time bootstrap credential grant. An ordinary paired client therefore cannot exchange its grant for `access:read`, `access:write`, or `relay:write`. diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 50a07b50fd2b..b2b540a83e2c 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -80,6 +80,10 @@ and expiry, and can revoke it if they have access management permission. The default endpoint controls the QR code and primary copy action for pairing links. You can change it from the expanded endpoint list. The preference is stored by endpoint type, so choosing the local LAN endpoint survives normal IP address changes when you move between networks. +After an app restart, the desktop app replaces its previous +local credential. Old local desktop entries are removed from **Authorized clients** +automatically. Paired phones, browsers, and remote desktop clients keep their access. + When no user default is saved, the app uses the built-in LAN endpoint for pairing links when available. You can set another endpoint as the default from the expanded endpoint list.