1- import { NodeOAuthClient } from '@atproto/oauth-client-node'
1+ import type { OAuthSession } from '@atproto/oauth-client-node'
2+ import { NodeOAuthClient , OAuthCallbackError } from '@atproto/oauth-client-node'
23import { createError , getQuery , sendRedirect } 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 type { DidString } from '@atproto/lex'
811import { Client } from '@atproto/lex'
912import * as com from '#shared/types/lexicons/com'
1013import * as app from '#shared/types/lexicons/app'
1114import { isAtIdentifierString } from '@atproto/lex'
1215// @ts -expect-error virtual file from oauth module
1316import { clientUri } from '#oauth/config'
1417
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-
5318export default defineEventHandler ( async event => {
5419 const config = useRuntimeConfig ( event )
5520 if ( ! config . sessionPassword ) {
@@ -72,16 +37,18 @@ export default defineEventHandler(async event => {
7237 handleResolver,
7338 } )
7439
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- }
40+ if ( query . handle ) {
41+ // Initiate auth flow
42+ if (
43+ typeof query . handle !== 'string' ||
44+ ( ! query . handle . startsWith ( 'https://' ) && ! isAtIdentifierString ( query . handle ) )
45+ ) {
46+ throw createError ( {
47+ statusCode : 400 ,
48+ message : 'Invalid handle parameter' ,
49+ } )
50+ }
8351
84- if ( ! query . code ) {
8552 // Validate returnTo is a safe relative path (prevent open redirect)
8653 // Only set cookie on initial auth request, not the callback
8754 let redirectPath = '/'
@@ -95,79 +62,198 @@ export default defineEventHandler(async event => {
9562 // Invalid URL, fall back to root
9663 }
9764
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- } )
10465 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 , {
66+ const redirectUrl = await atclient . authorize ( query . handle , {
11967 scope,
120- prompt : create ? 'create' : undefined ,
121- ui_locales : locale ,
68+ prompt : query . create ? 'create' : undefined ,
69+ ui_locales : query . locale ?. toString ( ) ,
70+ state : encodeOAuthState ( event , { redirectPath } ) ,
12271 } )
72+
12373 return sendRedirect ( event , redirectUrl . toString ( ) )
12474 } catch ( error ) {
125- const message = error instanceof Error ? error . message : 'Authentication failed .'
75+ const message = error instanceof Error ? error . message : 'Failed to initiate authentication .'
12676
12777 return handleApiError ( error , {
12878 statusCode : 401 ,
12979 statusMessage : 'Unauthorized' ,
13080 message : `${ message } . Please login and try again.` ,
13181 } )
13282 }
83+ } else {
84+ // Handle callback
85+ try {
86+ const params = new URLSearchParams ( query as Record < string , string > )
87+ const result = await atclient . callback ( params )
88+
89+ await session . update ( {
90+ public : await getMiniProfile ( result . session ) ,
91+ } )
92+
93+ const state = decodeOAuthState ( event , result . state )
94+ return sendRedirect ( event , state . redirectPath )
95+ } catch ( error ) {
96+ // user cancelled explicitly
97+ if ( query . error === 'access_denied' && error instanceof OAuthCallbackError && error . state ) {
98+ const state = decodeOAuthState ( event , error . state )
99+ return sendRedirect ( event , state . redirectPath )
100+ }
101+
102+ const message = error instanceof Error ? error . message : 'Authentication failed.'
103+ return handleApiError ( error , {
104+ statusCode : 401 ,
105+ statusMessage : 'Unauthorized' ,
106+ message : `${ message } . Please login and try again.` ,
107+ } )
108+ }
109+ }
110+ } )
111+
112+ type OAuthStateData = {
113+ redirectPath : string
114+ }
115+
116+ const SID_COOKIE_NAME = 'atproto_oauth_sid'
117+ const SID_COOKIE_VALUE = '1'
118+
119+ /**
120+ * This function encodes the OAuth state by generating a random SID, storing it
121+ * in a cookie, and returning a JSON string containing the original state and
122+ * the SID. The cookie is used to validate the authenticity of the callback
123+ * request later.
124+ *
125+ * This mechanism allows to bind a particular authentication request to a
126+ * particular client (browser) session, providing protection against CSRF attacks
127+ * and ensuring that the callback is part of an ongoing authentication flow
128+ * initiated by the same client.
129+ *
130+ * Note that this mechanism could use any other unique session mechanism the
131+ * server has with the client (e.g. UserServerSession). We don't do that though
132+ * to avoid polluting the session with ephemeral OAuth-specific data.
133+ *
134+ * @param event The H3 event object, used to set the cookie
135+ * @param state The original OAuth state to encode
136+ * @returns A JSON string encapsulating the original state and the generated SID
137+ */
138+ function encodeOAuthState ( event : H3Event , data : OAuthStateData ) : string {
139+ const sid = generateRandomHexString ( )
140+ setCookie ( event , `${ SID_COOKIE_NAME } :${ sid } ` , SID_COOKIE_VALUE , {
141+ maxAge : 60 * 5 ,
142+ httpOnly : true ,
143+ // secure only if NOT in dev mode
144+ secure : ! import . meta. dev ,
145+ } )
146+ return JSON . stringify ( { data, sid } )
147+ }
148+
149+ function generateRandomHexString ( byteLength : number = 16 ) : string {
150+ return Array . from ( crypto . getRandomValues ( new Uint8Array ( byteLength ) ) , byte =>
151+ byte . toString ( 16 ) . padStart ( 2 , '0' ) ,
152+ ) . join ( '' )
153+ }
154+
155+ /**
156+ * This function ensures that an oauth state was indeed encoded for the browser
157+ * session performing the oauth callback.
158+ *
159+ * @param event The H3 event object, used to read and delete the cookie
160+ * @param state The JSON string containing the original state and SID
161+ * @returns The original OAuth state if the SID is valid
162+ * @throws An error if the SID is missing or invalid, indicating a potential issue with cookies or expired state
163+ */
164+ function decodeOAuthState ( event : H3Event , state : string | null ) : OAuthStateData {
165+ if ( ! state ) {
166+ // May happen during transition period (if a user initiated auth flow before
167+ // the release with the new state handling, then tries to complete it after
168+ // the release).
169+ throw createError ( {
170+ statusCode : 400 ,
171+ message : 'Missing state parameter' ,
172+ } )
133173 }
134174
135- const { session : authSession } = await atclient . callback (
136- new URLSearchParams ( query as Record < string , string > ) ,
137- )
175+ // The state sting was encoded using encodeOAuthState. No need to protect
176+ // against JSON parsing since the StateStore should ensure it's integrity.
177+ const decoded = JSON . parse ( state ) as { data : OAuthStateData ; sid : string }
138178
179+ const sid = getCookie ( event , `${ SID_COOKIE_NAME } :${ decoded . sid } ` )
180+ if ( sid === SID_COOKIE_VALUE ) {
181+ deleteCookie ( event , `${ SID_COOKIE_NAME } :${ decoded . sid } ` , {
182+ httpOnly : true ,
183+ secure : ! import . meta. dev ,
184+ } )
185+ } else {
186+ throw createError ( {
187+ statusCode : 400 ,
188+ message : 'Missing authentication state. Please enable cookies and try again.' ,
189+ } )
190+ }
191+
192+ return decoded . data
193+ }
194+
195+ /**
196+ * Fetches the mini profile for the authenticated user, including their avatar if available.
197+ * This is used to populate the session with basic user info after authentication.
198+ * @param authSession The OAuth session containing the user's DID and token info
199+ * @returns An object containing the user's DID, handle, PDS, and avatar URL (if available)
200+ */
201+ async function getMiniProfile ( authSession : OAuthSession ) {
139202 const client = new Client ( { service : `https://${ SLINGSHOT_HOST } ` } )
140203 const response = await client . xrpcSafe ( com [ 'bad-example' ] . identity . resolveMiniDoc , {
141204 headers : { 'User-Agent' : 'npmx' } ,
142205 params : { identifier : authSession . did } ,
143206 } )
207+
144208 if ( response . success ) {
145209 const miniDoc = response . body
146210
147211 let avatar : string | undefined = await getAvatar ( authSession . did , miniDoc . pds )
148212
149- await session . update ( {
150- public : {
151- ...miniDoc ,
152- avatar,
153- } ,
154- } )
213+ return {
214+ ...miniDoc ,
215+ avatar,
216+ }
155217 } else {
156218 //If slingshot fails we still want to set some key info we need.
157219 const pdsBase = ( await authSession . getTokenInfo ( ) ) . aud
158220 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- } )
221+ return {
222+ did : authSession . did ,
223+ handle : 'Not available' ,
224+ pds : pdsBase ,
225+ avatar,
226+ }
167227 }
228+ }
168229
169- const returnToURL = getCookie ( event , 'auth_return_to' ) || '/'
170- deleteCookie ( event , 'auth_return_to' )
230+ /**
231+ * Fetch the user's profile record to get their avatar blob reference
232+ * @param did
233+ * @param pds
234+ * @returns
235+ */
236+ async function getAvatar ( did : DidString , pds : string ) {
237+ let avatar : string | undefined
238+ try {
239+ const pdsUrl = new URL ( pds )
240+ // Only fetch from HTTPS PDS endpoints to prevent SSRF
241+ if ( pdsUrl . protocol === 'https:' ) {
242+ const client = new Client ( pdsUrl )
243+ const profileResponse = await client . get ( app . bsky . actor . profile , {
244+ repo : did ,
245+ rkey : 'self' ,
246+ } )
171247
172- return sendRedirect ( event , returnToURL )
173- } )
248+ const validatedResponse = app . bsky . actor . profile . main . validate ( profileResponse . value )
249+
250+ if ( validatedResponse . avatar ?. ref ) {
251+ // Use Bluesky CDN for faster image loading
252+ avatar = `https://cdn.bsky.app/img/feed_thumbnail/plain/${ did } /${ validatedResponse . avatar ?. ref } @jpeg`
253+ }
254+ }
255+ } catch {
256+ // Avatar fetch failed, continue without it
257+ }
258+ return avatar
259+ }
0 commit comments