[READ-ONLY] a fast, modern browser for the npm registry

fix: properly bind OAuth state data to a browser session (#1327)

authored by

Matthieu Sieben and committed by
GitHub
6884aac6 dc038d12

+211 -93
+211 -93
server/api/auth/atproto.get.ts
··· 1 - import { NodeOAuthClient } from '@atproto/oauth-client-node' 2 - import { createError, getQuery, sendRedirect } from 'h3' 1 + import type { OAuthSession } from '@atproto/oauth-client-node' 2 + import { NodeOAuthClient, OAuthCallbackError } from '@atproto/oauth-client-node' 3 + import { createError, getQuery, sendRedirect, setCookie, getCookie, deleteCookie } from 'h3' 4 + import type { H3Event } from 'h3' 3 5 import { getOAuthLock } from '#server/utils/atproto/lock' 4 6 import { useOAuthStorage } from '#server/utils/atproto/storage' 5 7 import { SLINGSHOT_HOST } from '#shared/utils/constants' 6 8 import { useServerSession } from '#server/utils/server-session' 7 9 import { handleResolver } from '#server/utils/atproto/oauth' 10 + import { handleApiError } from '#server/utils/error-handler' 11 + import type { DidString } from '@atproto/lex' 8 12 import { Client } from '@atproto/lex' 9 13 import * as com from '#shared/types/lexicons/com' 10 14 import * as app from '#shared/types/lexicons/app' 11 15 import { isAtIdentifierString } from '@atproto/lex' 16 + import { scope, getOauthClientMetadata } from '#server/utils/atproto/oauth' 17 + import { UNSET_NUXT_SESSION_PASSWORD } from '#shared/utils/constants' 12 18 // @ts-expect-error virtual file from oauth module 13 19 import { clientUri } from '#oauth/config' 14 20 15 - //I did not have luck with other ones than these. I got this list from the PDS language picker 16 - const OAUTH_LOCALES = new Set(['en', 'fr-FR', 'ja-JP']) 17 - 18 - /** 19 - * Fetch the user's profile record to get their avatar blob reference 20 - * @param did 21 - * @param pds 22 - * @returns 23 - */ 24 - async function getAvatar(did: string, pds: string) { 25 - if (!isAtIdentifierString(did)) { 26 - return undefined 27 - } 28 - 29 - let avatar: string | undefined 30 - try { 31 - const pdsUrl = new URL(pds) 32 - // Only fetch from HTTPS PDS endpoints to prevent SSRF 33 - if (pdsUrl.protocol === 'https:') { 34 - const client = new Client(pdsUrl) 35 - const profileResponse = await client.get(app.bsky.actor.profile, { 36 - repo: did, 37 - rkey: 'self', 38 - }) 39 - 40 - const validatedResponse = app.bsky.actor.profile.main.validate(profileResponse.value) 41 - 42 - if (validatedResponse.avatar?.ref) { 43 - // Use Bluesky CDN for faster image loading 44 - avatar = `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${validatedResponse.avatar?.ref}@jpeg` 45 - } 46 - } 47 - } catch { 48 - // Avatar fetch failed, continue without it 49 - } 50 - return avatar 51 - } 52 - 53 21 export default defineEventHandler(async event => { 54 22 const config = useRuntimeConfig(event) 55 23 if (!config.sessionPassword) { ··· 72 40 handleResolver, 73 41 }) 74 42 75 - const error = query.error 76 - 77 - // user cancelled explicitly 78 - if (error === 'access_denied') { 79 - const returnToURL = getCookie(event, 'auth_return_to') || '/' 80 - deleteCookie(event, 'auth_return_to', { path: '/' }) 81 - return sendRedirect(event, returnToURL) 82 - } 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 + } 83 54 84 - if (!query.code) { 85 55 // Validate returnTo is a safe relative path (prevent open redirect) 86 56 // Only set cookie on initial auth request, not the callback 87 57 let redirectPath = '/' ··· 95 65 // Invalid URL, fall back to root 96 66 } 97 67 98 - setCookie(event, 'auth_return_to', redirectPath, { 99 - maxAge: 60 * 5, 100 - httpOnly: true, 101 - // secure only if NOT in dev mode 102 - secure: !import.meta.dev, 103 - }) 104 68 try { 105 - const handle = query.handle?.toString() 106 - const create = query.create?.toString() 107 - 108 - if (!handle) { 109 - throw createError({ 110 - statusCode: 401, 111 - message: 'Handle not provided in query', 112 - }) 113 - } 114 - 115 - const localeFromQuery = query.locale?.toString() ?? 'en' 116 - const locale = OAUTH_LOCALES.has(localeFromQuery) ? localeFromQuery : 'en' 117 - 118 - const redirectUrl = await atclient.authorize(handle, { 69 + const redirectUrl = await atclient.authorize(query.handle, { 119 70 scope, 120 - prompt: create ? 'create' : undefined, 121 - ui_locales: locale, 71 + prompt: query.create ? 'create' : undefined, 72 + ui_locales: query.locale?.toString(), 73 + state: encodeOAuthState(event, { redirectPath }), 122 74 }) 75 + 123 76 return sendRedirect(event, redirectUrl.toString()) 124 77 } catch (error) { 125 - const message = error instanceof Error ? error.message : 'Authentication failed.' 78 + const message = error instanceof Error ? error.message : 'Failed to initiate authentication.' 126 79 127 80 return handleApiError(error, { 128 81 statusCode: 401, ··· 130 83 message: `${message}. Please login and try again.`, 131 84 }) 132 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 + } 133 124 } 125 + }) 134 126 135 - const { session: authSession } = await atclient.callback( 136 - new URLSearchParams(query as Record<string, string>), 137 - ) 127 + type OAuthStateData = { 128 + redirectPath: string 129 + } 130 + 131 + const OAUTH_REQUEST_COOKIE_PREFIX = 'atproto_oauth_req' 138 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 + */ 148 + function 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 + 175 + function 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 + */ 190 + function 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 + */ 232 + async function getMiniProfile(authSession: OAuthSession) { 139 233 const client = new Client({ service: `https://${SLINGSHOT_HOST}` }) 140 234 const response = await client.xrpcSafe(com['bad-example'].identity.resolveMiniDoc, { 141 235 headers: { 'User-Agent': 'npmx' }, 142 236 params: { identifier: authSession.did }, 143 237 }) 238 + 144 239 if (response.success) { 145 240 const miniDoc = response.body 146 241 147 242 let avatar: string | undefined = await getAvatar(authSession.did, miniDoc.pds) 148 243 149 - await session.update({ 150 - public: { 151 - ...miniDoc, 152 - avatar, 153 - }, 154 - }) 244 + return { 245 + ...miniDoc, 246 + avatar, 247 + } 155 248 } else { 156 249 //If slingshot fails we still want to set some key info we need. 157 250 const pdsBase = (await authSession.getTokenInfo()).aud 158 251 let avatar: string | undefined = await getAvatar(authSession.did, pdsBase) 159 - await session.update({ 160 - public: { 161 - did: authSession.did, 162 - handle: 'Not available', 163 - pds: pdsBase, 164 - avatar, 165 - }, 166 - }) 252 + return { 253 + did: authSession.did, 254 + handle: 'Not available', 255 + pds: pdsBase, 256 + avatar, 257 + } 167 258 } 259 + } 168 260 169 - const returnToURL = getCookie(event, 'auth_return_to') || '/' 170 - deleteCookie(event, 'auth_return_to') 261 + /** 262 + * Fetch the user's profile record to get their avatar blob reference 263 + * @param did 264 + * @param pds 265 + * @returns 266 + */ 267 + async 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 + }) 171 278 172 - return sendRedirect(event, returnToURL) 173 - }) 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 + }