diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index 6e5f22fa3af6c4d511de3a3731b1e05c9678124d..028fe53e01913fb05a42ff2a3e368abe6320c473 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 2d0f02274de987ebc3326f0190b11784b3f72c9a..b0406b6e6ecd6815b9c5dd49417ffef424e7ba59 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 aa3b2d199148e278174f5efbdb8fd0b867e5e81e..fa87c5ce4e840f521e7c746dae28f3f605aad9ee 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 d4fbe445edf6b3406e693caba40c9e09094f4bbb..b315bdf87c7f6db562ce02b98e20d1d211c1d41a 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 579d3a608190af34e9203710f397940111fc04f2..b47f148f0761f0d8bbf9a1c7928daca1a9f3cefc 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 85afe00cb52aaee6838161671dc0b5e144333641..07aee5e861d30adfdbfe4ee038dbffa0b89c8ada 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 98b3df0a0dc8d1d59764036d81078c8751aaaae7..068fcfa68a9081b0a2ecf3d91da4c36ee2b559e4 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 50a07b50fd2bc06ce52bce00a9427d627428183e..b2b540a83e2c11f233a74ad7adeb45290785ccdc 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.