[READ-ONLY] a fast, modern browser for the npm registry
at main 291 lines 11 kB view raw
1import type { OAuthSession } from '@atproto/oauth-client-node' 2import { NodeOAuthClient, OAuthCallbackError } from '@atproto/oauth-client-node' 3import { createError, getQuery, sendRedirect, setCookie, getCookie, deleteCookie } from 'h3' 4import type { H3Event } from 'h3' 5import { getOAuthLock } from '#server/utils/atproto/lock' 6import { useOAuthStorage } from '#server/utils/atproto/storage' 7import { SLINGSHOT_HOST } from '#shared/utils/constants' 8import { useServerSession } from '#server/utils/server-session' 9import { handleResolver } from '#server/utils/atproto/oauth' 10import { handleApiError } from '#server/utils/error-handler' 11import type { DidString } from '@atproto/lex' 12import { Client } from '@atproto/lex' 13import * as com from '#shared/types/lexicons/com' 14import * as app from '#shared/types/lexicons/app' 15import { isAtIdentifierString } from '@atproto/lex' 16import { scope, getOauthClientMetadata } from '#server/utils/atproto/oauth' 17import { UNSET_NUXT_SESSION_PASSWORD } from '#shared/utils/constants' 18// @ts-expect-error virtual file from oauth module 19import { clientUri } from '#oauth/config' 20 21export default defineEventHandler(async event => { 22 const config = useRuntimeConfig(event) 23 if (!config.sessionPassword) { 24 throw createError({ 25 status: 500, 26 message: UNSET_NUXT_SESSION_PASSWORD, 27 }) 28 } 29 30 const query = getQuery(event) 31 const clientMetadata = getOauthClientMetadata() 32 const session = await useServerSession(event) 33 const { stateStore, sessionStore } = useOAuthStorage(session) 34 35 const atclient = new NodeOAuthClient({ 36 stateStore, 37 sessionStore, 38 clientMetadata, 39 requestLock: getOAuthLock(), 40 handleResolver, 41 }) 42 43 if (query.handle) { 44 // Initiate auth flow 45 if ( 46 typeof query.handle !== 'string' || 47 (!query.handle.startsWith('https://') && !isAtIdentifierString(query.handle)) 48 ) { 49 throw createError({ 50 statusCode: 400, 51 message: 'Invalid handle parameter', 52 }) 53 } 54 55 // Validate returnTo is a safe relative path (prevent open redirect) 56 // Only set cookie on initial auth request, not the callback 57 let redirectPath = '/' 58 try { 59 const clientOrigin = new URL(clientUri).origin 60 const returnToUrl = new URL(query.returnTo?.toString() || '/', clientUri) 61 if (returnToUrl.origin === clientOrigin) { 62 redirectPath = returnToUrl.pathname + returnToUrl.search + returnToUrl.hash 63 } 64 } catch { 65 // Invalid URL, fall back to root 66 } 67 68 try { 69 const redirectUrl = await atclient.authorize(query.handle, { 70 scope, 71 prompt: query.create ? 'create' : undefined, 72 ui_locales: query.locale?.toString(), 73 state: encodeOAuthState(event, { redirectPath }), 74 }) 75 76 return sendRedirect(event, redirectUrl.toString()) 77 } catch (error) { 78 const message = error instanceof Error ? error.message : 'Failed to initiate authentication.' 79 80 return handleApiError(error, { 81 statusCode: 401, 82 statusMessage: 'Unauthorized', 83 message: `${message}. Please login and try again.`, 84 }) 85 } 86 } else { 87 // Handle callback 88 try { 89 const params = new URLSearchParams(query as Record<string, string>) 90 const result = await atclient.callback(params) 91 try { 92 const state = decodeOAuthState(event, result.state) 93 const profile = await getMiniProfile(result.session) 94 95 await session.update({ public: profile }) 96 return sendRedirect(event, state.redirectPath) 97 } catch (error) { 98 // If we are unable to cleanly handle the callback, meaning that the 99 // user won't be able to use the session, we sign them out of the 100 // session to prevent dangling sessions. This can happen if the state is 101 // invalid (e.g. user has cookies disabled, or the state expired) or if 102 // there is an issue fetching the user's profile after authentication. 103 await result.session.signOut() 104 throw error 105 } 106 } catch (error) { 107 if (error instanceof OAuthCallbackError && error.state) { 108 // Always decode the state, to clean up the cookie 109 const state = decodeOAuthState(event, error.state) 110 111 // user cancelled explicitly 112 if (query.error === 'access_denied') { 113 return sendRedirect(event, state.redirectPath) 114 } 115 } 116 117 const message = error instanceof Error ? error.message : 'Authentication failed.' 118 return handleApiError(error, { 119 statusCode: 401, 120 statusMessage: 'Unauthorized', 121 message: `${message}. Please login and try again.`, 122 }) 123 } 124 } 125}) 126 127type OAuthStateData = { 128 redirectPath: string 129} 130 131const OAUTH_REQUEST_COOKIE_PREFIX = 'atproto_oauth_req' 132 133/** 134 * This function encodes the OAuth state by generating a random SID, storing it 135 * in a cookie, and returning a JSON string containing the original state and 136 * the SID. The cookie is used to validate the authenticity of the callback 137 * request later. 138 * 139 * This mechanism allows to bind a particular authentication request to a 140 * particular client (browser) session, providing protection against CSRF attacks 141 * and ensuring that the callback is part of an ongoing authentication flow 142 * initiated by the same client. 143 * 144 * @param event The H3 event object, used to set the cookie 145 * @param state The original OAuth state to encode 146 * @returns A JSON string encapsulating the original state and the generated SID 147 */ 148function encodeOAuthState(event: H3Event, data: OAuthStateData): string { 149 const id = generateRandomHexString() 150 // This uses an ephemeral cookie instead of useSession() to avoid polluting 151 // the session with ephemeral OAuth-specific data. The cookie is set with a 152 // short expiration time to limit the window of potential misuse, and is 153 // deleted immediately after validating the callback to clean up any remnants 154 // of the authentication flow. Using useSession() for this would require 155 // additional logic to clean up the session in case of expired ephemeral data. 156 157 // We use the id as cookie name to allow multiple concurrent auth flows (e.g. 158 // user opens multiple tabs and initiates auth in both, or initiates auth, 159 // waits for a while, then initiates again before completing the first one), 160 // without risk of cookie value collisions between them. The cookie value is a 161 // constant since the actual value doesn't matter - it's just used as a flag 162 // to validate the presence of the cookie on callback. 163 setCookie(event, `${OAUTH_REQUEST_COOKIE_PREFIX}_${id}`, '1', { 164 maxAge: 60 * 5, 165 httpOnly: true, 166 // secure only if NOT in dev mode 167 secure: !import.meta.dev, 168 sameSite: 'lax', 169 path: event.path.split('?', 1)[0], 170 }) 171 172 return JSON.stringify({ data, id }) 173} 174 175function generateRandomHexString(byteLength: number = 16): string { 176 return Array.from(crypto.getRandomValues(new Uint8Array(byteLength)), byte => 177 byte.toString(16).padStart(2, '0'), 178 ).join('') 179} 180 181/** 182 * This function ensures that an oauth state was indeed encoded for the browser 183 * session performing the oauth callback. 184 * 185 * @param event The H3 event object, used to read and delete the cookie 186 * @param state The JSON string containing the original state and id 187 * @returns The original OAuth state if the id is valid 188 * @throws An error if the id is missing or invalid, indicating a potential issue with cookies or expired state 189 */ 190function decodeOAuthState(event: H3Event, state: string | null): OAuthStateData { 191 if (!state) { 192 // May happen during transition period (if a user initiated auth flow before 193 // the release with the new state handling, then tries to complete it after 194 // the release). 195 throw createError({ 196 statusCode: 400, 197 message: 'Missing state parameter', 198 }) 199 } 200 201 // The state sting was encoded using encodeOAuthState. No need to protect 202 // against JSON parsing since the StateStore should ensure it's integrity. 203 const decoded = JSON.parse(state) as { data: OAuthStateData; id: string } 204 const requestCookieName = `${OAUTH_REQUEST_COOKIE_PREFIX}_${decoded.id}` 205 206 if (getCookie(event, requestCookieName) != null) { 207 // The cookie will never be used again since the state store ensure unique 208 // nonces, but we delete it to clean up any remnants of the authentication 209 // flow. 210 deleteCookie(event, requestCookieName, { 211 httpOnly: true, 212 secure: !import.meta.dev, 213 sameSite: 'lax', 214 path: event.path.split('?', 1)[0], 215 }) 216 } else { 217 throw createError({ 218 statusCode: 400, 219 message: 'Missing authentication state. Please enable cookies and try again.', 220 }) 221 } 222 223 return decoded.data 224} 225 226/** 227 * Fetches the mini profile for the authenticated user, including their avatar if available. 228 * This is used to populate the session with basic user info after authentication. 229 * @param authSession The OAuth session containing the user's DID and token info 230 * @returns An object containing the user's DID, handle, PDS, and avatar URL (if available) 231 */ 232async function getMiniProfile(authSession: OAuthSession) { 233 const client = new Client({ service: `https://${SLINGSHOT_HOST}` }) 234 const response = await client.xrpcSafe(com['bad-example'].identity.resolveMiniDoc, { 235 headers: { 'User-Agent': 'npmx' }, 236 params: { identifier: authSession.did }, 237 }) 238 239 if (response.success) { 240 const miniDoc = response.body 241 242 let avatar: string | undefined = await getAvatar(authSession.did, miniDoc.pds) 243 244 return { 245 ...miniDoc, 246 avatar, 247 } 248 } else { 249 //If slingshot fails we still want to set some key info we need. 250 const pdsBase = (await authSession.getTokenInfo()).aud 251 let avatar: string | undefined = await getAvatar(authSession.did, pdsBase) 252 return { 253 did: authSession.did, 254 handle: 'Not available', 255 pds: pdsBase, 256 avatar, 257 } 258 } 259} 260 261/** 262 * Fetch the user's profile record to get their avatar blob reference 263 * @param did 264 * @param pds 265 * @returns 266 */ 267async function getAvatar(did: DidString, pds: string) { 268 let avatar: string | undefined 269 try { 270 const pdsUrl = new URL(pds) 271 // Only fetch from HTTPS PDS endpoints to prevent SSRF 272 if (pdsUrl.protocol === 'https:') { 273 const client = new Client(pdsUrl) 274 const profileResponse = await client.get(app.bsky.actor.profile, { 275 repo: did, 276 rkey: 'self', 277 }) 278 279 const validatedResponse = app.bsky.actor.profile.main.validate(profileResponse.value) 280 const cid = validatedResponse.avatar?.ref 281 282 if (cid) { 283 // Use Bluesky CDN for faster image loading 284 avatar = `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${cid}@jpeg` 285 } 286 } 287 } catch { 288 // Avatar fetch failed, continue without it 289 } 290 return avatar 291}