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.
Unified · Split
| @@ -52,6 +52,18 @@ const makeCookieRequest = ( · 51 unmodified lines | |||
| 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) => { · 101 unmodified lines | |||
| 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; |
| @@ -750,6 +750,9 @@ export const make = Effect.gen(function* () { · 749 unmodified lines | |||
| 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 } : {}), |
| @@ -4,6 +4,7 @@ import { expect, it } from "@effect/vitest"; · 3 unmodified lines | |||
| 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({ · 40 unmodified lines | |||
| 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) => { · 125 unmodified lines | |||
| 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; |
| @@ -370,6 +370,11 @@ export class SessionStore extends Context.Service< · 369 unmodified lines | |||
| 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* () { · 271 unmodified lines | |||
| 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, |
| @@ -55,6 +55,13 @@ export const CreateAuthSessionInput = Schema.Struct({ · 54 unmodified lines | |||
| 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< · 35 unmodified lines | |||
| 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* () { · 152 unmodified lines | |||
| 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* () { · 83 unmodified lines | |||
| 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* () { · 93 unmodified lines | |||
| 442 | 490 | ||
| 443 | return { | 491 | return { |
| 444 | create, | 492 | create, |
| 493 | createReplacingActive, | ||
| 445 | getById, | 494 | getById, |
| 446 | listActive, | 495 | listActive, |
| 447 | revoke, | 496 | revoke, |
| @@ -1905,6 +1905,38 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 1904 unmodified lines | |||
| 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({ |
| @@ -121,6 +121,12 @@ Sessions issued from a plain bearer exchange use the store's · 120 unmodified lines | |||
| 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`. |
| @@ -80,6 +80,10 @@ and expiry, and can revoke it if they have access management permission. · 79 unmodified lines | |||
| 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 | ||