From a178bd6741dd891527f31d1420e8bd5236078bb3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 13 Apr 2026 15:17:57 +0000 Subject: [PATCH 1/6] Add trusted client reset flow Co-authored-by: Kent C. Dodds --- .../worker/client/routes/oauth-authorize.tsx | 70 ++++++++-- packages/worker/src/oauth-handlers.ts | 132 +++++++++++++++++- .../worker/src/oauth-handlers.workers.test.ts | 107 ++++++++++++++ 3 files changed, 295 insertions(+), 14 deletions(-) diff --git a/packages/worker/client/routes/oauth-authorize.tsx b/packages/worker/client/routes/oauth-authorize.tsx index c8ac5fc69c..b425a68f0a 100644 --- a/packages/worker/client/routes/oauth-authorize.tsx +++ b/packages/worker/client/routes/oauth-authorize.tsx @@ -11,6 +11,7 @@ import { fieldCss, fieldLabelCss, getAlertCardCss, + getDangerButtonCss, getPrimaryButtonCss, getSecondaryButtonCss, insetCardCss, @@ -31,6 +32,10 @@ type OAuthAuthorizeInfo = { type OAuthAuthorizeStatus = 'idle' | 'loading' | 'ready' | 'error' type OAuthAuthorizeMessage = { type: 'error' | 'info'; text: string } +type OAuthAuthorizeDecision = 'approve' | 'deny' | 'reset-client' + +const invalidRedirectUriMessage = + 'Invalid redirect URI. The redirect URI provided does not match any registered URI for this client.' function getSearchParams() { return typeof window === 'undefined' @@ -42,10 +47,11 @@ export function OAuthAuthorizeRoute(handle: Handle) { let info: OAuthAuthorizeInfo | null = null let status: OAuthAuthorizeStatus = 'idle' let message: OAuthAuthorizeMessage | null = null - let submitting = false + let submittingDecision: OAuthAuthorizeDecision | null = null let lastSearch = '' let session: SessionInfo | null = null let sessionStatus: SessionStatus = 'idle' + let resetCompleted = false function setMessage(next: OAuthAuthorizeMessage | null) { message = next @@ -116,12 +122,9 @@ export function OAuthAuthorizeRoute(handle: Handle) { handle.update() } - async function submitDecision( - decision: 'approve' | 'deny', - form?: HTMLFormElement, - ) { - if (submitting) return - submitting = true + async function submitDecision(decision: OAuthAuthorizeDecision, form?: HTMLFormElement) { + if (submittingDecision) return + submittingDecision = decision handle.update() try { @@ -136,7 +139,7 @@ export function OAuthAuthorizeRoute(handle: Handle) { type: 'error', text: 'Email and password are required.', }) - submitting = false + submittingDecision = null handle.update() return } @@ -159,7 +162,7 @@ export function OAuthAuthorizeRoute(handle: Handle) { ? payload.error : 'Unable to complete authorization.' setMessage({ type: 'error', text: errorText }) - submitting = false + submittingDecision = null handle.update() return } @@ -167,6 +170,11 @@ export function OAuthAuthorizeRoute(handle: Handle) { window.location.assign(payload.redirectTo) return } + if (typeof payload?.message === 'string') { + resetCompleted = true + setMessage({ type: 'info', text: payload.message }) + return + } setMessage({ type: 'error', text: 'Missing redirect response.' }) } catch { setMessage({ @@ -174,7 +182,7 @@ export function OAuthAuthorizeRoute(handle: Handle) { text: 'Network error. Please try again.', }) } finally { - submitting = false + submittingDecision = null handle.update() } } @@ -204,18 +212,28 @@ export function OAuthAuthorizeRoute(handle: Handle) { const scopes = info?.scopes ?? [] const scopeLabel = scopes.length > 0 ? scopes.join(', ') : 'No scopes requested.' + const queryError = readQueryError() const sessionEmail = session?.email ?? '' const isSessionReady = sessionStatus === 'ready' const isSessionLoading = sessionStatus === 'loading' || sessionStatus === 'idle' const isLoggedIn = isSessionReady && Boolean(sessionEmail) - const actionsDisabled = status !== 'ready' || submitting || isSessionLoading + const showResetClientCard = + queryError === invalidRedirectUriMessage && !resetCompleted + const actionsDisabled = + status !== 'ready' || Boolean(submittingDecision) || isSessionLoading + const resetClientDisabled = + Boolean(submittingDecision) || isSessionLoading || !isLoggedIn const formReady = status === 'ready' && !isSessionLoading - const authorizeLabel = submitting + const authorizeLabel = submittingDecision ? 'Submitting...' : isLoggedIn ? 'Approve connection' : 'Authorize' + const resetClientLabel = + submittingDecision === 'reset-client' + ? 'Deleting stored client...' + : 'Delete stored client' return (
@@ -258,6 +276,30 @@ export function OAuthAuthorizeRoute(handle: Handle) { {message.text}

) : null} + {showResetClientCard ? ( +
+

Reset stored connection

+

+ Delete this trusted client's saved registration and grants, + then start the connection again from the client to create a + fresh record. +

+ {isLoggedIn ? ( + + ) : isSessionReady ? ( +

+ Sign in first, then delete the stored client record. +

+ ) : null} +
+ ) : null}
() + let cursor: string | undefined + + do { + const page = await helpers.listUserGrants(userId, { cursor }) + for (const grant of page.items) { + if (grant.clientId === clientId) { + grants.push({ id: grant.id }) + } + } + cursor = page.cursor + } while (cursor) + + return grants +} + function resolveScopes(requestedScopes: Array) { if (requestedScopes.length === 0) return oauthScopes const invalidScopes = requestedScopes.filter( @@ -99,6 +132,97 @@ function resolveScopes(requestedScopes: Array) { return requestedScopes } +async function handleResetClientRequest( + request: Request, + env: Env, + helpers: OAuthHelpers, + requestIp?: string, +) { + const url = new URL(request.url) + const errorDescription = url.searchParams.get('error_description')?.trim() ?? '' + if (!isInvalidRedirectUriError(errorDescription)) { + return respondAuthorizeError( + request, + 'Stored client cleanup is only available for redirect URI mismatches.', + 400, + 'invalid_request', + ) + } + + const clientId = readClientIdFromAuthorizeRequest(request) + if (!clientId) { + return respondAuthorizeError( + request, + 'Missing client ID for stored client cleanup.', + 400, + 'invalid_request', + ) + } + + const { email: sessionEmail, setCookie } = await resolveSessionEmail(request, env) + if (!sessionEmail) { + void logAuditEvent({ + category: 'oauth', + action: 'reset_client', + result: 'failure', + ip: requestIp, + clientId, + reason: 'missing_session', + }) + return respondAuthorizeError( + request, + 'Sign in before deleting stored client records.', + 401, + 'unauthorized', + ) + } + + try { + const userId = await createStableUserIdFromEmail(sessionEmail) + const grants = await listUserGrantsForClient(helpers, userId, clientId) + await Promise.all(grants.map((grant) => helpers.revokeGrant(grant.id, userId))) + await helpers.deleteClient(clientId) + void logAuditEvent({ + category: 'oauth', + action: 'reset_client', + result: 'success', + email: sessionEmail, + ip: requestIp, + clientId, + }) + return jsonResponse( + { + ok: true, + message: + 'Deleted the stored client records for this connection. Start the connection again from your client to create a fresh trusted client.', + }, + setCookie + ? { + headers: { + 'Set-Cookie': setCookie, + }, + } + : undefined, + ) + } catch (error) { + void logAuditEvent({ + category: 'oauth', + action: 'reset_client', + result: 'failure', + email: sessionEmail, + ip: requestIp, + clientId, + reason: error instanceof Error ? error.message : 'unknown_error', + }) + return respondAuthorizeError( + request, + 'Unable to delete stored client records right now.', + 500, + 'server_error', + ) + } +} + function createAccessDeniedRedirectUrl(request: AuthRequest) { if (!request.redirectUri) { return null @@ -192,6 +316,12 @@ export async function handleAuthorizeRequest( const requestIp = getRequestIp(request) ?? undefined const helpers = getOAuthHelpers(env) + const formData = await request.formData() + const decision = String(formData.get('decision') ?? 'approve') + if (decision === 'reset-client') { + return handleResetClientRequest(request, env, helpers, requestIp) + } + const resolution = await resolveAuthRequest(helpers, request) if ('error' in resolution) { return respondAuthorizeError( @@ -201,8 +331,6 @@ export async function handleAuthorizeRequest( } const { authRequest } = resolution - const formData = await request.formData() - const decision = String(formData.get('decision') ?? 'approve') if (decision === 'deny') { const redirectTo = createAccessDeniedRedirectUrl(authRequest) if (!redirectTo) { diff --git a/packages/worker/src/oauth-handlers.workers.test.ts b/packages/worker/src/oauth-handlers.workers.test.ts index d4cd00d518..672ab029fa 100644 --- a/packages/worker/src/oauth-handlers.workers.test.ts +++ b/packages/worker/src/oauth-handlers.workers.test.ts @@ -13,6 +13,7 @@ import { handleOAuthCallback, oauthScopes, } from './oauth-handlers.ts' +import { createStableUserIdFromEmail } from '#worker/user-id.ts' const baseAuthRequest: AuthRequest = { responseType: 'code', @@ -250,6 +251,112 @@ test('authorize uses default scopes when none requested', async () => { expect(capturedOptions.scope).toEqual(oauthScopes) }) +test('reset client deletes matching grants and client registration', async () => { + const revokedGrantIds = new Array() + const deletedClientIds = new Array() + const userId = await createStableUserIdFromEmail('user@example.com') + const helpers = createHelpers({ + parseAuthRequest: async () => { + throw new Error( + 'Invalid redirect URI. The redirect URI provided does not match any registered URI for this client.', + ) + }, + listUserGrants: async (requestedUserId) => { + expect(requestedUserId).toBe(userId) + return { + items: [ + { + id: 'grant-1', + clientId: 'client-123', + userId, + scope: ['profile'], + metadata: {}, + createdAt: 0, + }, + { + id: 'grant-2', + clientId: 'other-client', + userId, + scope: ['profile'], + metadata: {}, + createdAt: 0, + }, + { + id: 'grant-3', + clientId: 'client-123', + userId, + scope: ['email'], + metadata: {}, + createdAt: 0, + }, + ], + } + }, + revokeGrant: async (grantId, requestedUserId) => { + expect(requestedUserId).toBe(userId) + revokedGrantIds.push(grantId) + }, + deleteClient: async (clientId) => { + deletedClientIds.push(clientId) + }, + }) + setAuthSessionSecret(cookieSecret) + const cookie = await createAuthCookie( + { id: 'session-id', email: 'user@example.com', rememberMe: false }, + false, + ) + + const response = await handleAuthorizeRequest( + new Request( + `https://example.com/oauth/authorize?client_id=client-123&error_description=${encodeURIComponent('Invalid redirect URI. The redirect URI provided does not match any registered URI for this client.')}`, + { + method: 'POST', + headers: { + Accept: 'application/json', + Cookie: cookie, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ decision: 'reset-client' }), + }, + ), + createEnv(helpers), + ) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + ok: true, + message: + 'Deleted the stored client records for this connection. Start the connection again from your client to create a fresh trusted client.', + }) + expect(revokedGrantIds).toEqual(['grant-1', 'grant-3']) + expect(deletedClientIds).toEqual(['client-123']) +}) + +test('reset client is rejected when the request is not a redirect mismatch', async () => { + const response = await handleAuthorizeRequest( + new Request( + 'https://example.com/oauth/authorize?client_id=client-123&error_description=Authorization%20error', + { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ decision: 'reset-client' }), + }, + ), + createEnv(createHelpers()), + ) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({ + ok: false, + error: + 'Stored client cleanup is only available for redirect URI mismatches.', + code: 'invalid_request', + }) +}) + test('oauth callback page returns SPA shell', async () => { const response = handleOAuthCallback( new Request('https://example.com/oauth/callback?code=abc123&state=demo'), From c229b00660333f3841edd8a2485267c47675cab2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 13 Apr 2026 15:37:14 +0000 Subject: [PATCH 2/6] Handle real redirect mismatch reset state Co-authored-by: Kent C. Dodds --- .../worker/client/routes/oauth-authorize.tsx | 8 ++- packages/worker/src/oauth-handlers.ts | 48 ++++++++++++++- .../worker/src/oauth-handlers.workers.test.ts | 59 +++++++++++++++++++ 3 files changed, 113 insertions(+), 2 deletions(-) diff --git a/packages/worker/client/routes/oauth-authorize.tsx b/packages/worker/client/routes/oauth-authorize.tsx index b425a68f0a..c7afdec911 100644 --- a/packages/worker/client/routes/oauth-authorize.tsx +++ b/packages/worker/client/routes/oauth-authorize.tsx @@ -37,6 +37,10 @@ type OAuthAuthorizeDecision = 'approve' | 'deny' | 'reset-client' const invalidRedirectUriMessage = 'Invalid redirect URI. The redirect URI provided does not match any registered URI for this client.' +function isInvalidRedirectUriMessage(message: string | null | undefined) { + return message === invalidRedirectUriMessage +} + function getSearchParams() { return typeof window === 'undefined' ? new URLSearchParams() @@ -219,7 +223,9 @@ export function OAuthAuthorizeRoute(handle: Handle) { sessionStatus === 'loading' || sessionStatus === 'idle' const isLoggedIn = isSessionReady && Boolean(sessionEmail) const showResetClientCard = - queryError === invalidRedirectUriMessage && !resetCompleted + (isInvalidRedirectUriMessage(queryError) || + isInvalidRedirectUriMessage(message?.text)) && + !resetCompleted const actionsDisabled = status !== 'ready' || Boolean(submittingDecision) || isSessionLoading const resetClientDisabled = diff --git a/packages/worker/src/oauth-handlers.ts b/packages/worker/src/oauth-handlers.ts index 29bc13ded5..b08cadeb4c 100644 --- a/packages/worker/src/oauth-handlers.ts +++ b/packages/worker/src/oauth-handlers.ts @@ -98,6 +98,49 @@ function readClientIdFromAuthorizeRequest(request: Request) { return clientId ? clientId : null } +function isLoopbackHostname(hostname: string) { + return hostname === 'localhost' || hostname === '::1' || hostname.startsWith('127.') +} + +function redirectUriMatchesRegisteredUri( + requestUri: string, + registeredUris: Array, +) { + return registeredUris.some((registeredUri) => { + try { + const requestUrl = new URL(requestUri) + const registeredUrl = new URL(registeredUri) + if ( + isLoopbackHostname(requestUrl.hostname) && + isLoopbackHostname(registeredUrl.hostname) + ) { + return ( + requestUrl.protocol === registeredUrl.protocol && + requestUrl.hostname === registeredUrl.hostname && + requestUrl.pathname === registeredUrl.pathname && + requestUrl.search === registeredUrl.search + ) + } + } catch { + return false + } + return requestUri === registeredUri + }) +} + +async function requestHasRedirectUriMismatch( + helpers: OAuthHelpers, + request: Request, +) { + const url = new URL(request.url) + const clientId = url.searchParams.get('client_id')?.trim() + const redirectUri = url.searchParams.get('redirect_uri')?.trim() + if (!clientId || !redirectUri) return false + const client = await helpers.lookupClient(clientId) + if (!client) return false + return !redirectUriMatchesRegisteredUri(redirectUri, client.redirectUris) +} + async function listUserGrantsForClient( helpers: OAuthHelpers, userId: string, @@ -140,7 +183,10 @@ async function handleResetClientRequest( ) { const url = new URL(request.url) const errorDescription = url.searchParams.get('error_description')?.trim() ?? '' - if (!isInvalidRedirectUriError(errorDescription)) { + const redirectUriMismatch = await requestHasRedirectUriMismatch(helpers, request) + const hasInvalidRedirectUri = + isInvalidRedirectUriError(errorDescription) || redirectUriMismatch + if (!hasInvalidRedirectUri) { return respondAuthorizeError( request, 'Stored client cleanup is only available for redirect URI mismatches.', diff --git a/packages/worker/src/oauth-handlers.workers.test.ts b/packages/worker/src/oauth-handlers.workers.test.ts index 672ab029fa..3c7cc75a8a 100644 --- a/packages/worker/src/oauth-handlers.workers.test.ts +++ b/packages/worker/src/oauth-handlers.workers.test.ts @@ -357,6 +357,65 @@ test('reset client is rejected when the request is not a redirect mismatch', asy }) }) +test('reset client also works when the mismatch came from authorize-info loading', async () => { + const deletedClientIds = new Array() + const userId = await createStableUserIdFromEmail('user@example.com') + const helpers = createHelpers({ + listUserGrants: async (requestedUserId) => { + expect(requestedUserId).toBe(userId) + return { + items: [ + { + id: 'grant-1', + clientId: 'client-123', + userId, + scope: ['profile'], + metadata: {}, + createdAt: 0, + }, + ], + } + }, + revokeGrant: async () => undefined, + deleteClient: async (clientId) => { + deletedClientIds.push(clientId) + }, + }) + setAuthSessionSecret(cookieSecret) + const cookie = await createAuthCookie( + { id: 'session-id', email: 'user@example.com', rememberMe: false }, + false, + ) + + const response = await handleAuthorizeRequest( + new Request( + 'https://example.com/oauth/authorize?client_id=client-123&redirect_uri=https%3A%2F%2Flocalhost%3A8888%2Fcallback', + { + method: 'POST', + headers: { + Accept: 'application/json', + Cookie: cookie, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + decision: 'reset-client', + resetReason: + 'Invalid redirect URI. The redirect URI provided does not match any registered URI for this client.', + }), + }, + ), + createEnv(helpers), + ) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + ok: true, + message: + 'Deleted the stored client records for this connection. Start the connection again from your client to create a fresh trusted client.', + }) + expect(deletedClientIds).toEqual(['client-123']) +}) + test('oauth callback page returns SPA shell', async () => { const response = handleOAuthCallback( new Request('https://example.com/oauth/callback?code=abc123&state=demo'), From 46c7e2a52d44c679cd9bc10e318b943d86f3df46 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 13 Apr 2026 15:55:38 +0000 Subject: [PATCH 3/6] Share oauth redirect mismatch message Co-authored-by: Kent C. Dodds --- packages/shared/src/oauth-messages.ts | 2 ++ packages/worker/client/routes/oauth-authorize.tsx | 4 +--- packages/worker/src/oauth-handlers.ts | 4 +--- packages/worker/src/oauth-handlers.workers.test.ts | 2 -- 4 files changed, 4 insertions(+), 8 deletions(-) create mode 100644 packages/shared/src/oauth-messages.ts diff --git a/packages/shared/src/oauth-messages.ts b/packages/shared/src/oauth-messages.ts new file mode 100644 index 0000000000..a89948fc00 --- /dev/null +++ b/packages/shared/src/oauth-messages.ts @@ -0,0 +1,2 @@ +export const invalidRedirectUriMessage = + 'Invalid redirect URI. The redirect URI provided does not match any registered URI for this client.' diff --git a/packages/worker/client/routes/oauth-authorize.tsx b/packages/worker/client/routes/oauth-authorize.tsx index c7afdec911..031c9f9e73 100644 --- a/packages/worker/client/routes/oauth-authorize.tsx +++ b/packages/worker/client/routes/oauth-authorize.tsx @@ -24,6 +24,7 @@ import { sectionTitleCss, stackedPageCss, } from '#client/styles/style-primitives.ts' +import { invalidRedirectUriMessage } from '@kody-internal/shared/oauth-messages.ts' type OAuthAuthorizeInfo = { client: { id: string; name: string } @@ -34,9 +35,6 @@ type OAuthAuthorizeStatus = 'idle' | 'loading' | 'ready' | 'error' type OAuthAuthorizeMessage = { type: 'error' | 'info'; text: string } type OAuthAuthorizeDecision = 'approve' | 'deny' | 'reset-client' -const invalidRedirectUriMessage = - 'Invalid redirect URI. The redirect URI provided does not match any registered URI for this client.' - function isInvalidRedirectUriMessage(message: string | null | undefined) { return message === invalidRedirectUriMessage } diff --git a/packages/worker/src/oauth-handlers.ts b/packages/worker/src/oauth-handlers.ts index b08cadeb4c..f83a20eb3a 100644 --- a/packages/worker/src/oauth-handlers.ts +++ b/packages/worker/src/oauth-handlers.ts @@ -11,6 +11,7 @@ import { getEnv } from '#app/env.ts' import { Layout } from '#app/layout.ts' import { createStableUserIdFromEmail } from '#worker/user-id.ts' import { render } from '#app/render.ts' +import { invalidRedirectUriMessage } from '@kody-internal/shared/oauth-messages.ts' import { createDb, usersTable } from './db.ts' import { wantsJson } from './utils.ts' import { verifyPassword } from '@kody-internal/shared/password-hash.ts' @@ -57,9 +58,6 @@ function jsonResponse(data: unknown, init?: ResponseInit) { }) } -const invalidRedirectUriMessage = - 'Invalid redirect URI. The redirect URI provided does not match any registered URI for this client.' - function getOAuthHelpers(env: Env) { const helpers = (env as OAuthEnv).OAUTH_PROVIDER if (!helpers) { diff --git a/packages/worker/src/oauth-handlers.workers.test.ts b/packages/worker/src/oauth-handlers.workers.test.ts index 3c7cc75a8a..a1e253c080 100644 --- a/packages/worker/src/oauth-handlers.workers.test.ts +++ b/packages/worker/src/oauth-handlers.workers.test.ts @@ -399,8 +399,6 @@ test('reset client also works when the mismatch came from authorize-info loading }, body: new URLSearchParams({ decision: 'reset-client', - resetReason: - 'Invalid redirect URI. The redirect URI provided does not match any registered URI for this client.', }), }, ), From 471c89338ecfb21047e19fa42c431d229bf90736 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 13 Apr 2026 15:57:14 +0000 Subject: [PATCH 4/6] Fix reset client redirect mismatch check Co-authored-by: Kent C. Dodds --- packages/worker/src/oauth-handlers.ts | 9 +-------- packages/worker/src/oauth-handlers.workers.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/packages/worker/src/oauth-handlers.ts b/packages/worker/src/oauth-handlers.ts index f83a20eb3a..c1197fecd9 100644 --- a/packages/worker/src/oauth-handlers.ts +++ b/packages/worker/src/oauth-handlers.ts @@ -87,10 +87,6 @@ async function resolveAuthRequest(helpers: OAuthHelpers, request: Request) { } } -function isInvalidRedirectUriError(message: string) { - return message === invalidRedirectUriMessage -} - function readClientIdFromAuthorizeRequest(request: Request) { const clientId = new URL(request.url).searchParams.get('client_id')?.trim() return clientId ? clientId : null @@ -180,11 +176,8 @@ async function handleResetClientRequest( requestIp?: string, ) { const url = new URL(request.url) - const errorDescription = url.searchParams.get('error_description')?.trim() ?? '' const redirectUriMismatch = await requestHasRedirectUriMismatch(helpers, request) - const hasInvalidRedirectUri = - isInvalidRedirectUriError(errorDescription) || redirectUriMismatch - if (!hasInvalidRedirectUri) { + if (!redirectUriMismatch) { return respondAuthorizeError( request, 'Stored client cleanup is only available for redirect URI mismatches.', diff --git a/packages/worker/src/oauth-handlers.workers.test.ts b/packages/worker/src/oauth-handlers.workers.test.ts index a1e253c080..298117dcdf 100644 --- a/packages/worker/src/oauth-handlers.workers.test.ts +++ b/packages/worker/src/oauth-handlers.workers.test.ts @@ -308,7 +308,7 @@ test('reset client deletes matching grants and client registration', async () => const response = await handleAuthorizeRequest( new Request( - `https://example.com/oauth/authorize?client_id=client-123&error_description=${encodeURIComponent('Invalid redirect URI. The redirect URI provided does not match any registered URI for this client.')}`, + `https://example.com/oauth/authorize?client_id=client-123&redirect_uri=${encodeURIComponent('https://example.com/invalid')}&error_description=${encodeURIComponent('Invalid redirect URI. The redirect URI provided does not match any registered URI for this client.')}`, { method: 'POST', headers: { @@ -335,7 +335,7 @@ test('reset client deletes matching grants and client registration', async () => test('reset client is rejected when the request is not a redirect mismatch', async () => { const response = await handleAuthorizeRequest( new Request( - 'https://example.com/oauth/authorize?client_id=client-123&error_description=Authorization%20error', + `https://example.com/oauth/authorize?client_id=client-123&redirect_uri=${encodeURIComponent('https://example.com/callback')}&error_description=Authorization%20error`, { method: 'POST', headers: { From 7ea6ac5b7c0165c93f3f1aaab9e33d2608a85cb2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 13 Apr 2026 16:12:23 +0000 Subject: [PATCH 5/6] Fix oauth reset typecheck issues Co-authored-by: Kent C. Dodds --- packages/worker/client/routes/oauth-authorize.tsx | 1 + packages/worker/src/oauth-handlers.ts | 2 -- packages/worker/src/oauth-handlers.workers.test.ts | 6 +++++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/worker/client/routes/oauth-authorize.tsx b/packages/worker/client/routes/oauth-authorize.tsx index 031c9f9e73..c733d5580d 100644 --- a/packages/worker/client/routes/oauth-authorize.tsx +++ b/packages/worker/client/routes/oauth-authorize.tsx @@ -174,6 +174,7 @@ export function OAuthAuthorizeRoute(handle: Handle) { } if (typeof payload?.message === 'string') { resetCompleted = true + submittingDecision = null setMessage({ type: 'info', text: payload.message }) return } diff --git a/packages/worker/src/oauth-handlers.ts b/packages/worker/src/oauth-handlers.ts index c1197fecd9..1f164d8441 100644 --- a/packages/worker/src/oauth-handlers.ts +++ b/packages/worker/src/oauth-handlers.ts @@ -11,7 +11,6 @@ import { getEnv } from '#app/env.ts' import { Layout } from '#app/layout.ts' import { createStableUserIdFromEmail } from '#worker/user-id.ts' import { render } from '#app/render.ts' -import { invalidRedirectUriMessage } from '@kody-internal/shared/oauth-messages.ts' import { createDb, usersTable } from './db.ts' import { wantsJson } from './utils.ts' import { verifyPassword } from '@kody-internal/shared/password-hash.ts' @@ -175,7 +174,6 @@ async function handleResetClientRequest( helpers: OAuthHelpers, requestIp?: string, ) { - const url = new URL(request.url) const redirectUriMismatch = await requestHasRedirectUriMismatch(helpers, request) if (!redirectUriMismatch) { return respondAuthorizeError( diff --git a/packages/worker/src/oauth-handlers.workers.test.ts b/packages/worker/src/oauth-handlers.workers.test.ts index 298117dcdf..a182d1132f 100644 --- a/packages/worker/src/oauth-handlers.workers.test.ts +++ b/packages/worker/src/oauth-handlers.workers.test.ts @@ -359,6 +359,7 @@ test('reset client is rejected when the request is not a redirect mismatch', asy test('reset client also works when the mismatch came from authorize-info loading', async () => { const deletedClientIds = new Array() + const revokedGrantIds = new Array() const userId = await createStableUserIdFromEmail('user@example.com') const helpers = createHelpers({ listUserGrants: async (requestedUserId) => { @@ -376,7 +377,9 @@ test('reset client also works when the mismatch came from authorize-info loading ], } }, - revokeGrant: async () => undefined, + revokeGrant: async (grantId) => { + revokedGrantIds.push(grantId) + }, deleteClient: async (clientId) => { deletedClientIds.push(clientId) }, @@ -411,6 +414,7 @@ test('reset client also works when the mismatch came from authorize-info loading message: 'Deleted the stored client records for this connection. Start the connection again from your client to create a fresh trusted client.', }) + expect(revokedGrantIds).toEqual(['grant-1']) expect(deletedClientIds).toEqual(['client-123']) }) From 5f5f4f108b5178ff9b8706c178546b5945c90813 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 13 Apr 2026 16:13:19 +0000 Subject: [PATCH 6/6] Remove unused oauth handler values Co-authored-by: Kent C. Dodds --- packages/worker/src/oauth-handlers.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/worker/src/oauth-handlers.ts b/packages/worker/src/oauth-handlers.ts index c1197fecd9..1f164d8441 100644 --- a/packages/worker/src/oauth-handlers.ts +++ b/packages/worker/src/oauth-handlers.ts @@ -11,7 +11,6 @@ import { getEnv } from '#app/env.ts' import { Layout } from '#app/layout.ts' import { createStableUserIdFromEmail } from '#worker/user-id.ts' import { render } from '#app/render.ts' -import { invalidRedirectUriMessage } from '@kody-internal/shared/oauth-messages.ts' import { createDb, usersTable } from './db.ts' import { wantsJson } from './utils.ts' import { verifyPassword } from '@kody-internal/shared/password-hash.ts' @@ -175,7 +174,6 @@ async function handleResetClientRequest( helpers: OAuthHelpers, requestIp?: string, ) { - const url = new URL(request.url) const redirectUriMismatch = await requestHasRedirectUriMismatch(helpers, request) if (!redirectUriMismatch) { return respondAuthorizeError(