diff --git a/app/components/Header/AuthModal.client.vue b/app/components/Header/AuthModal.client.vue index 1a93e51b7..806fd9f14 100644 --- a/app/components/Header/AuthModal.client.vue +++ b/app/components/Header/AuthModal.client.vue @@ -3,15 +3,15 @@ import { useAtproto } from '~/composables/atproto/useAtproto' import { authRedirect } from '~/utils/atproto/helpers' const handleInput = shallowRef('') - +const route = useRoute() const { user, logout } = useAtproto() async function handleBlueskySignIn() { - await authRedirect('https://bsky.social') + await authRedirect('https://bsky.social', { redirectTo: route.fullPath }) } async function handleCreateAccount() { - await authRedirect('https://npmx.social', true) + await authRedirect('https://npmx.social', { create: true, redirectTo: route.fullPath }) } async function handleLogin() { diff --git a/app/utils/atproto/helpers.ts b/app/utils/atproto/helpers.ts index 9166cef1e..dd4693a64 100644 --- a/app/utils/atproto/helpers.ts +++ b/app/utils/atproto/helpers.ts @@ -1,14 +1,22 @@ import type { FetchError } from 'ofetch' import type { LocationQueryRaw } from 'vue-router' +interface AuthRedirectOptions { + create?: boolean + redirectTo?: string +} + /** * Redirect user to ATProto authentication */ -export async function authRedirect(identifier: string, create: boolean = false) { +export async function authRedirect(identifier: string, options: AuthRedirectOptions = {}) { let query: LocationQueryRaw = { handle: identifier } - if (create) { + if (options.create) { query = { ...query, create: 'true' } } + if (options.redirectTo) { + query = { ...query, returnTo: options.redirectTo } + } await navigateTo( { path: '/api/auth/atproto', diff --git a/server/api/auth/atproto.get.ts b/server/api/auth/atproto.get.ts index 581c91294..cfd76b4ec 100644 --- a/server/api/auth/atproto.get.ts +++ b/server/api/auth/atproto.get.ts @@ -10,6 +10,8 @@ import { handleResolver } from '#server/utils/atproto/oauth' import { Client } from '@atproto/lex' import * as app from '#shared/types/lexicons/app' import { ensureValidAtIdentifier } from '@atproto/syntax' +// @ts-expect-error virtual file from oauth module +import { clientUri } from '#oauth/config' /** * Fetch the user's profile record to get their avatar blob reference @@ -66,6 +68,25 @@ export default defineEventHandler(async event => { }) if (!query.code) { + // Validate returnTo is a safe relative path (prevent open redirect) + // Only set cookie on initial auth request, not the callback + let redirectPath = '/' + try { + const clientOrigin = new URL(clientUri).origin + const returnToUrl = new URL(query.returnTo?.toString() || '/', clientUri) + if (returnToUrl.origin === clientOrigin) { + redirectPath = returnToUrl.pathname + returnToUrl.search + returnToUrl.hash + } + } catch { + // Invalid URL, fall back to root + } + + setCookie(event, 'auth_return_to', redirectPath, { + maxAge: 60 * 5, + httpOnly: true, + // secure only if NOT in dev mode + secure: !import.meta.dev, + }) try { const handle = query.handle?.toString() const create = query.create?.toString() @@ -126,5 +147,9 @@ export default defineEventHandler(async event => { }, }) } - return sendRedirect(event, '/') + + const returnToURL = getCookie(event, 'auth_return_to') || '/' + deleteCookie(event, 'auth_return_to') + + return sendRedirect(event, returnToURL) })