Skip to content

Commit d6558e0

Browse files
fix(oauth): properly bind oauth state data to the device
1 parent b73edaf commit d6558e0

1 file changed

Lines changed: 178 additions & 92 deletions

File tree

server/api/auth/atproto.get.ts

Lines changed: 178 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,20 @@
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'
23
import { createError, getQuery, sendRedirect } 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 type { DidString } from '@atproto/lex'
811
import { Client } from '@atproto/lex'
912
import * as com from '#shared/types/lexicons/com'
1013
import * as app from '#shared/types/lexicons/app'
1114
import { isAtIdentifierString } from '@atproto/lex'
1215
// @ts-expect-error virtual file from oauth module
1316
import { 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-
5318
export 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

Comments
 (0)