From a6e6815b7baf24fd6e8a286df0075f3f32672ccf Mon Sep 17 00:00:00 2001 From: abcb1122 Date: Thu, 18 Sep 2025 09:17:03 -0500 Subject: [PATCH] fix: auto-recover from OAuth app deletion after token revocation (#2422) When users revoke OAuth access on their Mastodon server, the OAuth application gets deleted but remains cached in Elk. This causes login failures with 'Client authentication failed due to unknown client'. This fix adds automatic detection and recovery: - Detects specific invalid_client errors (401 status) - Automatically invalidates stale cached OAuth credentials - Creates fresh OAuth application and retries seamlessly - Single retry prevents infinite loops - Preserves existing error handling for other failures Changes: - Add invalidateApp() function to server/utils/shared.ts - Enhanced error handling in server/api/[server]/oauth/[origin].ts - Backward compatible, zero breaking changes Fixes #2422 --- server/api/[server]/oauth/[origin].ts | 48 +++++++++++++++++++++++++-- server/utils/shared.ts | 6 ++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/server/api/[server]/oauth/[origin].ts b/server/api/[server]/oauth/[origin].ts index 66833146..c5555354 100644 --- a/server/api/[server]/oauth/[origin].ts +++ b/server/api/[server]/oauth/[origin].ts @@ -1,6 +1,6 @@ import { stringifyQuery } from 'ufo' -import { defaultUserAgent } from '~~/server/utils/shared' +import { defaultUserAgent, invalidateApp } from '~~/server/utils/shared' export default defineEventHandler(async (event) => { let { server, origin } = getRouterParams(event) @@ -43,7 +43,51 @@ export default defineEventHandler(async (event) => { const url = `/signin/callback?${stringifyQuery({ server, token: result.access_token, vapid_key: app.vapid_key })}` await sendRedirect(event, url, 302) } - catch { + catch (error: any) { + // Check for invalid client error (OAuth app deleted) + if (error?.data?.error === 'invalid_client' + || (error?.statusCode === 401 && error?.data?.error_description?.includes('Client authentication failed'))) { + // Invalidate cached app and retry once + await invalidateApp(origin, server) + + try { + const newApp = await getApp(origin, server) + if (!newApp) { + throw createError({ + statusCode: 400, + statusMessage: `Failed to re-register app for server: ${server}`, + }) + } + + const retryResult: any = await $fetch(`https://${server}/oauth/token`, { + method: 'POST', + headers: { + 'user-agent': defaultUserAgent, + }, + body: { + client_id: newApp.client_id, + client_secret: newApp.client_secret, + redirect_uri: getRedirectURI(origin, server), + grant_type: 'authorization_code', + code, + scope: 'read write follow push', + }, + retry: 1, + }) + + const url = `/signin/callback?${stringifyQuery({ server, token: retryResult.access_token, vapid_key: newApp.vapid_key })}` + await sendRedirect(event, url, 302) + return + } + catch { + throw createError({ + statusCode: 400, + statusMessage: 'OAuth application recovery failed. Please try again.', + }) + } + } + + // Other errors (network, invalid code, etc.) throw createError({ statusCode: 400, statusMessage: 'Could not complete log in.', diff --git a/server/utils/shared.ts b/server/utils/shared.ts index 35ef064b..54faa53c 100644 --- a/server/utils/shared.ts +++ b/server/utils/shared.ts @@ -111,6 +111,12 @@ export async function deleteApp(server: string) { await storage.removeItem(key) } +export async function invalidateApp(origin: string, server: string) { + const host = origin.replace(/^https?:\/\//, '').replace(/\W/g, '-').replace(/\?.*$/, '') + const key = `servers:v4:${server}:${host}.json`.toLowerCase() + await storage.removeItem(key) +} + export async function listServers() { const keys = await storage.getKeys('servers:v4:') const servers = new Set()