Skip to content

Commit 6884aac

Browse files
fix: properly bind OAuth state data to a browser session (#1327)
1 parent dc038d1 commit 6884aac

File tree

1 file changed

+211
-93
lines changed

1 file changed

+211
-93
lines changed

server/api/auth/atproto.get.ts

Lines changed: 211 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,23 @@
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'
35
import { getOAuthLock } from '#server/utils/atproto/lock'
46
import { useOAuthStorage } from '#server/utils/atproto/storage'
57
import { SLINGSHOT_HOST } from '#shared/utils/constants'
68
import { useServerSession } from '#server/utils/server-session'
79
import { handleResolver } from '#server/utils/atproto/oauth'
10+
import { handleApiError } from '#server/utils/error-handler'
11+
import type { DidString } from '@atproto/lex'
812
import { Client } from '@atproto/lex'
913
import * as com from '#shared/types/lexicons/com'
1014
import * as app from '#shared/types/lexicons/app'
1115
import { 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
1319
import { 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-
5321
export 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

Comments
 (0)