Skip to content

Comments

fix: properly bind OAuth state data to a browser session#1327

Merged
danielroe merged 6 commits intonpmx-dev:mainfrom
matthieusieben:msi/atproto-oauth
Feb 10, 2026
Merged

fix: properly bind OAuth state data to a browser session#1327
danielroe merged 6 commits intonpmx-dev:mainfrom
matthieusieben:msi/atproto-oauth

Conversation

@matthieusieben
Copy link
Contributor

@matthieusieben matthieusieben commented Feb 10, 2026

Currently, the OAuth state data (redirect path) is stored in a dedicated cookie. However, the NodeOAuthClient already provides a way to store this state data in the OAuth state store.

Relatedly, the OAuth state data should be stored server side (see #1322). Once it does, the server should verify that the browser for which it is performing the OAuth callback is indeed the browser that initiated that OAuth request.

This PR improves the handling of the OAuth state by:

  1. Using the OAuth client state store to store OAuth state data (redirect uri), instead of a custom cookie.
  2. Ensuring that the OAuth state data matches the right device (using an ephemeral session cookie)

@vercel
Copy link

vercel bot commented Feb 10, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
npmx.dev Ready Ready Preview, Comment Feb 10, 2026 0:03am
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
docs.npmx.dev Ignored Ignored Preview Feb 10, 2026 0:03am
npmx-lunaria Ignored Ignored Feb 10, 2026 0:03am

Request Review

@codecov
Copy link

codecov bot commented Feb 10, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ All tests successful. No failed tests found.

📢 Thoughts on this report? Let us know!

@matthieusieben matthieusieben changed the title fix(oauth): properly bind oauth state data to the device feat(oauth): properly bind oauth state data to the device Feb 10, 2026
import { clientUri } from '#oauth/config'

//I did not have luck with other ones than these. I got this list from the PDS language picker
const OAUTH_LOCALES = new Set(['en', 'fr-FR', 'ja-JP'])
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Other Atproto Authorization Server implementation exists out there. We should let the AS decide how to handle the locale passed in, based on what it supports.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 10, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Reworks the atproto OAuth handler into distinct initiation and callback flows: initiation validates the handle, builds an authorize redirect and issues a per-request SID cookie via encodeOAuthState; callback decodes and validates that state with decodeOAuthState, handles user-cancel cases, fetches a mini profile and avatar via getMiniProfile/getAvatar, updates session public data, clears the SID cookie, and redirects to the original path. Adds typed OAuth state/session helpers and centralized handleApiError, and removes legacy inlined locale and avatar retrieval logic.

Possibly related PRs

Suggested reviewers

  • danielroe
🚥 Pre-merge checks | ✅ 1
✅ Passed checks (1 passed)
Check name Status Explanation
Description check ✅ Passed The PR description clearly explains the OAuth state handling improvements and directly relates to the changeset modifications.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

No actionable comments were generated in the recent review. 🎉


Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
server/api/auth/atproto.get.ts (1)

55-70: ⚠️ Potential issue | 🟡 Minor

Query parameters may be arrays; toString() silently concatenates them.

getQuery returns values that can be arrays. Using ?.toString() on query.returnTo (line 57) and query.locale (line 69) will silently convert ['a', 'b'] to 'a,b', which could produce unexpected redirect paths or invalid locale strings. Consider normalising these parameters explicitly.

🛡️ Suggested fix to validate single-value parameters
+function getSingleQueryParam(value: unknown): string | undefined {
+  if (typeof value === 'string') return value
+  if (Array.isArray(value) && value.length === 1 && typeof value[0] === 'string') return value[0]
+  return undefined
+}
+
 // Validate returnTo is a safe relative path (prevent open redirect)
 // Only set cookie on initial auth request, not the callback
 let redirectPath = '/'
 try {
   const clientOrigin = new URL(clientUri).origin
-  const returnToUrl = new URL(query.returnTo?.toString() || '/', clientUri)
+  const returnToUrl = new URL(getSingleQueryParam(query.returnTo) || '/', clientUri)
   if (returnToUrl.origin === clientOrigin) {
     redirectPath = returnToUrl.pathname + returnToUrl.search + returnToUrl.hash
   }
 } catch {
   // Invalid URL, fall back to root
 }

 try {
   const redirectUrl = await atclient.authorize(query.handle, {
     scope,
     prompt: query.create ? 'create' : undefined,
-    ui_locales: query.locale?.toString(),
+    ui_locales: getSingleQueryParam(query.locale),
     state: encodeOAuthState(event, { redirectPath }),
   })
🧹 Nitpick comments (3)
server/api/auth/atproto.get.ts (3)

146-155: Consider adding sameSite and path attributes to the SID cookie.

The cookie is missing sameSite and path attributes. Adding sameSite: 'lax' provides additional CSRF protection, and setting path to the callback endpoint limits cookie exposure.

🔧 Suggested improvement
   setCookie(event, `${SID_COOKIE_NAME}:${sid}`, SID_COOKIE_VALUE, {
     maxAge: 60 * 5,
     httpOnly: true,
     // secure only if NOT in dev mode
     secure: !import.meta.dev,
+    sameSite: 'lax',
+    path: '/api/auth/atproto',
   })

Note: Ensure the same path is used in deleteCookie within decodeOAuthState.


183-200: Add runtime validation for parsed state structure.

The type assertion as { data: OAuthStateData; sid: string } at line 185 assumes the parsed JSON has the expected shape. If the state is malformed, accessing decoded.sid or decoded.data could throw or return undefined, leading to unexpected behaviour. Consider adding basic runtime validation.

🛡️ Suggested fix to add validation
   // The state string was encoded using encodeOAuthState. No need to protect
   // against JSON parsing since the StateStore should ensure its integrity.
-  const decoded = JSON.parse(state) as { data: OAuthStateData; sid: string }
+  let decoded: { data: OAuthStateData; sid: string }
+  try {
+    const parsed = JSON.parse(state)
+    if (
+      typeof parsed !== 'object' ||
+      parsed === null ||
+      typeof parsed.sid !== 'string' ||
+      typeof parsed.data?.redirectPath !== 'string'
+    ) {
+      throw new Error('Invalid state structure')
+    }
+    decoded = parsed as { data: OAuthStateData; sid: string }
+  } catch {
+    throw createError({
+      statusCode: 400,
+      message: 'Invalid state parameter',
+    })
+  }

256-261: Store ref in a variable to avoid redundant optional chaining.

The validatedResponse.avatar?.ref expression is evaluated twice. Since the condition at line 258 already confirms ref exists, the optional chain at line 260 is redundant. Storing it in a variable improves clarity.

♻️ Suggested refactor
-      if (validatedResponse.avatar?.ref) {
+      const avatarRef = validatedResponse.avatar?.ref
+      if (avatarRef) {
         // Use Bluesky CDN for faster image loading
-        avatar = `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${validatedResponse.avatar?.ref}@jpeg`
+        avatar = `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${avatarRef}@jpeg`
       }

@matthieusieben matthieusieben changed the title feat(oauth): properly bind oauth state data to the device fix: properly bind OAuth state data to a browser session Feb 10, 2026
Comment on lines 149 to 161
function encodeOAuthState(event: H3Event, data: OAuthStateData): string {
const sid = generateRandomHexString()
setCookie(event, `${SID_COOKIE_PREFIX}_${sid}`, SID_COOKIE_VALUE, {
maxAge: 60 * 5,
httpOnly: true,
// secure only if NOT in dev mode
secure: !import.meta.dev,
sameSite: 'lax',
path: event.path.split('?', 1)[0],
})
return JSON.stringify({ data, sid })
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useSession generates an encrypted, stateless cookie that validates it was issued by the server

doesn't that provide enough protection here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does. This way of doing things allows to keep the "oauth sid" (the name might be poorly chosen) ephemeral without having to cleanup the h3 cookie.

Copy link
Contributor Author

@matthieusieben matthieusieben Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For example, if the user initiates an oauth flow (causing the useSession cookie to contain that "sid" value), then the user performs a "back" navigation. In that case, the "sid" would probably not be removed from the useSession cookie, unless every time we read that cookie, we performed additional logic to clean things up. I wanted to avoid doing that.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to explain this in the JSdoc right above.

@danielroe danielroe added this pull request to the merge queue Feb 10, 2026
Merged via the queue into npmx-dev:main with commit 6884aac Feb 10, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants