From a1e291519bb112129d95730e26d234ead7ece548 Mon Sep 17 00:00:00 2001 From: Ken-ichi Ueda Date: Tue, 2 Jun 2026 17:48:42 -0700 Subject: [PATCH] fix: store rotated access and refresh tokens when resuming session resumeSession always forces a token refresh, and atproto refresh tokens are single-use: the server rotates the refresh token and invalidates the old one. We weren't persisting the rotated tokens, so the keychain kept the original refresh token. Once the server stopped accepting the consumed token, every command failed until re-authenticating This saves the agent's session after resumeSession so the rotated tokens are durably stored for the next invocation, and adds an integration test that drives the real CredentialSession through a stubbed fetch to prove the rotated refresh token reaches the keychain. Co-authored-by: Claude (claude-opus-4-8) --- src/lib/api-client.ts | 9 ++ tests/lib/api-client.test.ts | 18 +++- tests/lib/auth-token-rotation.test.ts | 121 ++++++++++++++++++++++++++ 3 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 tests/lib/auth-token-rotation.test.ts diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 7f45250..dadf424 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -105,6 +105,15 @@ export class TangledApiClient { this.agent = new AtpAgent({ service: metadata.pds }); await this.agent.resumeSession(sessionData); + // resumeSession always forces a token refresh, which rotates the + // single-use refresh token and replaces agent.session with the new + // tokens. Persist them so the next CLI invocation presents a valid refresh + // token instead of the consumed one (which would force a re-login). + const refreshed = this.agent.session; + if (refreshed) { + await saveSession(refreshed); + } + // Update last used timestamp await saveCurrentSessionMetadata({ ...metadata, diff --git a/tests/lib/api-client.test.ts b/tests/lib/api-client.test.ts index 02fe24e..06d8a0b 100644 --- a/tests/lib/api-client.test.ts +++ b/tests/lib/api-client.test.ts @@ -5,7 +5,7 @@ import { TangledApiClient } from '../../src/lib/api-client.js'; import * as sessionModule from '../../src/lib/session.js'; import { KeychainAccessError } from '../../src/lib/session.js'; import * as pdsResolver from '../../src/utils/pds-resolver.js'; -import { mockSessionData, mockSessionMetadata } from '../helpers/mock-data.js'; +import { mockSessionData, mockSessionData2, mockSessionMetadata } from '../helpers/mock-data.js'; // Singleton mock agent — always the same object regardless of how many times // new AtpAgent() is called. This keeps test references valid even when login() @@ -185,6 +185,22 @@ describe('TangledApiClient', () => { expect(vi.mocked(sessionModule.clearCurrentSessionMetadata)).toHaveBeenCalled(); }); + it('should durably re-save the session the agent holds after resuming', async () => { + vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(mockSessionMetadata); + vi.mocked(sessionModule.loadSession).mockResolvedValue(mockSessionData); + // Simulate the agent refreshing and rotating its tokens during resume: it + // ends up holding a different session than the stale one loaded from the + // keychain. The rotated session must be written back, awaited, so the next + // CLI invocation can refresh again instead of presenting a consumed token. + vi.mocked(mockAgent.resumeSession).mockImplementationOnce(async () => { + mockAgent._session = mockSessionData2; + }); + + await client.resumeSession(); + + expect(vi.mocked(sessionModule.saveSession)).toHaveBeenCalledWith(mockSessionData2); + }); + it('should return false without clearing metadata on transient resume error', async () => { vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(mockSessionMetadata); vi.mocked(sessionModule.loadSession).mockResolvedValue(mockSessionData); diff --git a/tests/lib/auth-token-rotation.test.ts b/tests/lib/auth-token-rotation.test.ts new file mode 100644 index 0000000..578a00c --- /dev/null +++ b/tests/lib/auth-token-rotation.test.ts @@ -0,0 +1,121 @@ +import type { AtpSessionData } from '@atproto/api'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { TangledApiClient } from '../../src/lib/api-client.js'; +import * as sessionModule from '../../src/lib/session.js'; + +// Integration test for refresh-token rotation persistence. +// +// Unlike api-client.test.ts, this file deliberately does NOT mock @atproto/api. +// It drives the real CredentialSession through a stubbed global fetch so we can +// prove the full chain end to end: resumeSession forces a token refresh, the +// server rotates the refresh token, and the rotated token is what reaches +// saveSession (and would therefore be written to the keychain). Mocking the +// library would only re-prove our own wiring; this proves the fix. + +vi.mock('../../src/lib/session.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + saveSession: vi.fn(), + loadSession: vi.fn(), + deleteSession: vi.fn(), + saveCurrentSessionMetadata: vi.fn(), + getCurrentSessionMetadata: vi.fn(), + clearCurrentSessionMetadata: vi.fn(), + }; +}); + +const PDS = 'https://pds.example'; +const DID = 'did:plc:test123'; +const HANDLE = 'user.bsky.social'; + +// The session loaded from the keychain holds the original (about to be rotated) +// refresh token. +const storedSession: AtpSessionData = { + did: DID, + handle: HANDLE, + email: 'user@example.com', + emailConfirmed: true, + active: true, + accessJwt: 'access-token-1', + refreshJwt: 'refresh-token-1', +}; + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); +} + +describe('refresh-token rotation persistence (integration)', () => { + const originalFetch = globalThis.fetch; + let fetchMock: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + + vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue({ + handle: HANDLE, + did: DID, + pds: PDS, + lastUsed: '2024-01-01T00:00:00.000Z', + }); + vi.mocked(sessionModule.loadSession).mockResolvedValue(storedSession); + + // Stub the network. refreshSession returns rotated tokens; getSession (which + // the library calls to backfill email/didDoc) echoes identity fields and + // does not touch the refresh token. + fetchMock = vi.fn(async (input: string | URL | Request) => { + const url = input instanceof Request ? input.url : input.toString(); + if (url.includes('com.atproto.server.refreshSession')) { + return jsonResponse({ + did: DID, + handle: HANDLE, + accessJwt: 'access-token-2', + refreshJwt: 'refresh-token-2', + active: true, + }); + } + if (url.includes('com.atproto.server.getSession')) { + return jsonResponse({ + did: DID, + handle: HANDLE, + email: 'user@example.com', + emailConfirmed: true, + active: true, + }); + } + throw new Error(`Unexpected fetch in test: ${url}`); + }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it('persists the rotated refresh token returned by the real refresh call', async () => { + const client = new TangledApiClient(); + + const resumed = await client.resumeSession(); + + expect(resumed).toBe(true); + + // The refresh call used the OLD refresh token (proving a real rotation). + const refreshCall = fetchMock.mock.calls.find(([input]) => { + const url = input instanceof Request ? input.url : String(input); + return url.includes('com.atproto.server.refreshSession'); + }); + expect(refreshCall).toBeDefined(); + const refreshInit = refreshCall?.[1] as RequestInit | undefined; + const authHeader = new Headers(refreshInit?.headers).get('authorization'); + expect(authHeader).toBe('Bearer refresh-token-1'); + + // The NEW refresh token is what gets persisted to the keychain. + expect(vi.mocked(sessionModule.saveSession)).toHaveBeenCalledTimes(1); + const persisted = vi.mocked(sessionModule.saveSession).mock.calls[0][0]; + expect(persisted.refreshJwt).toBe('refresh-token-2'); + expect(persisted.accessJwt).toBe('access-token-2'); + }); +}); -- 2.51.2