forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
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}