diff --git a/src/oauth-persistence.ts b/src/oauth-persistence.ts index 1d611d3..c5d0d82 100644 --- a/src/oauth-persistence.ts +++ b/src/oauth-persistence.ts @@ -396,12 +396,9 @@ class CompositePersistence implements OAuthPersistence { } async readTokens(): Promise { - const results = await Promise.all(this.stores.map((store) => store.readTokens())); - this.recoveryTokenSnapshots.clear(); - this.stores.forEach((store, index) => this.recoveryTokenSnapshots.set(store, results[index])); - const sourceIndex = results.findIndex((tokens) => tokens !== undefined); - this.recoveryTokenSource = sourceIndex >= 0 ? this.stores[sourceIndex] : undefined; - return sourceIndex >= 0 ? results[sourceIndex] : undefined; + const result = await this.readRecoveryValues((store) => store.readTokens(), this.recoveryTokenSnapshots); + this.recoveryTokenSource = result.source; + return result.value; } async saveTokens(tokens: OAuthTokens): Promise { @@ -439,12 +436,37 @@ class CompositePersistence implements OAuthPersistence { } async readClientInfo(): Promise { - const results = await Promise.all(this.stores.map((store) => store.readClientInfo())); - this.recoveryClientSnapshots.clear(); - this.stores.forEach((store, index) => this.recoveryClientSnapshots.set(store, results[index])); - const sourceIndex = results.findIndex((clientInfo) => clientInfo !== undefined); - this.recoveryClientSource = sourceIndex >= 0 ? this.stores[sourceIndex] : undefined; - return sourceIndex >= 0 ? results[sourceIndex] : undefined; + const result = await this.readRecoveryValues((store) => store.readClientInfo(), this.recoveryClientSnapshots); + this.recoveryClientSource = result.source; + return result.value; + } + + private async readRecoveryValues( + read: (store: OAuthPersistence) => Promise, + snapshots: Map + ): Promise<{ value: T | undefined; source: OAuthPersistence | undefined }> { + const results = await Promise.allSettled(this.stores.map((store) => read(store))); + snapshots.clear(); + let value: T | undefined; + let source: OAuthPersistence | undefined; + for (const [index, result] of results.entries()) { + const store = this.stores[index]!; + if (result.status === 'rejected') { + // Preserve ordered fallback semantics: a lower-priority store cannot + // invalidate an already-readable primary cache, while failures before + // the first usable value still surface. + if (!source) { + throw result.reason; + } + continue; + } + snapshots.set(store, result.value); + if (!source && result.value !== undefined) { + source = store; + value = result.value; + } + } + return { value, source }; } async saveClientInfo(info: OAuthClientInformationMixed): Promise { diff --git a/tests/oauth-persistence.test.ts b/tests/oauth-persistence.test.ts index 2cf6853..c1ae952 100644 --- a/tests/oauth-persistence.test.ts +++ b/tests/oauth-persistence.test.ts @@ -123,6 +123,42 @@ describe('oauth persistence', () => { expect(entry?.tokens?.access_token).toBe('new-token'); }); + it.runIf(process.platform !== 'win32')( + 'keeps an explicit token cache usable when the lower-priority vault is unreadable', + async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-cache-vault-unreadable-')); + tempRoots.push(tmp); + homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(path.join(tmp, 'home')); + hasSpy = true; + process.env.XDG_DATA_HOME = path.join(tmp, 'data'); + + const cacheDir = path.join(tmp, 'cache'); + await fs.mkdir(cacheDir, { recursive: true }); + await fs.writeFile( + path.join(cacheDir, 'tokens.json'), + JSON.stringify({ access_token: 'from-cache', token_type: 'Bearer' }) + ); + await fs.writeFile(path.join(cacheDir, 'client.json'), JSON.stringify({ client_id: 'cache-client' })); + + const vaultPath = path.join(tmp, 'data', 'mcporter', 'credentials.json'); + await fs.mkdir(path.dirname(vaultPath), { recursive: true }); + await fs.writeFile(vaultPath, JSON.stringify({ version: 1, entries: {} }), 'utf8'); + + try { + await fs.chmod(vaultPath, 0o000); + const persistence = await buildOAuthPersistence(mkDef('service', cacheDir)); + + await expect(persistence.readTokens()).resolves.toEqual({ + access_token: 'from-cache', + token_type: 'Bearer', + }); + await expect(persistence.readClientInfo()).resolves.toEqual({ client_id: 'cache-client' }); + } finally { + await fs.chmod(vaultPath, 0o600).catch(() => {}); + } + } + ); + it('migrates legacy per-server cache into the vault', async () => { const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-')); tempRoots.push(tmp);