diff --git a/server/api/atproto/callback.get.ts b/server/api/atproto/callback.get.ts index e35a049..600b1f7 100644 --- a/server/api/atproto/callback.get.ts +++ b/server/api/atproto/callback.get.ts @@ -1,8 +1,8 @@ -import { eq } from 'drizzle-orm' +import { and, eq, ne } from 'drizzle-orm' import { userIdentity } from '~~/server/db/schema' import { enqueue } from '~~/server/utils/queue' import { writeSession } from '~~/server/utils/server-session' -import { generateAndPublishKey } from '~~/server/utils/tangled-pubkey' +import { generateAndPublishKey, revokeKeyForInstallationDid } from '~~/server/utils/tangled-pubkey' export default defineEventHandler(async event => { const url = getRequestURL(event) @@ -37,6 +37,32 @@ export default defineEventHandler(async event => { } installationId = parsed + // One installation maps to exactly one DID. If another DID is currently + // bound to this installation, this connect displaces it: revoke that DID's + // now-dead SSH key (PDS record + local row) and null its installationId so + // the worker stops syncing for it. The displaced user_identity row is + // preserved — the user keeps their identity and can re-bind elsewhere. + const displaced = await db.select({ did: userIdentity.did }) + .from(userIdentity) + .where(and( + eq(userIdentity.installationId, installationId), + ne(userIdentity.did, session.did), + )) + + for (const row of displaced) { + // eslint-disable-next-line no-await-in-loop -- sequential PDS revocations + await revokeKeyForInstallationDid(installationId, row.did) + } + + if (displaced.length > 0) { + await db.update(userIdentity) + .set({ installationId: null, updatedAt: new Date() }) + .where(and( + eq(userIdentity.installationId, installationId), + ne(userIdentity.did, session.did), + )) + } + await db.insert(userIdentity).values({ did: session.did, handle: null, diff --git a/server/api/me/logout.post.ts b/server/api/me/logout.post.ts new file mode 100644 index 0000000..6b35e90 --- /dev/null +++ b/server/api/me/logout.post.ts @@ -0,0 +1,7 @@ +import { sessionConfig } from '~~/server/utils/server-session' + +export default defineEventHandler(async event => { + const session = await useSession(event, sessionConfig()) + await session.clear() + return { ok: true } +}) diff --git a/server/utils/tangled-pubkey.ts b/server/utils/tangled-pubkey.ts index 776d6cf..a08e31c 100644 --- a/server/utils/tangled-pubkey.ts +++ b/server/utils/tangled-pubkey.ts @@ -158,3 +158,48 @@ export async function revokeKeysForInstallation(installationId: number): Promise } } } + +/** + * Revoke the `sh.tangled.publicKey` PDS record for one `(installationId, did)` + * pair and drop its local `ssh_key` row. + * + * Used when a re-bind displaces a DID from an installation: the displaced + * DID's key is now dead for that account, so we revoke it from their PDS and + * delete the local row. Best-effort on the PDS side (a 404 or a failed + * session restore is logged, not fatal) so the re-bind always completes; the + * local row is dropped regardless. + */ +export async function revokeKeyForInstallationDid(installationId: number, did: string): Promise { + const db = useDb() + const rows = await db.select({ id: sshKey.id, rkey: sshKey.tangledKeyRkey }) + .from(sshKey) + .where(sql`${sshKey.installationId} = ${installationId} AND ${sshKey.did} = ${did}`) + + const client = await useOAuthClient() + + for (const row of rows) { + if (row.rkey) { + try { + // eslint-disable-next-line no-await-in-loop -- one PDS session per row + const session = await client.restore(did) + const agent = new Agent(session) + // eslint-disable-next-line no-await-in-loop -- sequential PDS deletes + await agent.com.atproto.repo.deleteRecord({ + repo: did, + collection: PUBKEY_LEXICON, + rkey: row.rkey, + }) + } + catch (err) { + const status = err && typeof err === 'object' && 'status' in err && typeof err.status === 'number' + ? err.status + : undefined + if (status !== 404) { + console.error(`failed to revoke publicKey record for did ${did} (installation ${installationId})`, err) + } + } + } + // eslint-disable-next-line no-await-in-loop -- sequential row deletes + await db.delete(sshKey).where(sql`${sshKey.id} = ${row.id}`) + } +} diff --git a/test/unit/tangled-pubkey.spec.ts b/test/unit/tangled-pubkey.spec.ts index 8cfdf92..b0fced8 100644 --- a/test/unit/tangled-pubkey.spec.ts +++ b/test/unit/tangled-pubkey.spec.ts @@ -29,7 +29,7 @@ vi.mock('../../server/utils/atproto-oauth', () => ({ useOAuthClient: async () => ({ restore: restoreMock }), })) -const { generateAndPublishKey, revokeKeysForInstallation, rotateKey } = await import('../../server/utils/tangled-pubkey') +const { generateAndPublishKey, revokeKeyForInstallationDid, revokeKeysForInstallation, rotateKey } = await import('../../server/utils/tangled-pubkey') function fakeOauthSession(did: string) { // The Agent mock above ignores its constructor argument, so we only need @@ -309,3 +309,86 @@ describe('revokeKeysForInstallation', () => { expect(deleteRecordMock).not.toHaveBeenCalled() }) }) + +describe('revokeKeyForInstallationDid', () => { + beforeEach(async () => { + process.env.NUXT_ENCRYPTION_KEY = crypto.randomBytes(32).toString('base64') + clearEncryptionKeyCache() + + setDb(await createTestDb()) + await useDb().insert(installation).values({ + id: 1, accountLogin: 'alice', accountId: 100, accountType: 'User', + }) + + createRecordMock.mockReset() + deleteRecordMock.mockReset() + restoreMock.mockReset() + let counter = 0 + createRecordMock.mockImplementation(async () => { + counter += 1 + return { data: { uri: `at://did:plc:${counter}/sh.tangled.publicKey/rkey-${counter}`, cid: 'bafy' } } + }) + deleteRecordMock.mockResolvedValue({}) + restoreMock.mockImplementation(async (did: string) => ({ did })) + }) + + afterEach(() => { + if (ORIGINAL_ENC_KEY === undefined) delete process.env.NUXT_ENCRYPTION_KEY + else process.env.NUXT_ENCRYPTION_KEY = ORIGINAL_ENC_KEY + clearEncryptionKeyCache() + clearDb() + }) + + it('revokes the PDS record and drops the local row for one (install, did)', async () => { + await generateAndPublishKey({ oauthSession: fakeOauthSession('did:plc:1'), installationId: 1 }) + const db = useDb() + expect(await db.select().from(sshKey)).toHaveLength(1) + + await revokeKeyForInstallationDid(1, 'did:plc:1') + + expect(restoreMock).toHaveBeenCalledWith('did:plc:1') + const del = deleteRecordMock.mock.calls[0]![0] + expect(del.repo).toBe('did:plc:1') + expect(del.collection).toBe('sh.tangled.publicKey') + expect(del.rkey).toBe('rkey-1') + expect(await db.select().from(sshKey)).toHaveLength(0) + }) + + it('leaves other dids on the same installation untouched', async () => { + await generateAndPublishKey({ oauthSession: fakeOauthSession('did:plc:1'), installationId: 1 }) + await generateAndPublishKey({ oauthSession: fakeOauthSession('did:plc:2'), installationId: 1 }) + + await revokeKeyForInstallationDid(1, 'did:plc:1') + + const db = useDb() + const rows = await db.select().from(sshKey) + expect(rows).toHaveLength(1) + expect(rows[0]!.did).toBe('did:plc:2') + }) + + it('no-ops when no key exists for the pair', async () => { + await revokeKeyForInstallationDid(1, 'did:plc:none') + expect(restoreMock).not.toHaveBeenCalled() + expect(deleteRecordMock).not.toHaveBeenCalled() + }) + + it('drops the local row even when the PDS delete fails', async () => { + await generateAndPublishKey({ oauthSession: fakeOauthSession('did:plc:1'), installationId: 1 }) + deleteRecordMock.mockRejectedValueOnce(Object.assign(new Error('boom'), { status: 500 })) + + await expect(revokeKeyForInstallationDid(1, 'did:plc:1')).resolves.toBeUndefined() + + const db = useDb() + expect(await db.select().from(sshKey)).toHaveLength(0) + }) + + it('drops the local row even when session restoration fails', async () => { + await generateAndPublishKey({ oauthSession: fakeOauthSession('did:plc:1'), installationId: 1 }) + restoreMock.mockRejectedValueOnce(new Error('session gone')) + + await expect(revokeKeyForInstallationDid(1, 'did:plc:1')).resolves.toBeUndefined() + + const db = useDb() + expect(await db.select().from(sshKey)).toHaveLength(0) + }) +})