Skip to content
Merged
Changes from 2 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
270 changes: 178 additions & 92 deletions server/api/auth/atproto.get.ts
Original file line number Diff line number Diff line change
@@ -1,55 +1,20 @@
import { NodeOAuthClient } from '@atproto/oauth-client-node'
import type { OAuthSession } from '@atproto/oauth-client-node'
import { NodeOAuthClient, OAuthCallbackError } from '@atproto/oauth-client-node'
import { createError, getQuery, sendRedirect } from 'h3'
import type { H3Event } from 'h3'
import { getOAuthLock } from '#server/utils/atproto/lock'
import { useOAuthStorage } from '#server/utils/atproto/storage'
import { SLINGSHOT_HOST } from '#shared/utils/constants'
import { useServerSession } from '#server/utils/server-session'
import { handleResolver } from '#server/utils/atproto/oauth'
import type { DidString } from '@atproto/lex'
import { Client } from '@atproto/lex'
import * as com from '#shared/types/lexicons/com'
import * as app from '#shared/types/lexicons/app'
import { isAtIdentifierString } from '@atproto/lex'
// @ts-expect-error virtual file from oauth module
import { clientUri } from '#oauth/config'
Comment thread
coderabbitai[bot] marked this conversation as resolved.

//I did not have luck with other ones than these. I got this list from the PDS language picker
const OAUTH_LOCALES = new Set(['en', 'fr-FR', 'ja-JP'])
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Other Atproto Authorization Server implementation exists out there. We should let the AS decide how to handle the locale passed in, based on what it supports.


/**
* Fetch the user's profile record to get their avatar blob reference
* @param did
* @param pds
* @returns
*/
async function getAvatar(did: string, pds: string) {
if (!isAtIdentifierString(did)) {
return undefined
}

let avatar: string | undefined
try {
const pdsUrl = new URL(pds)
// Only fetch from HTTPS PDS endpoints to prevent SSRF
if (pdsUrl.protocol === 'https:') {
const client = new Client(pdsUrl)
const profileResponse = await client.get(app.bsky.actor.profile, {
repo: did,
rkey: 'self',
})

const validatedResponse = app.bsky.actor.profile.main.validate(profileResponse.value)

if (validatedResponse.avatar?.ref) {
// Use Bluesky CDN for faster image loading
avatar = `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${validatedResponse.avatar?.ref}@jpeg`
}
}
} catch {
// Avatar fetch failed, continue without it
}
return avatar
}

