···11-import { NodeOAuthClient } from '@atproto/oauth-client-node'
22-import { createError, getQuery, sendRedirect } from 'h3'
11+import type { OAuthSession } from '@atproto/oauth-client-node'
22+import { NodeOAuthClient, OAuthCallbackError } from '@atproto/oauth-client-node'
33+import { createError, getQuery, sendRedirect, setCookie, getCookie, deleteCookie } from 'h3'
44+import type { H3Event } from 'h3'
35import { getOAuthLock } from '#server/utils/atproto/lock'
46import { useOAuthStorage } from '#server/utils/atproto/storage'
57import { SLINGSHOT_HOST } from '#shared/utils/constants'
68import { useServerSession } from '#server/utils/server-session'
79import { handleResolver } from '#server/utils/atproto/oauth'
1010+import { handleApiError } from '#server/utils/error-handler'
1111+import type { DidString } from '@atproto/lex'
812import { Client } from '@atproto/lex'
913import * as com from '#shared/types/lexicons/com'
1014import * as app from '#shared/types/lexicons/app'
1115import { isAtIdentifierString } from '@atproto/lex'
1616+import { scope, getOauthClientMetadata } from '#server/utils/atproto/oauth'
1717+import { UNSET_NUXT_SESSION_PASSWORD } from '#shared/utils/constants'
1218// @ts-expect-error virtual file from oauth module
1319import { clientUri } from '#oauth/config'
14201515-//I did not have luck with other ones than these. I got this list from the PDS language picker
1616-const OAUTH_LOCALES = new Set(['en', 'fr-FR', 'ja-JP'])
1717-1818-/**
1919- * Fetch the user's profile record to get their avatar blob reference
2020- * @param did
2121- * @param pds
2222- * @returns
2323- */
2424-async function getAvatar(did: string, pds: string) {
2525- if (!isAtIdentifierString(did)) {
2626- return undefined
2727- }
2828-2929- let avatar: string | undefined
3030- try {
3131- const pdsUrl = new URL(pds)
3232- // Only fetch from HTTPS PDS endpoints to prevent SSRF
3333- if (pdsUrl.protocol === 'https:') {
3434- const client = new Client(pdsUrl)
3535- const profileResponse = await client.get(app.bsky.actor.profile, {
3636- repo: did,
3737- rkey: 'self',
3838- })
3939-4040- const validatedResponse = app.bsky.actor.profile.main.validate(profileResponse.value)
4141-4242- if (validatedResponse.avatar?.ref) {
4343- // Use Bluesky CDN for faster image loading
4444- avatar = `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${validatedResponse.avatar?.ref}@jpeg`
4545- }
4646- }
4747- } catch {
4848- // Avatar fetch failed, continue without it
4949- }
5050- return avatar
5151-}
5252-5321export default defineEventHandler(async event => {
5422 const config = useRuntimeConfig(event)
5523 if (!config.sessionPassword) {
···7240 handleResolver,
7341 })
74427575- const error = query.error
7676-7777- // user cancelled explicitly
7878- if (error === 'access_denied') {
7979- const returnToURL = getCookie(event, 'auth_return_to') || '/'
8080- deleteCookie(event, 'auth_return_to', { path: '/' })
8181- return sendRedirect(event, returnToURL)
8282- }
4343+ if (query.handle) {
4444+ // Initiate auth flow
4545+ if (
4646+ typeof query.handle !== 'string' ||
4747+ (!query.handle.startsWith('https://') && !isAtIdentifierString(query.handle))
4848+ ) {
4949+ throw createError({
5050+ statusCode: 400,
5151+ message: 'Invalid handle parameter',
5252+ })
5353+ }
83548484- if (!query.code) {
8555 // Validate returnTo is a safe relative path (prevent open redirect)
8656 // Only set cookie on initial auth request, not the callback
8757 let redirectPath = '/'
···9565 // Invalid URL, fall back to root
9666 }
97679898- setCookie(event, 'auth_return_to', redirectPath, {
9999- maxAge: 60 * 5,
100100- httpOnly: true,
101101- // secure only if NOT in dev mode
102102- secure: !import.meta.dev,
103103- })
10468 try {
105105- const handle = query.handle?.toString()
106106- const create = query.create?.toString()
107107-108108- if (!handle) {
109109- throw createError({
110110- statusCode: 401,
111111- message: 'Handle not provided in query',
112112- })
113113- }
114114-115115- const localeFromQuery = query.locale?.toString() ?? 'en'
116116- const locale = OAUTH_LOCALES.has(localeFromQuery) ? localeFromQuery : 'en'
117117-118118- const redirectUrl = await atclient.authorize(handle, {
6969+ const redirectUrl = await atclient.authorize(query.handle, {
11970 scope,
120120- prompt: create ? 'create' : undefined,
121121- ui_locales: locale,
7171+ prompt: query.create ? 'create' : undefined,
7272+ ui_locales: query.locale?.toString(),
7373+ state: encodeOAuthState(event, { redirectPath }),
12274 })
7575+12376 return sendRedirect(event, redirectUrl.toString())
12477 } catch (error) {
125125- const message = error instanceof Error ? error.message : 'Authentication failed.'
7878+ const message = error instanceof Error ? error.message : 'Failed to initiate authentication.'
1267912780 return handleApiError(error, {
12881 statusCode: 401,
···13083 message: `${message}. Please login and try again.`,
13184 })
13285 }
8686+ } else {
8787+ // Handle callback
8888+ try {
8989+ const params = new URLSearchParams(query as Record<string, string>)
9090+ const result = await atclient.callback(params)
9191+ try {
9292+ const state = decodeOAuthState(event, result.state)
9393+ const profile = await getMiniProfile(result.session)
9494+9595+ await session.update({ public: profile })
9696+ return sendRedirect(event, state.redirectPath)
9797+ } catch (error) {
9898+ // If we are unable to cleanly handle the callback, meaning that the
9999+ // user won't be able to use the session, we sign them out of the
100100+ // session to prevent dangling sessions. This can happen if the state is
101101+ // invalid (e.g. user has cookies disabled, or the state expired) or if
102102+ // there is an issue fetching the user's profile after authentication.
103103+ await result.session.signOut()
104104+ throw error
105105+ }
106106+ } catch (error) {
107107+ if (error instanceof OAuthCallbackError && error.state) {
108108+ // Always decode the state, to clean up the cookie
109109+ const state = decodeOAuthState(event, error.state)
110110+111111+ // user cancelled explicitly
112112+ if (query.error === 'access_denied') {
113113+ return sendRedirect(event, state.redirectPath)
114114+ }
115115+ }
116116+117117+ const message = error instanceof Error ? error.message : 'Authentication failed.'
118118+ return handleApiError(error, {
119119+ statusCode: 401,
120120+ statusMessage: 'Unauthorized',
121121+ message: `${message}. Please login and try again.`,
122122+ })
123123+ }
133124 }
125125+})
134126135135- const { session: authSession } = await atclient.callback(
136136- new URLSearchParams(query as Record<string, string>),
137137- )
127127+type OAuthStateData = {
128128+ redirectPath: string
129129+}
130130+131131+const OAUTH_REQUEST_COOKIE_PREFIX = 'atproto_oauth_req'
138132133133+/**
134134+ * This function encodes the OAuth state by generating a random SID, storing it
135135+ * in a cookie, and returning a JSON string containing the original state and
136136+ * the SID. The cookie is used to validate the authenticity of the callback
137137+ * request later.
138138+ *
139139+ * This mechanism allows to bind a particular authentication request to a
140140+ * particular client (browser) session, providing protection against CSRF attacks
141141+ * and ensuring that the callback is part of an ongoing authentication flow
142142+ * initiated by the same client.
143143+ *
144144+ * @param event The H3 event object, used to set the cookie
145145+ * @param state The original OAuth state to encode
146146+ * @returns A JSON string encapsulating the original state and the generated SID
147147+ */
148148+function encodeOAuthState(event: H3Event, data: OAuthStateData): string {
149149+ const id = generateRandomHexString()
150150+ // This uses an ephemeral cookie instead of useSession() to avoid polluting
151151+ // the session with ephemeral OAuth-specific data. The cookie is set with a
152152+ // short expiration time to limit the window of potential misuse, and is
153153+ // deleted immediately after validating the callback to clean up any remnants
154154+ // of the authentication flow. Using useSession() for this would require
155155+ // additional logic to clean up the session in case of expired ephemeral data.
156156+157157+ // We use the id as cookie name to allow multiple concurrent auth flows (e.g.
158158+ // user opens multiple tabs and initiates auth in both, or initiates auth,
159159+ // waits for a while, then initiates again before completing the first one),
160160+ // without risk of cookie value collisions between them. The cookie value is a
161161+ // constant since the actual value doesn't matter - it's just used as a flag
162162+ // to validate the presence of the cookie on callback.
163163+ setCookie(event, `${OAUTH_REQUEST_COOKIE_PREFIX}_${id}`, '1', {
164164+ maxAge: 60 * 5,
165165+ httpOnly: true,
166166+ // secure only if NOT in dev mode
167167+ secure: !import.meta.dev,
168168+ sameSite: 'lax',
169169+ path: event.path.split('?', 1)[0],
170170+ })
171171+172172+ return JSON.stringify({ data, id })
173173+}
174174+175175+function generateRandomHexString(byteLength: number = 16): string {
176176+ return Array.from(crypto.getRandomValues(new Uint8Array(byteLength)), byte =>
177177+ byte.toString(16).padStart(2, '0'),
178178+ ).join('')
179179+}
180180+181181+/**
182182+ * This function ensures that an oauth state was indeed encoded for the browser
183183+ * session performing the oauth callback.
184184+ *
185185+ * @param event The H3 event object, used to read and delete the cookie
186186+ * @param state The JSON string containing the original state and id
187187+ * @returns The original OAuth state if the id is valid
188188+ * @throws An error if the id is missing or invalid, indicating a potential issue with cookies or expired state
189189+ */
190190+function decodeOAuthState(event: H3Event, state: string | null): OAuthStateData {
191191+ if (!state) {
192192+ // May happen during transition period (if a user initiated auth flow before
193193+ // the release with the new state handling, then tries to complete it after
194194+ // the release).
195195+ throw createError({
196196+ statusCode: 400,
197197+ message: 'Missing state parameter',
198198+ })
199199+ }
200200+201201+ // The state sting was encoded using encodeOAuthState. No need to protect
202202+ // against JSON parsing since the StateStore should ensure it's integrity.
203203+ const decoded = JSON.parse(state) as { data: OAuthStateData; id: string }
204204+ const requestCookieName = `${OAUTH_REQUEST_COOKIE_PREFIX}_${decoded.id}`
205205+206206+ if (getCookie(event, requestCookieName) != null) {
207207+ // The cookie will never be used again since the state store ensure unique
208208+ // nonces, but we delete it to clean up any remnants of the authentication
209209+ // flow.
210210+ deleteCookie(event, requestCookieName, {
211211+ httpOnly: true,
212212+ secure: !import.meta.dev,
213213+ sameSite: 'lax',
214214+ path: event.path.split('?', 1)[0],
215215+ })
216216+ } else {
217217+ throw createError({
218218+ statusCode: 400,
219219+ message: 'Missing authentication state. Please enable cookies and try again.',
220220+ })
221221+ }
222222+223223+ return decoded.data
224224+}
225225+226226+/**
227227+ * Fetches the mini profile for the authenticated user, including their avatar if available.
228228+ * This is used to populate the session with basic user info after authentication.
229229+ * @param authSession The OAuth session containing the user's DID and token info
230230+ * @returns An object containing the user's DID, handle, PDS, and avatar URL (if available)
231231+ */
232232+async function getMiniProfile(authSession: OAuthSession) {
139233 const client = new Client({ service: `https://${SLINGSHOT_HOST}` })
140234 const response = await client.xrpcSafe(com['bad-example'].identity.resolveMiniDoc, {
141235 headers: { 'User-Agent': 'npmx' },
142236 params: { identifier: authSession.did },
143237 })
238238+144239 if (response.success) {
145240 const miniDoc = response.body
146241147242 let avatar: string | undefined = await getAvatar(authSession.did, miniDoc.pds)
148243149149- await session.update({
150150- public: {
151151- ...miniDoc,
152152- avatar,
153153- },
154154- })
244244+ return {
245245+ ...miniDoc,
246246+ avatar,
247247+ }
155248 } else {
156249 //If slingshot fails we still want to set some key info we need.
157250 const pdsBase = (await authSession.getTokenInfo()).aud
158251 let avatar: string | undefined = await getAvatar(authSession.did, pdsBase)
159159- await session.update({
160160- public: {
161161- did: authSession.did,
162162- handle: 'Not available',
163163- pds: pdsBase,
164164- avatar,
165165- },
166166- })
252252+ return {
253253+ did: authSession.did,
254254+ handle: 'Not available',
255255+ pds: pdsBase,
256256+ avatar,
257257+ }
167258 }
259259+}
168260169169- const returnToURL = getCookie(event, 'auth_return_to') || '/'
170170- deleteCookie(event, 'auth_return_to')
261261+/**
262262+ * Fetch the user's profile record to get their avatar blob reference
263263+ * @param did
264264+ * @param pds
265265+ * @returns
266266+ */
267267+async function getAvatar(did: DidString, pds: string) {
268268+ let avatar: string | undefined
269269+ try {
270270+ const pdsUrl = new URL(pds)
271271+ // Only fetch from HTTPS PDS endpoints to prevent SSRF
272272+ if (pdsUrl.protocol === 'https:') {
273273+ const client = new Client(pdsUrl)
274274+ const profileResponse = await client.get(app.bsky.actor.profile, {
275275+ repo: did,
276276+ rkey: 'self',
277277+ })
171278172172- return sendRedirect(event, returnToURL)
173173-})
279279+ const validatedResponse = app.bsky.actor.profile.main.validate(profileResponse.value)
280280+ const cid = validatedResponse.avatar?.ref
281281+282282+ if (cid) {
283283+ // Use Bluesky CDN for faster image loading
284284+ avatar = `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${cid}@jpeg`
285285+ }
286286+ }
287287+ } catch {
288288+ // Avatar fetch failed, continue without it
289289+ }
290290+ return avatar
291291+}