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
6 changes: 3 additions & 3 deletions app/components/Header/AuthModal.client.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
12 changes: 10 additions & 2 deletions app/utils/atproto/helpers.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down
27 changes: 26 additions & 1 deletion server/api/auth/atproto.get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
})
Loading