Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/shared/src/oauth-messages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const invalidRedirectUriMessage =
'Invalid redirect URI. The redirect URI provided does not match any registered URI for this client.'
75 changes: 63 additions & 12 deletions packages/worker/client/routes/oauth-authorize.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
fieldCss,
fieldLabelCss,
getAlertCardCss,
getDangerButtonCss,
getPrimaryButtonCss,
getSecondaryButtonCss,
insetCardCss,
Expand All @@ -23,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 }
Expand All @@ -31,6 +33,11 @@ type OAuthAuthorizeInfo = {

type OAuthAuthorizeStatus = 'idle' | 'loading' | 'ready' | 'error'
type OAuthAuthorizeMessage = { type: 'error' | 'info'; text: string }
type OAuthAuthorizeDecision = 'approve' | 'deny' | 'reset-client'

function isInvalidRedirectUriMessage(message: string | null | undefined) {
return message === invalidRedirectUriMessage
}

function getSearchParams() {
return typeof window === 'undefined'
Expand All @@ -42,10 +49,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
Expand Down Expand Up @@ -116,12 +124,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 {
Expand All @@ -136,7 +141,7 @@ export function OAuthAuthorizeRoute(handle: Handle) {
type: 'error',
text: 'Email and password are required.',
})
submitting = false
submittingDecision = null
handle.update()
return
}
Expand All @@ -159,22 +164,28 @@ export function OAuthAuthorizeRoute(handle: Handle) {
? payload.error
: 'Unable to complete authorization.'
setMessage({ type: 'error', text: errorText })
submitting = false
submittingDecision = null
handle.update()
return
}
if (payload?.redirectTo) {
window.location.assign(payload.redirectTo)
return
}
if (typeof payload?.message === 'string') {
resetCompleted = true
submittingDecision = null
setMessage({ type: 'info', text: payload.message })
return
}
setMessage({ type: 'error', text: 'Missing redirect response.' })
} catch {
setMessage({
type: 'error',
text: 'Network error. Please try again.',
})
} finally {
submitting = false
submittingDecision = null
handle.update()
}
}
Expand Down Expand Up @@ -204,18 +215,30 @@ 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 =
(isInvalidRedirectUriMessage(queryError) ||
isInvalidRedirectUriMessage(message?.text)) &&
!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 (
<section css={pageCss}>
Expand Down Expand Up @@ -258,6 +281,30 @@ export function OAuthAuthorizeRoute(handle: Handle) {
{message.text}
</p>
) : null}
{showResetClientCard ? (
<section css={cardCss}>
<p css={sectionTitleCss}>Reset stored connection</p>
<p css={descriptionCss}>
Delete this trusted client&apos;s saved registration and grants,
then start the connection again from the client to create a
fresh record.
</p>
{isLoggedIn ? (
<button
type="button"
disabled={resetClientDisabled}
on={{ click: () => submitDecision('reset-client') }}
css={dangerButtonCss}
>
{resetClientLabel}
</button>
) : isSessionReady ? (
<p css={descriptionCss}>
Sign in first, then delete the stored client record.
</p>
) : null}
</section>
) : null}
<form
css={{
...cardCss,
Expand Down Expand Up @@ -332,3 +379,7 @@ const secondaryButtonCss = getSecondaryButtonCss({
size: 'lg',
weight: 'semibold',
})
const dangerButtonCss = getDangerButtonCss({
size: 'lg',
weight: 'semibold',
})
167 changes: 165 additions & 2 deletions packages/worker/src/oauth-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,75 @@ async function resolveAuthRequest(helpers: OAuthHelpers, request: Request) {
}
}

function readClientIdFromAuthorizeRequest(request: Request) {
const clientId = new URL(request.url).searchParams.get('client_id')?.trim()
return clientId ? clientId : null
}

function isLoopbackHostname(hostname: string) {
return hostname === 'localhost' || hostname === '::1' || hostname.startsWith('127.')
}

function redirectUriMatchesRegisteredUri(
requestUri: string,
registeredUris: Array<string>,
) {
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,
clientId: string,
) {
const grants = new Array<{ id: string }>()
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<string>) {
if (requestedScopes.length === 0) return oauthScopes
const invalidScopes = requestedScopes.filter(
Expand All @@ -99,6 +168,96 @@ function resolveScopes(requestedScopes: Array<string>) {
return requestedScopes
}

async function handleResetClientRequest(
request: Request,
env: Env,
helpers: OAuthHelpers,
requestIp?: string,
) {
const redirectUriMismatch = await requestHasRedirectUriMismatch(helpers, request)
if (!redirectUriMismatch) {
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
Expand Down Expand Up @@ -192,6 +351,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(
Expand All @@ -201,8 +366,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) {
Expand Down
Loading
Loading