| author | |
| committer | |
| log | eb77683e5544e071db74831bae052bbd8a7d5f88 |
| tree | 0d3ff9f6576423328b875c2fe3df870eedb3bc1a |
| parent | 14bf3f6d1644a37029be58429e8f0138e1ceb743 |
| signature | Signed by PGP key B5690EEEBB952194 |
Replace stale local desktop sessions in one transaction. Preserve paired clients and browser sessions, and keep the previous credential valid if replacement fails.
Closes https://github.com/pingdotgg/t3code/issues/6283.
Original implementation by seeb1337. Reviewed and verified with GPT-6 Astra (preview) in Codex.
Co-authored-by: seeb1337 <63622047+seeb1337@users.noreply.github.com>
Co-authored-by: Theo Browne <me@t3.gg>8 files changed, 284 insertions(+), 18 deletions(-)
apps/server/src/auth/EnvironmentAuth.test.ts+77| ... | @@ -52,6 +52,18 @@ const makeCookieRequest = ( | ... | @@ -52,6 +52,18 @@ const makeCookieRequest = ( |
| 52 | EnvironmentAuth.EnvironmentAuth["Service"]["authenticateHttpRequest"] | 52 | EnvironmentAuth.EnvironmentAuth["Service"]["authenticateHttpRequest"] |
| 53 | >[0]; | 53 | >[0]; |
| 54 | 54 | ||
| 55 | const makeBearerRequest = ( | ||
| 56 | token: string, | ||
| 57 | ): Parameters<EnvironmentAuth.EnvironmentAuth["Service"]["authenticateHttpRequest"]>[0] => | ||
| 58 | ({ | ||
| 59 | cookies: {}, | ||
| 60 | headers: { | ||
| 61 | authorization: `Bearer ${token}`, | ||
| 62 | }, | ||
| 63 | }) as unknown as Parameters< | ||
| 64 | EnvironmentAuth.EnvironmentAuth["Service"]["authenticateHttpRequest"] | ||
| 65 | >[0]; | ||
| 66 | |||
| 55 | const requestMetadata = { | 67 | const requestMetadata = { |
| 56 | deviceType: "desktop" as const, | 68 | deviceType: "desktop" as const, |
| 57 | os: "macOS", | 69 | os: "macOS", |
| ... | @@ -159,6 +171,71 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { | ... | @@ -159,6 +171,71 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { |
| 159 | }).pipe(Effect.provide(makeEnvironmentAuthLayer())), | 171 | }).pipe(Effect.provide(makeEnvironmentAuthLayer())), |
| 160 | ); | 172 | ); |
| 161 | 173 | ||
| 174 | it.effect("rotates desktop bearer sessions without accumulating authorized clients", () => | ||
| 175 | Effect.gen(function* () { | ||
| 176 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; | ||
| 177 | const sessions = yield* SessionStore.SessionStore; | ||
| 178 | const browser = yield* serverAuth.createBrowserSession( | ||
| 179 | "desktop-bootstrap-token", | ||
| 180 | requestMetadata, | ||
| 181 | ); | ||
| 182 | const browserSession = yield* serverAuth.authenticateHttpRequest( | ||
| 183 | makeCookieRequest(sessions.cookieName, browser.sessionToken), | ||
| 184 | ); | ||
| 185 | const staleSessions = yield* Effect.forEach([1, 2, 3], () => | ||
| 186 | sessions.issue({ subject: "desktop-bootstrap", method: "bearer-access-token" }), | ||
| 187 | ); | ||
| 188 | const pairing = yield* serverAuth.issuePairingCredential(); | ||
| 189 | const paired = yield* serverAuth.exchangeBootstrapCredentialForAccessToken( | ||
| 190 | pairing.credential, | ||
| 191 | undefined, | ||
| 192 | { ...requestMetadata, label: "T3 Code Desktop" }, | ||
| 193 | ); | ||
| 194 | const first = yield* serverAuth.exchangeBootstrapCredentialForAccessToken( | ||
| 195 | "desktop-bootstrap-token", | ||
| 196 | undefined, | ||
| 197 | requestMetadata, | ||
| 198 | ); | ||
| 199 | const firstSession = yield* serverAuth.authenticateHttpRequest( | ||
| 200 | makeBearerRequest(first.access_token), | ||
| 201 | ); | ||
| 202 | const second = yield* serverAuth.exchangeBootstrapCredentialForAccessToken( | ||
| 203 | "desktop-bootstrap-token", | ||
| 204 | undefined, | ||
| 205 | requestMetadata, | ||
| 206 | ); | ||
| 207 | |||
| 208 | const active = yield* serverAuth.listSessions(); | ||
| 209 | const firstError = yield* serverAuth | ||
| 210 | .authenticateHttpRequest(makeBearerRequest(first.access_token)) | ||
| 211 | .pipe(Effect.flip); | ||
| 212 | const secondSession = yield* serverAuth.authenticateHttpRequest( | ||
| 213 | makeBearerRequest(second.access_token), | ||
| 214 | ); | ||
| 215 | |||
| 216 | expect(active).toHaveLength(3); | ||
| 217 | expect(active.map((entry) => entry.sessionId)).toContain(browserSession.sessionId); | ||
| 218 | expect(active.map((entry) => entry.sessionId)).toContain(secondSession.sessionId); | ||
| 219 | expect(active.map((entry) => entry.sessionId)).not.toContain(firstSession.sessionId); | ||
| 220 | expect(firstError._tag).toBe("ServerAuthInvalidCredentialError"); | ||
| 221 | for (const stale of staleSessions) { | ||
| 222 | const error = yield* sessions.verify(stale.token).pipe(Effect.flip); | ||
| 223 | expect(error._tag).toBe("SessionTokenRevokedError"); | ||
| 224 | } | ||
| 225 | const pairedSession = yield* serverAuth.authenticateHttpRequest( | ||
| 226 | makeBearerRequest(paired.access_token), | ||
| 227 | ); | ||
| 228 | expect(pairedSession.subject).toBe("one-time-token"); | ||
| 229 | expect(active.map((entry) => entry.sessionId)).toContain(pairedSession.sessionId); | ||
| 230 | }).pipe( | ||
| 231 | Effect.provide( | ||
| 232 | makeEnvironmentAuthLayer({ | ||
| 233 | desktopBootstrapToken: "desktop-bootstrap-token", | ||
| 234 | }), | ||
| 235 | ), | ||
| 236 | ), | ||
| 237 | ); | ||
| 238 | |||
| 162 | it.effect("keeps user-issued administrative pairing links manageable", () => | 239 | it.effect("keeps user-issued administrative pairing links manageable", () => |
| 163 | Effect.gen(function* () { | 240 | Effect.gen(function* () { |
| 164 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; | 241 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; |
apps/server/src/auth/EnvironmentAuth.ts+3| ... | @@ -750,6 +750,9 @@ export const make = Effect.gen(function* () { | ... | @@ -750,6 +750,9 @@ export const make = Effect.gen(function* () { |
| 750 | ttl: Duration.hours(1), | 750 | ttl: Duration.hours(1), |
| 751 | } | 751 | } |
| 752 | : {}), | 752 | : {}), |
| 753 | // Desktop restarts forget the previous bearer token. Replace | ||
| 754 | // its session, including stale entries left by older versions. | ||
| 755 | replaceActiveForSubjectAndMethod: grant.method === "desktop-bootstrap", | ||
| 753 | client: { | 756 | client: { |
| 754 | ...requestMetadata, | 757 | ...requestMetadata, |
| 755 | ...(grant.label ? { label: grant.label } : {}), | 758 | ...(grant.label ? { label: grant.label } : {}), |
apps/server/src/auth/SessionStore.test.ts+74| ... | @@ -4,6 +4,7 @@ import { expect, it } from "@effect/vitest"; | ... | @@ -4,6 +4,7 @@ import { expect, it } from "@effect/vitest"; |
| 4 | import * as Duration from "effect/Duration"; | 4 | import * as Duration from "effect/Duration"; |
| 5 | import * as Effect from "effect/Effect"; | 5 | import * as Effect from "effect/Effect"; |
| 6 | import * as Layer from "effect/Layer"; | 6 | import * as Layer from "effect/Layer"; |
| 7 | import * as Option from "effect/Option"; | ||
| 7 | import * as TestClock from "effect/testing/TestClock"; | 8 | import * as TestClock from "effect/testing/TestClock"; |
| 8 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | 9 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 9 | 10 | ||
| ... | @@ -50,6 +51,7 @@ const repositoryFailure = new PersistenceSqlError({ | ... | @@ -50,6 +51,7 @@ const repositoryFailure = new PersistenceSqlError({ |
| 50 | 51 | ||
| 51 | const failingSessionLookupRepositoryLayer = Layer.succeed(AuthSessions.AuthSessionRepository, { | 52 | const failingSessionLookupRepositoryLayer = Layer.succeed(AuthSessions.AuthSessionRepository, { |
| 52 | create: () => Effect.void, | 53 | create: () => Effect.void, |
| 54 | createReplacingActive: () => Effect.succeed([]), | ||
| 53 | getById: () => Effect.fail(repositoryFailure), | 55 | getById: () => Effect.fail(repositoryFailure), |
| 54 | listActive: () => Effect.succeed([]), | 56 | listActive: () => Effect.succeed([]), |
| 55 | revoke: () => Effect.fail(repositoryFailure), | 57 | revoke: () => Effect.fail(repositoryFailure), |
| ... | @@ -181,6 +183,78 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { | ... | @@ -181,6 +183,78 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { |
| 181 | }).pipe(Effect.provide(Layer.merge(makeSessionStoreLayer(), TestClock.layer()))), | 183 | }).pipe(Effect.provide(Layer.merge(makeSessionStoreLayer(), TestClock.layer()))), |
| 182 | ); | 184 | ); |
| 183 | 185 | ||
| 186 | it.effect("atomically replaces active sessions with the same subject and method", () => | ||
| 187 | Effect.gen(function* () { | ||
| 188 | const sessions = yield* SessionStore.SessionStore; | ||
| 189 | const browser = yield* sessions.issue({ | ||
| 190 | subject: "desktop-bootstrap", | ||
| 191 | method: "browser-session-cookie", | ||
| 192 | }); | ||
| 193 | const [firstBearer, secondBearer] = yield* Effect.all( | ||
| 194 | [ | ||
| 195 | sessions.issue({ | ||
| 196 | subject: "desktop-bootstrap", | ||
| 197 | method: "bearer-access-token", | ||
| 198 | replaceActiveForSubjectAndMethod: true, | ||
| 199 | }), | ||
| 200 | sessions.issue({ | ||
| 201 | subject: "desktop-bootstrap", | ||
| 202 | method: "bearer-access-token", | ||
| 203 | replaceActiveForSubjectAndMethod: true, | ||
| 204 | }), | ||
| 205 | ], | ||
| 206 | { concurrency: "unbounded" }, | ||
| 207 | ); | ||
| 208 | |||
| 209 | const active = yield* sessions.listActive(); | ||
| 210 | const bearerVerification = yield* Effect.all([ | ||
| 211 | sessions.verify(firstBearer.token).pipe(Effect.option), | ||
| 212 | sessions.verify(secondBearer.token).pipe(Effect.option), | ||
| 213 | ]); | ||
| 214 | |||
| 215 | expect(active).toHaveLength(2); | ||
| 216 | expect(active.find((entry) => entry.sessionId === browser.sessionId)).toBeDefined(); | ||
| 217 | expect( | ||
| 218 | active.filter( | ||
| 219 | (entry) => | ||
| 220 | entry.subject === "desktop-bootstrap" && entry.method === "bearer-access-token", | ||
| 221 | ), | ||
| 222 | ).toHaveLength(1); | ||
| 223 | expect(bearerVerification.filter(Option.isSome)).toHaveLength(1); | ||
| 224 | }).pipe(Effect.provide(makeSessionStoreLayer())), | ||
| 225 | ); | ||
| 226 | |||
| 227 | it.effect("keeps the previous desktop session valid when replacement fails", () => | ||
| 228 | Effect.gen(function* () { | ||
| 229 | const sessions = yield* SessionStore.SessionStore; | ||
| 230 | const sql = yield* SqlClient.SqlClient; | ||
| 231 | const previous = yield* sessions.issue({ | ||
| 232 | subject: "desktop-bootstrap", | ||
| 233 | method: "bearer-access-token", | ||
| 234 | }); | ||
| 235 | yield* sql` | ||
| 236 | CREATE TRIGGER reject_auth_session_insert BEFORE INSERT ON auth_sessions | ||
| 237 | BEGIN | ||
| 238 | SELECT RAISE(ABORT, 'simulated insert failure'); | ||
| 239 | END | ||
| 240 | `; | ||
| 241 | |||
| 242 | const error = yield* sessions | ||
| 243 | .issue({ | ||
| 244 | subject: "desktop-bootstrap", | ||
| 245 | method: "bearer-access-token", | ||
| 246 | replaceActiveForSubjectAndMethod: true, | ||
| 247 | }) | ||
| 248 | .pipe(Effect.flip); | ||
| 249 | |||
| 250 | expect(error._tag).toBe("SessionCredentialIssueError"); | ||
| 251 | expect((yield* sessions.verify(previous.token)).sessionId).toBe(previous.sessionId); | ||
| 252 | expect((yield* sessions.listActive()).map((session) => session.sessionId)).toEqual([ | ||
| 253 | previous.sessionId, | ||
| 254 | ]); | ||
| 255 | }).pipe(Effect.provide(Layer.mergeAll(makeSessionStoreLayer(), SqlitePersistenceMemory))), | ||
| 256 | ); | ||
| 257 | |||
| 184 | it.effect("rejects websocket tokens once the parent session has expired", () => | 258 | it.effect("rejects websocket tokens once the parent session has expired", () => |
| 185 | Effect.gen(function* () { | 259 | Effect.gen(function* () { |
| 186 | const sessions = yield* SessionStore.SessionStore; | 260 | const sessions = yield* SessionStore.SessionStore; |
apps/server/src/auth/SessionStore.ts+39-18| ... | @@ -370,6 +370,11 @@ export class SessionStore extends Context.Service< | ... | @@ -370,6 +370,11 @@ export class SessionStore extends Context.Service< |
| 370 | readonly scopes?: ReadonlyArray<AuthEnvironmentScope>; | 370 | readonly scopes?: ReadonlyArray<AuthEnvironmentScope>; |
| 371 | readonly client?: AuthClientMetadata; | 371 | readonly client?: AuthClientMetadata; |
| 372 | readonly proofKeyThumbprint?: string; | 372 | readonly proofKeyThumbprint?: string; |
| 373 | /** | ||
| 374 | * Atomically revoke active sessions with the same subject and method | ||
| 375 | * before storing this session. | ||
| 376 | */ | ||
| 377 | readonly replaceActiveForSubjectAndMethod?: boolean; | ||
| 373 | }) => Effect.Effect<IssuedSession, SessionCredentialInternalError>; | 378 | }) => Effect.Effect<IssuedSession, SessionCredentialInternalError>; |
| 374 | readonly verify: (token: string) => Effect.Effect<VerifiedSession, SessionCredentialError>; | 379 | readonly verify: (token: string) => Effect.Effect<VerifiedSession, SessionCredentialError>; |
| 375 | readonly issueWebSocketToken: ( | 380 | readonly issueWebSocketToken: ( |
| ... | @@ -647,24 +652,40 @@ export const make = Effect.gen(function* () { | ... | @@ -647,24 +652,40 @@ export const make = Effect.gen(function* () { |
| 647 | ); | 652 | ); |
| 648 | const signature = signPayload(encodedPayload, signingSecret); | 653 | const signature = signPayload(encodedPayload, signingSecret); |
| 649 | const client = input?.client ?? createDefaultClientMetadata(); | 654 | const client = input?.client ?? createDefaultClientMetadata(); |
| 650 | yield* authSessions | 655 | const sessionRecord = { |
| 651 | .create({ | 656 | sessionId, |
| 652 | sessionId, | 657 | subject: claims.sub, |
| 653 | subject: claims.sub, | 658 | scopes: claims.scopes, |
| 654 | scopes: claims.scopes, | 659 | method: claims.method, |
| 655 | method: claims.method, | 660 | client: { |
| 656 | client: { | 661 | label: client.label ?? null, |
| 657 | label: client.label ?? null, | 662 | ipAddress: client.ipAddress ?? null, |
| 658 | ipAddress: client.ipAddress ?? null, | 663 | userAgent: client.userAgent ?? null, |
| 659 | userAgent: client.userAgent ?? null, | 664 | deviceType: client.deviceType, |
| 660 | deviceType: client.deviceType, | 665 | os: client.os ?? null, |
| 661 | os: client.os ?? null, | 666 | browser: client.browser ?? null, |
| 662 | browser: client.browser ?? null, | 667 | }, |
| 663 | }, | 668 | issuedAt, |
| 664 | issuedAt, | 669 | expiresAt, |
| 665 | expiresAt, | 670 | } satisfies AuthSessions.CreateAuthSessionInput; |
| 666 | }) | 671 | const replacedSessionIds = yield* ( |
| 667 | .pipe(Effect.mapError((cause) => new SessionCredentialIssueError({ sessionId, cause }))); | 672 | input?.replaceActiveForSubjectAndMethod |
| 673 | ? authSessions.createReplacingActive({ session: sessionRecord, revokedAt: issuedAt }) | ||
| 674 | : authSessions.create(sessionRecord).pipe(Effect.as([] as ReadonlyArray<AuthSessionId>)) | ||
| 675 | ).pipe(Effect.mapError((cause) => new SessionCredentialIssueError({ sessionId, cause }))); | ||
| 676 | if (replacedSessionIds.length > 0) { | ||
| 677 | yield* Ref.update(connectedSessionsRef, (current) => { | ||
| 678 | const next = new Map(current); | ||
| 679 | for (const replacedSessionId of replacedSessionIds) { | ||
| 680 | next.delete(replacedSessionId); | ||
| 681 | } | ||
| 682 | return next; | ||
| 683 | }); | ||
| 684 | yield* Effect.forEach(replacedSessionIds, emitRemoved, { | ||
| 685 | concurrency: "unbounded", | ||
| 686 | discard: true, | ||
| 687 | }); | ||
| 688 | } | ||
| 668 | yield* emitUpsert( | 689 | yield* emitUpsert( |
| 669 | toAuthClientSession({ | 690 | toAuthClientSession({ |
| 670 | sessionId, | 691 | sessionId, |
apps/server/src/persistence/AuthSessions.ts+49| ... | @@ -55,6 +55,13 @@ export const CreateAuthSessionInput = Schema.Struct({ | ... | @@ -55,6 +55,13 @@ export const CreateAuthSessionInput = Schema.Struct({ |
| 55 | }); | 55 | }); |
| 56 | export type CreateAuthSessionInput = typeof CreateAuthSessionInput.Type; | 56 | export type CreateAuthSessionInput = typeof CreateAuthSessionInput.Type; |
| 57 | 57 | ||
| 58 | export const CreateReplacingActiveAuthSessionInput = Schema.Struct({ | ||
| 59 | session: CreateAuthSessionInput, | ||
| 60 | revokedAt: Schema.DateTimeUtcFromString, | ||
| 61 | }); | ||
| 62 | export type CreateReplacingActiveAuthSessionInput = | ||
| 63 | typeof CreateReplacingActiveAuthSessionInput.Type; | ||
| 64 | |||
| 58 | export const GetAuthSessionByIdInput = Schema.Struct({ | 65 | export const GetAuthSessionByIdInput = Schema.Struct({ |
| 59 | sessionId: AuthSessionId, | 66 | sessionId: AuthSessionId, |
| 60 | }); | 67 | }); |
| ... | @@ -96,6 +103,9 @@ export class AuthSessionRepository extends Context.Service< | ... | @@ -96,6 +103,9 @@ export class AuthSessionRepository extends Context.Service< |
| 96 | readonly create: ( | 103 | readonly create: ( |
| 97 | input: CreateAuthSessionInput, | 104 | input: CreateAuthSessionInput, |
| 98 | ) => Effect.Effect<void, AuthSessionRepositoryError>; | 105 | ) => Effect.Effect<void, AuthSessionRepositoryError>; |
| 106 | readonly createReplacingActive: ( | ||
| 107 | input: CreateReplacingActiveAuthSessionInput, | ||
| 108 | ) => Effect.Effect<ReadonlyArray<AuthSessionId>, AuthSessionRepositoryError>; | ||
| 99 | readonly getById: ( | 109 | readonly getById: ( |
| 100 | input: GetAuthSessionByIdInput, | 110 | input: GetAuthSessionByIdInput, |
| 101 | ) => Effect.Effect<Option.Option<AuthSessionRecord>, AuthSessionRepositoryError>; | 111 | ) => Effect.Effect<Option.Option<AuthSessionRecord>, AuthSessionRepositoryError>; |
| ... | @@ -254,6 +264,21 @@ export const make = Effect.gen(function* () { | ... | @@ -254,6 +264,21 @@ export const make = Effect.gen(function* () { |
| 254 | `, | 264 | `, |
| 255 | }); | 265 | }); |
| 256 | 266 | ||
| 267 | const revokeActiveSessionsForReplacement = SqlSchema.findAll({ | ||
| 268 | Request: CreateReplacingActiveAuthSessionInput, | ||
| 269 | Result: Schema.Struct({ sessionId: AuthSessionId }), | ||
| 270 | execute: ({ session, revokedAt }) => | ||
| 271 | sql` | ||
| 272 | UPDATE auth_sessions | ||
| 273 | SET revoked_at = ${revokedAt} | ||
| 274 | WHERE subject = ${session.subject} | ||
| 275 | AND method = ${session.method} | ||
| 276 | AND revoked_at IS NULL | ||
| 277 | AND expires_at > ${revokedAt} | ||
| 278 | RETURNING session_id AS "sessionId" | ||
| 279 | `, | ||
| 280 | }); | ||
| 281 | |||
| 257 | const listActiveSessionRows = SqlSchema.findAll({ | 282 | const listActiveSessionRows = SqlSchema.findAll({ |
| 258 | Request: ListActiveAuthSessionsInput, | 283 | Request: ListActiveAuthSessionsInput, |
| 259 | Result: AuthSessionRawDbRow, | 284 | Result: AuthSessionRawDbRow, |
| ... | @@ -343,6 +368,29 @@ export const make = Effect.gen(function* () { | ... | @@ -343,6 +368,29 @@ export const make = Effect.gen(function* () { |
| 343 | ), | 368 | ), |
| 344 | ); | 369 | ); |
| 345 | 370 | ||
| 371 | const createReplacingActive: AuthSessionRepository["Service"]["createReplacingActive"] = ( | ||
| 372 | input, | ||
| 373 | ) => | ||
| 374 | sql | ||
| 375 | .withTransaction( | ||
| 376 | revokeActiveSessionsForReplacement(input).pipe( | ||
| 377 | Effect.flatMap((revokedRows) => | ||
| 378 | createSessionRow(input.session).pipe( | ||
| 379 | Effect.as(revokedRows.map((row) => row.sessionId)), | ||
| 380 | ), | ||
| 381 | ), | ||
| 382 | ), | ||
| 383 | ) | ||
| 384 | .pipe( | ||
| 385 | Effect.mapError( | ||
| 386 | toPersistenceSqlOrDecodeError( | ||
| 387 | "AuthSessionRepository.createReplacingActive:query", | ||
| 388 | "AuthSessionRepository.createReplacingActive:encodeRequest", | ||
| 389 | { sessionId: input.session.sessionId }, | ||
| 390 | ), | ||
| 391 | ), | ||
| 392 | ); | ||
| 393 | |||
| 346 | const getById: AuthSessionRepository["Service"]["getById"] = (input) => | 394 | const getById: AuthSessionRepository["Service"]["getById"] = (input) => |
| 347 | getSessionRowById(input).pipe( | 395 | getSessionRowById(input).pipe( |
| 348 | Effect.mapError( | 396 | Effect.mapError( |
| ... | @@ -442,6 +490,7 @@ export const make = Effect.gen(function* () { | ... | @@ -442,6 +490,7 @@ export const make = Effect.gen(function* () { |
| 442 | 490 | ||
| 443 | return { | 491 | return { |
| 444 | create, | 492 | create, |
| 493 | createReplacingActive, | ||
| 445 | getById, | 494 | getById, |
| 446 | listActive, | 495 | listActive, |
| 447 | revoke, | 496 | revoke, |
apps/server/src/server.test.ts+32| ... | @@ -1905,6 +1905,38 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -1905,6 +1905,38 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1905 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 1905 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 1906 | ); | 1906 | ); |
| 1907 | 1907 | ||
| 1908 | it.effect("replaces the local desktop credential on repeated bootstrap exchanges", () => | ||
| 1909 | Effect.gen(function* () { | ||
| 1910 | yield* buildAppUnderTest(); | ||
| 1911 | const first = yield* exchangeAccessToken(); | ||
| 1912 | const second = yield* exchangeAccessToken(); | ||
| 1913 | const third = yield* exchangeAccessToken(); | ||
| 1914 | assert.equal(first.response.status, 200); | ||
| 1915 | assert.equal(second.response.status, 200); | ||
| 1916 | assert.equal(third.response.status, 200); | ||
| 1917 | |||
| 1918 | const clientsResponse = yield* HttpClient.get("/api/auth/clients", { | ||
| 1919 | headers: { authorization: `Bearer ${third.body.access_token}` }, | ||
| 1920 | }); | ||
| 1921 | const clients = (yield* clientsResponse.json) as ReadonlyArray<{ | ||
| 1922 | readonly current: boolean; | ||
| 1923 | readonly subject: string; | ||
| 1924 | }>; | ||
| 1925 | assert.equal(clientsResponse.status, 200); | ||
| 1926 | assert.equal(clients.length, 1); | ||
| 1927 | assert.equal(clients[0]?.current, true); | ||
| 1928 | assert.equal(clients[0]?.subject, "desktop-bootstrap"); | ||
| 1929 | |||
| 1930 | for (const previous of [first, second]) { | ||
| 1931 | const response = yield* HttpClient.get("/api/auth/session", { | ||
| 1932 | headers: { authorization: `Bearer ${previous.body.access_token}` }, | ||
| 1933 | }); | ||
| 1934 | const state = (yield* response.json) as { readonly authenticated: boolean }; | ||
| 1935 | assert.equal(state.authenticated, false); | ||
| 1936 | } | ||
| 1937 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 1938 | ); | ||
| 1939 | |||
| 1908 | it.effect("persists token exchange client display metadata for authorized-client listings", () => | 1940 | it.effect("persists token exchange client display metadata for authorized-client listings", () => |
| 1909 | Effect.gen(function* () { | 1941 | Effect.gen(function* () { |
| 1910 | yield* buildAppUnderTest({ | 1942 | yield* buildAppUnderTest({ |
docs/internals/environment-auth.md+6| ... | @@ -121,6 +121,12 @@ Sessions issued from a plain bearer exchange use the store's | ... | @@ -121,6 +121,12 @@ Sessions issued from a plain bearer exchange use the store's |
| 121 | only to DPoP-bound exchanges, where the token is additionally constrained by a | 121 | only to DPoP-bound exchanges, where the token is additionally constrained by a |
| 122 | proof key. See `SessionStore.ts` and `EnvironmentAuth.ts`. | 122 | proof key. See `SessionStore.ts` and `EnvironmentAuth.ts`. |
| 123 | 123 | ||
| 124 | The reusable `desktop-bootstrap` grant replaces active sessions with the same | ||
| 125 | subject and authentication method. Revocation and insertion share one database | ||
| 126 | transaction, so a failed insertion preserves the previous credential. This also | ||
| 127 | removes stale local desktop entries from earlier launches. Browser-cookie sessions | ||
| 128 | and sessions issued through pairing links are not replaced. | ||
| 129 | |||
| 124 | Requested scopes must be a subset of the one-time bootstrap credential grant. | 130 | Requested scopes must be a subset of the one-time bootstrap credential grant. |
| 125 | An ordinary paired client therefore cannot exchange its grant for | 131 | An ordinary paired client therefore cannot exchange its grant for |
| 126 | `access:read`, `access:write`, or `relay:write`. | 132 | `access:read`, `access:write`, or `relay:write`. |
docs/user/remote-access.md+4| ... | @@ -80,6 +80,10 @@ and expiry, and can revoke it if they have access management permission. | ... | @@ -80,6 +80,10 @@ and expiry, and can revoke it if they have access management permission. |
| 80 | 80 | ||
| 81 | 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. | 81 | 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. |
| 82 | 82 | ||
| 83 | After an app restart, the desktop app replaces its previous | ||
| 84 | local credential. Old local desktop entries are removed from **Authorized clients** | ||
| 85 | automatically. Paired phones, browsers, and remote desktop clients keep their access. | ||
| 86 | |||
| 83 | When no user default is saved, the app uses the built-in LAN endpoint for pairing links when | 87 | When no user default is saved, the app uses the built-in LAN endpoint for pairing links when |
| 84 | available. You can set another endpoint as the default from the expanded endpoint list. | 88 | available. You can set another endpoint as the default from the expanded endpoint list. |
| 85 | 89 |