export default defineEventHandler(async event => {
const config = useRuntimeConfig(event)
if (!config.sessionPassword) {
Expand All @@ -72,16 +37,18 @@ export default defineEventHandler(async event => {
handleResolver,
})

const error = query.error

// user cancelled explicitly
if (error === 'access_denied') {
const returnToURL = getCookie(event, 'auth_return_to') || '/'
deleteCookie(event, 'auth_return_to', { path: '/' })
return sendRedirect(event, returnToURL)
}
if (query.handle) {
// Initiate auth flow
if (
typeof query.handle !== 'string' ||
(!query.handle.startsWith('https://') && !isAtIdentifierString(query.handle))
) {
throw createError({
statusCode: 400,
message: 'Invalid handle parameter',
})
}

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 = '/'
Expand All @@ -95,79 +62,198 @@ export default defineEventHandler(async event => {
// 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()

if (!handle) {
throw createError({
statusCode: 401,
message: 'Handle not provided in query',
})
}

const localeFromQuery = query.locale?.toString() ?? 'en'
const locale = OAUTH_LOCALES.has(localeFromQuery) ? localeFromQuery : 'en'

const redirectUrl = await atclient.authorize(handle, {
const redirectUrl = await atclient.authorize(query.handle, {
scope,
prompt: create ? 'create' : undefined,
ui_locales: locale,
prompt: query.create ? 'create' : undefined,
ui_locales: query.locale?.toString(),
state: encodeOAuthState(event, { redirectPath }),
})

return sendRedirect(event, redirectUrl.toString())
} catch (error) {
const message = error instanceof Error ? error.message : 'Authentication failed.'
const message = error instanceof Error ? error.message : 'Failed to initiate authentication.'

return handleApiError(error, {
statusCode: 401,
statusMessage: 'Unauthorized',
message: `${message}. Please login and try again.`,
})
}
} else {
// Handle callback
try {
const params = new URLSearchParams(query as Record<string, string>)
const result = await atclient.callback(params)

Comment thread
matthieusieben marked this conversation as resolved.
await session.update({
public: await getMiniProfile(result.session),
})

const state = decodeOAuthState(event, result.state)
return sendRedirect(event, state.redirectPath)
} catch (error) {
// user cancelled explicitly
if (query.error === 'access_denied' && error instanceof OAuthCallbackError && error.state) {
const state = decodeOAuthState(event, error.state)
return sendRedirect(event, state.redirectPath)
}

const message = error instanceof Error ? error.message : 'Authentication failed.'
return handleApiError(error, {
statusCode: 401,
statusMessage: 'Unauthorized',
message: `${message}. Please login and try again.`,
})
}
}
})

type OAuthStateData = {
redirectPath: string
}

const SID_COOKIE_NAME = 'atproto_oauth_sid'
const SID_COOKIE_VALUE = '1'

/**
* This function encodes the OAuth state by generating a random SID, storing it
* in a cookie, and returning a JSON string containing the original state and
* the SID. The cookie is used to validate the authenticity of the callback
* request later.
*
* This mechanism allows to bind a particular authentication request to a
* particular client (browser) session, providing protection against CSRF attacks
* and ensuring that the callback is part of an ongoing authentication flow
* initiated by the same client.
*
* Note that this mechanism could use any other unique session mechanism the
* server has with the client (e.g. UserServerSession). We don't do that though
* to avoid polluting the session with ephemeral OAuth-specific data.
*
* @param event The H3 event object, used to set the cookie
* @param state The original OAuth state to encode
* @returns A JSON string encapsulating the original state and the generated SID
*/
function encodeOAuthState(event: H3Event, data: OAuthStateData): string {
const sid = generateRandomHexString()
setCookie(event, `${SID_COOKIE_NAME}:${sid}`, SID_COOKIE_VALUE, {
maxAge: 60 * 5,
httpOnly: true,
// secure only if NOT in dev mode
secure: !import.meta.dev,
})
return JSON.stringify({ data, sid })
}

Comment on lines +148 to +174
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useSession generates an encrypted, stateless cookie that validates it was issued by the server

doesn't that provide enough protection here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does. This way of doing things allows to keep the "oauth sid" (the name might be poorly chosen) ephemeral without having to cleanup the h3 cookie.

Copy link
Copy Markdown
Contributor Author

@matthieusieben matthieusieben Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For example, if the user initiates an oauth flow (causing the useSession cookie to contain that "sid" value), then the user performs a "back" navigation. In that case, the "sid" would probably not be removed from the useSession cookie, unless every time we read that cookie, we performed additional logic to clean things up. I wanted to avoid doing that.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to explain this in the JSdoc right above.

function generateRandomHexString(byteLength: number = 16): string {
return Array.from(crypto.getRandomValues(new Uint8Array(byteLength)), byte =>
byte.toString(16).padStart(2, '0'),
).join('')
}

/**
* This function ensures that an oauth state was indeed encoded for the browser
* session performing the oauth callback.
*
* @param event The H3 event object, used to read and delete the cookie
* @param state The JSON string containing the original state and SID
* @returns The original OAuth state if the SID is valid
* @throws An error if the SID is missing or invalid, indicating a potential issue with cookies or expired state
*/
function decodeOAuthState(event: H3Event, state: string | null): OAuthStateData {
if (!state) {
// May happen during transition period (if a user initiated auth flow before
// the release with the new state handling, then tries to complete it after
// the release).
throw createError({
statusCode: 400,
message: 'Missing state parameter',
})
}

const { session: authSession } = await atclient.callback(
new URLSearchParams(query as Record<string, string>),
)
// The state sting was encoded using encodeOAuthState. No need to protect
// against JSON parsing since the StateStore should ensure it's integrity.
const decoded = JSON.parse(state) as { data: OAuthStateData; sid: string }

const sid = getCookie(event, `${SID_COOKIE_NAME}:${decoded.sid}`)
if (sid === SID_COOKIE_VALUE) {
deleteCookie(event, `${SID_COOKIE_NAME}:${decoded.sid}`, {
httpOnly: true,
secure: !import.meta.dev,
})
} else {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
throw createError({
statusCode: 400,
message: 'Missing authentication state. Please enable cookies and try again.',
})
}

return decoded.data
}

/**
* Fetches the mini profile for the authenticated user, including their avatar if available.
* This is used to populate the session with basic user info after authentication.
* @param authSession The OAuth session containing the user's DID and token info
* @returns An object containing the user's DID, handle, PDS, and avatar URL (if available)
*/
async function getMiniProfile(authSession: OAuthSession) {
const client = new Client({ service: `https://${SLINGSHOT_HOST}` })
const response = await client.xrpcSafe(com['bad-example'].identity.resolveMiniDoc, {
headers: { 'User-Agent': 'npmx' },
params: { identifier: authSession.did },
})

if (response.success) {
const miniDoc = response.body

let avatar: string | undefined = await getAvatar(authSession.did, miniDoc.pds)

await session.update({
public: {
...miniDoc,
avatar,
},
})
return {
...miniDoc,
avatar,
}
} else {
//If slingshot fails we still want to set some key info we need.
const pdsBase = (await authSession.getTokenInfo()).aud
let avatar: string | undefined = await getAvatar(authSession.did, pdsBase)
await session.update({
public: {
did: authSession.did,
handle: 'Not available',
pds: pdsBase,
avatar,
},
})
return {
did: authSession.did,
handle: 'Not available',
pds: pdsBase,
avatar,
}
}
}

const returnToURL = getCookie(event, 'auth_return_to') || '/'
deleteCookie(event, 'auth_return_to')
/**
* Fetch the user's profile record to get their avatar blob reference
* @param did
* @param pds
* @returns
*/
async function getAvatar(did: DidString, pds: string) {
let avatar: string | undefined
try {
const pdsUrl = new URL(pds)
// Only fetch from HTTPS PDS endpoints to prevent SSRF
if (pdsUrl.protocol === 'https:') {
const client = new Client(pdsUrl)
const profileResponse = await client.get(app.bsky.actor.profile, {
repo: did,
rkey: 'self',
})

return sendRedirect(event, returnToURL)
})
const validatedResponse = app.bsky.actor.profile.main.validate(profileResponse.value)

if (validatedResponse.avatar?.ref) {
// Use Bluesky CDN for faster image loading
avatar = `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${validatedResponse.avatar?.ref}@jpeg`
}
}
} catch {
// Avatar fetch failed, continue without it
}
return avatar
}
Loading