diff --git a/server/utils/atproto-oauth.ts b/server/utils/atproto-oauth.ts index 3119cbc..aa30288 100644 --- a/server/utils/atproto-oauth.ts +++ b/server/utils/atproto-oauth.ts @@ -153,6 +153,39 @@ function makeSessionStore(): NodeSavedSessionStore { } } +/** + * True when restoring an OAuth session failed because the session no longer + * exists or can't be refreshed: the user revoked the app on their PDS, the + * refresh token expired, or the session row was never written / already + * deleted. The library signals all of these by throwing `TokenRefreshError` / + * `TokenRevokedError`; we also match the message for resilience across library + * versions. Callers treat this as a benign drop rather than a retryable error, + * so per-DID work doesn't loop forever once a user disconnects. + */ +export function isSessionGone(err: unknown): boolean { + if (!err || typeof err !== 'object') return false + const name = 'name' in err && typeof err.name === 'string' ? err.name : '' + if (name === 'TokenRefreshError' || name === 'TokenRevokedError') return true + const message = 'message' in err && typeof err.message === 'string' ? err.message : '' + return /session was deleted|token (has been )?revoked|no refresh token/i.test(message) +} + +/** + * Restore an OAuth session for `did`, or null when the session is gone (see + * `isSessionGone`). Any other failure re-throws so genuine/transient errors + * still surface to the queue's retry. + */ +export async function restoreSessionOrNull(did: string) { + const client = await useOAuthClient() + try { + return await client.restore(did) + } + catch (err) { + if (isSessionGone(err)) return null + throw err + } +} + /** Test hook: drop the cached client. */ export function clearOAuthClientCache() { cachedClient = undefined diff --git a/server/utils/job-handlers.ts b/server/utils/job-handlers.ts index e72d9dd..1699a17 100644 --- a/server/utils/job-handlers.ts +++ b/server/utils/job-handlers.ts @@ -1,7 +1,7 @@ import type { OAuthSession } from '@atproto/oauth-client-node' import { and, eq, sql } from 'drizzle-orm' import { installation, repoMapping, userIdentity } from '../db/schema' -import { useOAuthClient } from './atproto-oauth' +import { restoreSessionOrNull } from './atproto-oauth' import { useDb } from './db' import { installationOctokit } from './github-app' import type { JobEnvelope } from './queue' @@ -203,8 +203,11 @@ export async function dispatch(envelope: JobEnvelope): Promise { // fail forever on the foreign key. Drop cleanly instead of retrying, and // avoid publishing an orphan sh.tangled.publicKey record to the PDS. if (!(await installationExists(installationId))) return - const client = await useOAuthClient() - const session = await client.restore(did) + // The user may have revoked the app on their PDS after this job was + // enqueued; the session is then gone and can't be restored. Drop cleanly + // rather than throwing and retrying to exhaustion. + const session = await restoreSessionOrNull(did) + if (!session) return if (force) await rotateKey({ oauthSession: session, installationId }) else await generateAndPublishKey({ oauthSession: session, installationId }) return @@ -386,8 +389,7 @@ async function restoreSessionForInstallation(installationId: number): Promise ({ vi.mock('../../server/utils/atproto-oauth', () => ({ useOAuthClient: async () => ({ restore: restoreMock }), + restoreSessionOrNull: (did: string) => restoreMock(did), })) const { dispatch } = await import('../../server/utils/job-handlers') @@ -345,4 +346,21 @@ describe('atproto.publish-pubkey install-existence guard', () => { // Guard short-circuits before restoring the session or publishing a record. expect(restoreMock).not.toHaveBeenCalled() }) + + it('drops the job cleanly when the OAuth session is gone', async () => { + await useDb().insert(installation).values({ + id: 1, accountLogin: 'alice', accountId: 100, accountType: 'User', + }) + // restoreSessionOrNull yields null (user revoked the app on their PDS). + restoreMock.mockResolvedValue(null as unknown as { did: string }) + + await expect(dispatch({ + id: 1, + kind: 'atproto.publish-pubkey', + payload: { did: 'did:plc:abc', installationId: 1, force: true }, + attempts: 1, + })).resolves.toBeUndefined() + // No PDS write attempted. + expect(putRecordMock).not.toHaveBeenCalled() + }) }) diff --git a/test/unit/session-gone.spec.ts b/test/unit/session-gone.spec.ts new file mode 100644 index 0000000..0cb1d3c --- /dev/null +++ b/test/unit/session-gone.spec.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import { isSessionGone } from '../../server/utils/atproto-oauth' + +describe('isSessionGone', () => { + it('matches the library errors thrown when a session can no longer be restored', () => { + expect(isSessionGone(Object.assign(new Error('whatever'), { name: 'TokenRefreshError' }))).toBe(true) + expect(isSessionGone(Object.assign(new Error('whatever'), { name: 'TokenRevokedError' }))).toBe(true) + expect(isSessionGone(new Error('The session was deleted by another process'))).toBe(true) + expect(isSessionGone(new Error('Token has been revoked'))).toBe(true) + expect(isSessionGone(new Error('No refresh token available'))).toBe(true) + }) + + it('does not swallow unrelated errors', () => { + expect(isSessionGone(new Error('network timeout'))).toBe(false) + expect(isSessionGone(new Error('ECONNRESET'))).toBe(false) + expect(isSessionGone(null)).toBe(false) + expect(isSessionGone('a string')).toBe(false) + }) +})