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'
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'
10+ import { handleApiError } from '#server/utils/error-handler'
11+ 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'
16+ import { scope , getOauthClientMetadata } from '#server/utils/atproto/oauth'
17+ import { UNSET_NUXT_SESSION_PASSWORD } from '#shared/utils/constants'
1218// @ts -expect-error virtual file from oauth module
1319import { clientUri } from '#oauth/config'
1420
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-
5321export default defineEventHandler ( async event => {
5422 const config = useRuntimeConfig ( event )
5523 if ( ! config . sessionPassword ) {
@@ -72,16 +40,18 @@ export default defineEventHandler(async event => {
7240 handleResolver,
7341 } )
7442
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+ }
8354
84- 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 = '/'
@@ -95,79 +65,227 @@ export default defineEventHandler(async event => {
9565 // Invalid URL, fall back to root
9666 }
9767
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- } )
10468 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 , {
11970 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 } ) ,
12274 } )
75+
12376 return sendRedirect ( event , redirectUrl . toString ( ) )
12477 } catch ( error ) {
125- const message = error instanceof Error ? error . message : 'Authentication failed.'
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 )
12694
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.'
127118 return handleApiError ( error , {
128119 statusCode : 401 ,
129120 statusMessage : 'Unauthorized' ,
130121 message : `${ message } . Please login and try again.` ,
131122 } )
132123 }
133124 }
125+ } )
126+
127+ type OAuthStateData = {
128+ redirectPath : string
129+ }
130+
131+ const 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+ */
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+ }
134200
135- const { session : authSession } = await atclient . callback (
136- new URLSearchParams ( query as Record < string , string > ) ,
137- )
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 } `
138205
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 ) {
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 } )
238+
144239 if ( response . success ) {
145240 const miniDoc = response . body
146241
147242 let avatar : string | undefined = await getAvatar ( authSession . did , miniDoc . pds )
148243
149- await session . update ( {
150- public : {
151- ...miniDoc ,
152- avatar,
153- } ,
154- } )
244+ return {
245+ ...miniDoc ,
246+ avatar,
247+ }
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 )
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+ }
167258 }
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+ */
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+ } )
168278
169- const returnToURL = getCookie ( event , 'auth_return_to' ) || '/'
170- deleteCookie ( event , 'auth_return_to' )
279+ const validatedResponse = app . bsky . actor . profile . main . validate ( profileResponse . value )
280+ const cid = validatedResponse . avatar ?. ref
171281
172- return sendRedirect ( event , returnToURL )
173- } )
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+ }
0 commit comments