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'); + }); +});