@@ -104,10 +104,14 @@ export default defineEventHandler(async event => {
104104 throw error
105105 }
106106 } catch ( error ) {
107- // user cancelled explicitly
108- if ( query . error === 'access_denied' && error instanceof OAuthCallbackError && error . state ) {
107+ if ( error instanceof OAuthCallbackError && error . state ) {
108+ // Always decode the state, to clean up the cookie
109109 const state = decodeOAuthState ( event , error . state )
110- return sendRedirect ( event , state . redirectPath )
110+
111+ // user cancelled explicitly
112+ if ( query . error === 'access_denied' ) {
113+ return sendRedirect ( event , state . redirectPath )
114+ }
111115 }
112116
113117 const message = error instanceof Error ? error . message : 'Authentication failed.'
@@ -124,8 +128,7 @@ type OAuthStateData = {
124128 redirectPath : string
125129}
126130
127- const SID_COOKIE_PREFIX = 'atproto_oauth_sid'
128- const SID_COOKIE_VALUE = '1'
131+ const OAUTH_REQUEST_COOKIE_PREFIX = 'atproto_oauth_req'
129132
130133/**
131134 * This function encodes the OAuth state by generating a random SID, storing it
@@ -138,25 +141,35 @@ const SID_COOKIE_VALUE = '1'
138141 * and ensuring that the callback is part of an ongoing authentication flow
139142 * initiated by the same client.
140143 *
141- * Note that this mechanism could use any other unique session mechanism the
142- * server has with the client (e.g. UserServerSession). We don't do that though
143- * to avoid polluting the session with ephemeral OAuth-specific data.
144- *
145144 * @param event The H3 event object, used to set the cookie
146145 * @param state The original OAuth state to encode
147146 * @returns A JSON string encapsulating the original state and the generated SID
148147 */
149148function encodeOAuthState ( event : H3Event , data : OAuthStateData ) : string {
150- const sid = generateRandomHexString ( )
151- setCookie ( event , `${ SID_COOKIE_PREFIX } _${ sid } ` , SID_COOKIE_VALUE , {
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' , {
152164 maxAge : 60 * 5 ,
153165 httpOnly : true ,
154166 // secure only if NOT in dev mode
155167 secure : ! import . meta. dev ,
156168 sameSite : 'lax' ,
157169 path : event . path . split ( '?' , 1 ) [ 0 ] ,
158170 } )
159- return JSON . stringify ( { data, sid } )
171+
172+ return JSON . stringify ( { data, id } )
160173}
161174
162175function generateRandomHexString ( byteLength : number = 16 ) : string {
@@ -170,9 +183,9 @@ function generateRandomHexString(byteLength: number = 16): string {
170183 * session performing the oauth callback.
171184 *
172185 * @param event The H3 event object, used to read and delete the cookie
173- * @param state The JSON string containing the original state and SID
174- * @returns The original OAuth state if the SID is valid
175- * @throws An error if the SID is missing or invalid, indicating a potential issue with cookies or expired state
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
176189 */
177190function decodeOAuthState ( event : H3Event , state : string | null ) : OAuthStateData {
178191 if ( ! state ) {
@@ -187,11 +200,14 @@ function decodeOAuthState(event: H3Event, state: string | null): OAuthStateData
187200
188201 // The state sting was encoded using encodeOAuthState. No need to protect
189202 // against JSON parsing since the StateStore should ensure it's integrity.
190- const decoded = JSON . parse ( state ) as { data : OAuthStateData ; sid : string }
203+ const decoded = JSON . parse ( state ) as { data : OAuthStateData ; id : string }
204+ const requestCookieName = `${ OAUTH_REQUEST_COOKIE_PREFIX } _${ decoded . id } `
191205
192- const sid = getCookie ( event , `${ SID_COOKIE_PREFIX } _${ decoded . sid } ` )
193- if ( sid === SID_COOKIE_VALUE ) {
194- deleteCookie ( event , `${ SID_COOKIE_PREFIX } _${ decoded . sid } ` , {
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 , {
195211 httpOnly : true ,
196212 secure : ! import . meta. dev ,
197213 sameSite : 'lax' ,
0 commit comments