Skip to content

Conversation

@fatfingers23
Copy link
Contributor

@fatfingers23 fatfingers23 commented Feb 11, 2026

This moves the OAuth session/state store back server-side. This should resolve #840, resolves #1322, and I think we can go ahead and close #870 and create new issues on new bugs

  • Moves the oauth stores back to the backend to add more security and hopefully allow the oauth lock to do it's thing. The session stores holds a randomly generated UUID in the encrypted cookie that is combined with the users did to hopefully give each browser a unique session and does not cause collisions on token refresh.
  • Added the confidential client since users will need to relog for this chaneg already. We will have to set the env variable NUXT_OAUTH_JWK_ONE on production before we do the next release, or find a better home for it. This value can be gotten from pnpm run generate:jwk and is a secret. This will hopefully allow oauth sessions to last much longer than 2 weeks
  • One time redirect to relogin if the old oauth session store is being used to help those upgrade those logins to use the new store and conf client

@matthieusieben was kind enough to offer a review of this PR, so I am going to tag him. Thanks again for looking this over and for the other atproto help!

@vercel
Copy link

vercel bot commented Feb 11, 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 11, 2026 4:31am
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
docs.npmx.dev Ignored Ignored Preview Feb 11, 2026 4:31am
npmx-lunaria Ignored Ignored Feb 11, 2026 4:31am

Request Review

@codecov
Copy link

codecov bot commented Feb 11, 2026

Codecov Report

❌ Patch coverage is 33.33333% with 2 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
app/components/Header/AuthModal.client.vue 33.33% 1 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 11, 2026

📝 Walkthrough

Walkthrough

Centralises OAuth client construction and session handling, migrating OAuth session and state storage from per-request/in-memory fields to a storage-backed, per-session scheme using a new OAUTH_CACHE_STORAGE_BASE. Adds JWK support (runtime config field, JWKS route, and a generation script), updates OAuth route handlers and metadata to load keys asynchronously, introduces getNodeOAuthClient and related helpers, changes session/state store APIs to key-scoped storage operations, and adds a client-side watcher to trigger reauthentication when the server session marks relogin.

Possibly related PRs

  • PR #905 — Overlaps with OAuth flow refactors (session/state stores, getOAuthSession/eventHandlerWithOAuthSession) and related route/handler changes.
  • PR #1327 — Refactors OAuth session/state persistence to storage-backed, per-session keys; directly related to the storage and API changes here.
  • PR #712 — Client-side AuthModal reauthentication changes that interact with authRedirect and relogin signalling.

Suggested reviewers

  • danielroe
🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR addresses OAuth security and session management from #840 and #1322, but does not remove the unused i18n config file explicitly mentioned in #840. Remove the unused /i18n/i18n.config.ts file as specified in linked issue #840 to fully resolve that issue.
✅ Passed checks (2 passed)
Check name Status Explanation
Out of Scope Changes check ✅ Passed All changes focus on OAuth server-side session/state stores, confidential client support, JWK generation, and migration flows. Changes are well-scoped to OAuth infrastructure and directly support the PR objectives.
Description check ✅ Passed The pull request description clearly relates to the changeset, detailing OAuth store migration, session management improvements, and confidential client implementation.

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

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

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: 5

🧹 Nitpick comments (3)
modules/cache.ts (1)

7-8: Centralise the OAuth storage key to avoid drift.

The same literal now exists here and in server/utils/atproto/storage.ts; consider moving it to a shared constant so updates stay in sync.

server/utils/atproto/oauth-session-store.ts (1)

10-11: Remove the duplicate assignment in the constructor.

Minor clarity win; the extra assignment is redundant.

Suggested tidy-up
   constructor(session: SessionManager<UserServerSession>) {
-    this.serverSession = this.serverSession = session
+    this.serverSession = session
   }
server/utils/atproto/oauth-state-store.ts (1)

28-34: Align error handling with the session store.

If setItem fails after oauthStateId is updated, the session will point to a missing state. Consider wrapping the update/write in a try/catch, logging the error, and rolling back oauthStateId.

Example pattern
   async set(key: string, val: NodeSavedState) {
-    let stateId = crypto.randomUUID()
-    await this.serverSession.update({
-      oauthStateId: stateId,
-    })
-    await this.storage.setItem<NodeSavedState>(this.createStorageKey(key, stateId), val)
+    const stateId = crypto.randomUUID()
+    try {
+      await this.serverSession.update({ oauthStateId: stateId })
+      await this.storage.setItem<NodeSavedState>(this.createStorageKey(key, stateId), val)
+    } catch (error) {
+      console.error(
+        '[oauth state store] Failed to set state:',
+        error instanceof Error ? error.message : 'Unknown error',
+      )
+      await this.serverSession.update({ oauthStateId: undefined })
+      throw error
+    }
   }

Comment on lines 35 to 38
runtimeConfig: {
sessionPassword: '',
oauthJwkOne: process.env.OAUTH_JWK_ONE || undefined,
// Upstash Redis for distributed OAuth token refresh locking in production
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check where each env var name is referenced
rg -n "OAUTH_JWK_ONE|NUXT_OAUTH_JWK_ONE" --type-list

Repository: npmx-dev/npmx.dev

Length of output: 450


🏁 Script executed:

#!/bin/bash
# Search for both env var references across the codebase
rg -n "OAUTH_JWK_ONE|NUXT_OAUTH_JWK_ONE"

Repository: npmx-dev/npmx.dev

Length of output: 135


🏁 Script executed:

#!/bin/bash
# Search for other NUXT_ prefixed env vars in the codebase
rg -n "process\.env\.NUXT_"

Repository: npmx-dev/npmx.dev

Length of output: 644


🏁 Script executed:

#!/bin/bash
# Look for .env files and environment documentation
fd -type f "\.env" -o "\.env\.*" -o "*env*example*" -o "*README*" -o "*ENV*"

Repository: npmx-dev/npmx.dev

Length of output: 231


🏁 Script executed:

#!/bin/bash
# Check the nuxt.config.ts file to see the full context around line 37
cat -n nuxt.config.ts | head -50

Repository: npmx-dev/npmx.dev

Length of output: 1726


🏁 Script executed:

#!/bin/bash
# Search for sessionPassword references
rg -n "sessionPassword|SESSION_PASSWORD"

Repository: npmx-dev/npmx.dev

Length of output: 1138


🏁 Script executed:

#!/bin/bash
# Look for documentation or config files mentioning env vars
fd -type f -name "README*" -o -name "*.md" | head -10

Repository: npmx-dev/npmx.dev

Length of output: 231


🏁 Script executed:

#!/bin/bash
# Check if there's a .env.example or similar
find . -maxdepth 2 -type f \( -name ".env*" -o -name "env.*" \) 2>/dev/null

Repository: npmx-dev/npmx.dev

Length of output: 91


🏁 Script executed:

#!/bin/bash
# Read the .env.example file
cat .env.example

Repository: npmx-dev/npmx.dev

Length of output: 132


🏁 Script executed:

#!/bin/bash
# Search for OAUTH_JWK in documentation or comments
rg -n "OAUTH_JWK" -B 2 -A 2

Repository: npmx-dev/npmx.dev

Length of output: 345


🏁 Script executed:

#!/bin/bash
# Check modules/oauth.ts to understand the OAuth configuration pattern
cat -n modules/oauth.ts | head -50

Repository: npmx-dev/npmx.dev

Length of output: 1727


🏁 Script executed:

#!/bin/bash
# Search for JWKS or jwks references
rg -n "jwks|JWKS|JWK" -i

Repository: npmx-dev/npmx.dev

Length of output: 3401


🏁 Script executed:

#!/bin/bash
# Check git log for context around this change
git log --oneline -20 -- nuxt.config.ts

Repository: npmx-dev/npmx.dev

Length of output: 96


🏁 Script executed:

#!/bin/bash
# Look for any documentation about OAuth setup
fd -type f -name "*.md" | xargs rg -l "oauth|OAUTH" -i 2>/dev/null | head -5

Repository: npmx-dev/npmx.dev

Length of output: 445


Use standard Nuxt env var naming for consistency.

The oauthJwkOne config reads from OAUTH_JWK_ONE, but Nuxt convention (as used for sessionPassword with NUXT_SESSION_PASSWORD) suggests it should be NUXT_OAUTH_JWK_ONE. To avoid confusion and follow the established pattern, either rename to the prefixed variant or support both for backwards compatibility.

💡 Suggested approach (support both names)
-    oauthJwkOne: process.env.OAUTH_JWK_ONE || undefined,
+    oauthJwkOne: process.env.NUXT_OAUTH_JWK_ONE || process.env.OAUTH_JWK_ONE || undefined,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
runtimeConfig: {
sessionPassword: '',
oauthJwkOne: process.env.OAUTH_JWK_ONE || undefined,
// Upstash Redis for distributed OAuth token refresh locking in production
runtimeConfig: {
sessionPassword: '',
oauthJwkOne: process.env.NUXT_OAUTH_JWK_ONE || process.env.OAUTH_JWK_ONE || undefined,
// Upstash Redis for distributed OAuth token refresh locking in production

import * as app from '#shared/types/lexicons/app'
import { isAtIdentifierString } from '@atproto/lex'
import { scope, getOauthClientMetadata } from '#server/utils/atproto/oauth'
import { scope } from '#server/utils/atproto/oauth'
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Import getNodeOAuthClient where it is used.

It is referenced on Line 29 but not imported, so this will throw at runtime.

🔧 Proposed fix
-import { scope } from '#server/utils/atproto/oauth'
+import { getNodeOAuthClient, scope } from '#server/utils/atproto/oauth'
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import { scope } from '#server/utils/atproto/oauth'
import { getNodeOAuthClient, scope } from '#server/utils/atproto/oauth'

Comment on lines +3 to +5
const keyset = await loadJWKs(config)
// @ts-expect-error Taken from statusphere-example-app. Throws a ts error
const pk = keyset?.findPrivateKey({ use: 'sig' })
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, locate and examine the file in question
find . -name "oauth-client-metadata.json.get.ts" -type f

Repository: npmx-dev/npmx.dev

Length of output: 110


🏁 Script executed:

# Check for existing oauth utils
find . -path "*/server/utils/atproto/*" -type f

Repository: npmx-dev/npmx.dev

Length of output: 331


🏁 Script executed:

# Search for Keyset imports and usage patterns
rg "import.*Keyset|from.*oauth-client" --type ts --type tsx

Repository: npmx-dev/npmx.dev

Length of output: 88


🏁 Script executed:

# Check package.json for `@atproto/oauth-client-node` version
cat package.json | grep -A 2 -B 2 "@atproto/oauth-client"

Repository: npmx-dev/npmx.dev

Length of output: 235


🏁 Script executed:

# Read the file with the issue
cat -n ./server/routes/oauth-client-metadata.json.get.ts

Repository: npmx-dev/npmx.dev

Length of output: 413


🏁 Script executed:

# Read oauth.ts to check for existing helpers
cat -n ./server/utils/atproto/oauth.ts

Repository: npmx-dev/npmx.dev

Length of output: 7060


🏁 Script executed:

# Search for Keyset type and how it's used elsewhere
rg "Keyset|findPrivateKey" --type ts -A 3 -B 1

Repository: npmx-dev/npmx.dev

Length of output: 2001


🌐 Web query:

@atproto/oauth-client-node Keyset type findPrivateKey method

💡 Result:

In @atproto/oauth-client-node (and the core @atproto/oauth-client), the keyset you pass is not a Keyset object with a findPrivateKey() method—it’s simply a list/array of private Key objects used for private_key_jwt client authentication. The README shows keyset: [ await JoseKey.fromImportable(...), ... ]. [1]

So there is no keyset.findPrivateKey(...) to call. If you need that behavior, implement it yourself by selecting from your array (usually by kid), e.g. keyset.find(k => k.kid === kid).

To construct keys for that array, use JoseKey.fromImportable(input, kid?) (optionally supplying a kid). [2]

Sources
[1] @atproto/oauth-client README example showing keyset as an array of keys: (npmjs.com)
[2] JoseKey.fromImportable(input, kid?) in @atproto/jwk-jose: (app.unpkg.com)

Citations:


🏁 Script executed:

# Check the `@atproto/oauth-client-node` types for Keyset
find node_modules/@atproto/oauth-client-node -name "*.d.ts" -type f | head -20

Repository: npmx-dev/npmx.dev

Length of output: 135


🏁 Script executed:

# Look for Keyset type definition
rg "class Keyset|interface Keyset|type Keyset" --type ts

Repository: npmx-dev/npmx.dev

Length of output: 43


🏁 Script executed:

# Check if there are any other implementations or references to findPrivateKey
rg "findPrivateKey" --type ts -B 2 -A 2

Repository: npmx-dev/npmx.dev

Length of output: 891


🌐 Web query:

@atproto/oauth-client-node 0.3.15 Keyset API documentation

💡 Result:

keyset (confidential-client signing keys)

In @atproto/oauth-client-node@0.3.15, the “Keyset API” is the keyset option you pass into the underlying OAuthClient configuration to enable confidential client auth (token_endpoint_auth_method=private_key_jwt). The type is:

  • keyset?: Keyset | Iterable<Key> (imported from @atproto/jwk) [2]

What it’s for:

  • Provide one or more private keys used to sign the client assertion JWT for authenticated token requests.
  • These must correspond to the public keys you publish in client metadata (jwks / jwks_uri). [1][3]
  • The public JWKS corresponding to your configured private keys can be obtained via the client.jwks getter. [1]

Minimal example (Node / backend / BFF):

import { NodeOAuthClient } from '@atproto/oauth-client-node'
import { JoseKey } from '@atproto/jwk-jose'

const client = new NodeOAuthClient({
  responseMode: 'query',
  clientMetadata: {
    client_id: 'https://example.com/oauth-client-metadata.json',
    jwks_uri: 'https://example.com/jwks.json',
    token_endpoint_auth_method: 'private_key_jwt',
  },

  // ...stateStore, sessionStore, etc...

  keyset: [
    await JoseKey.fromImportable(process.env.PRIVATE_KEY_1!),
    await JoseKey.fromImportable(process.env.PRIVATE_KEY_2!),
  ],
})

Notes:

  • If you’re building a native/Electron app without a backend, you typically do not provide a keyset (you can’t safely store the private key client-side). [4]
  • Atproto OAuth expects confidential clients to publish JWKS and rotate keys; authorization servers may re-fetch metadata/JWKS and can revoke sessions if keys disappear. [3]

Sources: @atproto/oauth-client config example showing keyset and client.jwks [1], OAuthClientOptions type including keyset?: Keyset | Iterable<Key> [2], atproto OAuth spec on jwks / jwks_uri and confidential-client key expectations [3], @atproto/oauth-client-node readme note about no keyset in app-only clients [4].


🌐 Web query:

statusphere-example-app oauth keyset findPrivateKey

💡 Result:

In bluesky-social/statusphere-example-app, the OAuth “private key / keyset” is configured via the PRIVATE_KEYS environment variable (a JSON array of JWKs). In production you’re expected to generate a JWK (private key) with ./bin/gen-jwk and paste the generated JWK JSON into PRIVATE_KEYS (example shown in the README). The app uses the first key in that array as the current signing key (older keys can remain for validating existing sessions). [1]

The OAuth client in the app is built on @atproto/oauth-client-node and is initialized in the app’s authentication code (src/auth/client.ts per the project docs). [2]

Sources: GitHub README for the repo (key generation + PRIVATE_KEYS behavior) [1]; DeepWiki “Authentication System” page (OAuth client package + file locations) [2].

[1] (github.com)
[2] (deepwiki.com)

Citations:


Replace the @ts-expect-error suppression with a typed helper in both locations.

The suppression weakens type safety and violates the "strictly type-safe" guideline. This pattern appears in both server/routes/oauth-client-metadata.json.get.ts (lines 3–5) and server/utils/atproto/oauth.ts (lines 80–81), indicating a systemic need for a typed solution. Extract a helper function (e.g., getPrivateSigningKey) in server/utils/atproto/oauth.ts to encapsulate the private key selection logic and eliminate the type suppression across both files.

Example change
-  // `@ts-expect-error` Taken from statusphere-example-app. Throws a ts error
-  const pk = keyset?.findPrivateKey({ use: 'sig' })
+  const pk = getPrivateSigningKey(keyset)
🧰 Tools
🪛 GitHub Check: 💪 Type check

[failure] 3-3:
Argument of type 'RuntimeConfig' is not assignable to parameter of type 'NitroRuntimeConfig'.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 11, 2026

📝 Walkthrough

Walkthrough

The PR implements OAuth session refresh and state management improvements by introducing server-side storage for OAuth sessions and state. It adds a JWK generation script and exposes public keys via a new .well-known/jwks.json endpoint. OAuth session and state objects are migrated from direct session storage to external storage, with ephemeral IDs tracked in the server session. A factory function getNodeOAuthClient centralises OAuth client construction with runtime-config-driven key loading. The metadata endpoint becomes asynchronous to support dynamic algorithm selection based on key type. A relogin flow is introduced in the session endpoint and client components to handle session invalidation scenarios, with corresponding schema and type updates.

Possibly related PRs

Suggested reviewers

  • danielroe
🚥 Pre-merge checks | ✅ 1 | ❌ 2
❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR addresses issue #870 requirements for session refresh and edge-case handling but does not address issue #840 which requires removing an unused i18n file. Remove the unused /i18n/i18n.config.ts file as specified in issue #840 to fully satisfy all linked issue requirements.
Out of Scope Changes check ❓ Inconclusive Changes introduce OAuth confidential client infrastructure (JWK generation, metadata endpoint updates) and backend session/state stores beyond the explicit linked issue scope of OAuth edge-case handling. Clarify whether confidential client infrastructure and JWK generation features are pre-requisites for addressing issue #870 or if they constitute scope expansion beyond the linked issues.
✅ Passed checks (1 passed)
Check name Status Explanation
Description check ✅ Passed The PR description clearly relates to the changeset, describing OAuth store migration to backend, confidential client implementation, and re-login redirect flow.

✏️ 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

Tip

Issue Planner is now in beta. Read the docs and try it out! Share your feedback on Discord.


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: 6

🧹 Nitpick comments (3)
server/utils/atproto/oauth-state-store.ts (1)

28-34: Use const for stateId and consider adding error handling.

The stateId variable is never reassigned, so it should be declared with const. Additionally, the set method in OAuthSessionStore includes error handling with try/catch, but this method lacks it—consider adding consistent error handling for debugging purposes.

Proposed fix
   async set(key: string, val: NodeSavedState) {
-    let stateId = crypto.randomUUID()
-    await this.serverSession.update({
-      oauthStateId: stateId,
-    })
-    await this.storage.setItem<NodeSavedState>(this.createStorageKey(key, stateId), val)
+    const stateId = crypto.randomUUID()
+    try {
+      await this.serverSession.update({
+        oauthStateId: stateId,
+      })
+      await this.storage.setItem<NodeSavedState>(this.createStorageKey(key, stateId), val)
+    } catch (error) {
+      console.error(
+        '[oauth state store] Failed to set state:',
+        error instanceof Error ? error.message : 'Unknown error',
+      )
+      throw error
+    }
   }
server/utils/atproto/oauth-session-store.ts (2)

26-29: Use const for variables that are not reassigned.

Both session (line 26) and sessionId (line 33) are never reassigned and should use const instead of let.

Proposed fix for line 26
-    let session = await this.storage.getItem<NodeSavedSession>(
+    const session = await this.storage.getItem<NodeSavedSession>(
       this.createStorageKey(key, serverSessionData.oauthSessionId),
     )

32-38: Use const for sessionId.

The sessionId variable is never reassigned.

Proposed fix
   async set(key: string, val: NodeSavedSession) {
-    let sessionId = crypto.randomUUID()
+    const sessionId = crypto.randomUUID()
     try {

Comment on lines 35 to 38
runtimeConfig: {
sessionPassword: '',
oauthJwkOne: process.env.OAUTH_JWK_ONE || undefined,
// Upstash Redis for distributed OAuth token refresh locking in production
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

rg -n "OAUTH_JWK_ONE|NUXT_OAUTH_JWK_ONE"

Repository: npmx-dev/npmx.dev

Length of output: 135


🏁 Script executed:

# Check for environment variable references in deployment or config documentation
fd -e md -e yml -e yaml -e json | head -20 | xargs rg -l "OAUTH_JWK|env var|environment" 2>/dev/null || true

Repository: npmx-dev/npmx.dev

Length of output: 140


🏁 Script executed:

# Examine other runtimeConfig entries to understand naming pattern
cat -n nuxt.config.ts | head -50

Repository: npmx-dev/npmx.dev

Length of output: 1726


🏁 Script executed:

rg -A 3 -B 3 "OAUTH_JWK|oauth" docs/ README.md CONTRIBUTING.md 2>/dev/null || true

Repository: npmx-dev/npmx.dev

Length of output: 43


🏁 Script executed:

# Check for deployment or environment setup documentation
rg -i "env.*var|environment.*setup|deployment" docs/ README.md --max-count=5

Repository: npmx-dev/npmx.dev

Length of output: 43


🏁 Script executed:

# Search for any reference to how runtimeConfig env vars should be named
rg -i "nuxt.*env.*prefix|NUXT_|runtimeConfig" README.md docs/ CONTRIBUTING.md --max-count=10

Repository: npmx-dev/npmx.dev

Length of output: 316


Align the environment variable name with Nuxt 3 conventions.

Nuxt 3 automatically prefixes runtimeConfig entries with NUXT_ when reading environment variables (as seen in CONTRIBUTING.md with NUXT_TEST_FIXTURES). The current code reads process.env.OAUTH_JWK_ONE directly, bypassing this auto-prefixing. In standard Nuxt deployments, the variable is set as NUXT_OAUTH_JWK_ONE, so the current code will always resolve to undefined.

Either allow Nuxt's auto-prefixing to work (remove process.env access), or explicitly support both the prefixed and non-prefixed variants:

🔧 Suggested adjustment
-    oauthJwkOne: process.env.OAUTH_JWK_ONE || undefined,
+    oauthJwkOne:
+      process.env.NUXT_OAUTH_JWK_ONE ??
+      process.env.OAUTH_JWK_ONE ??
+      undefined,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
runtimeConfig: {
sessionPassword: '',
oauthJwkOne: process.env.OAUTH_JWK_ONE || undefined,
// Upstash Redis for distributed OAuth token refresh locking in production
runtimeConfig: {
sessionPassword: '',
oauthJwkOne:
process.env.NUXT_OAUTH_JWK_ONE ??
process.env.OAUTH_JWK_ONE ??
undefined,
// Upstash Redis for distributed OAuth token refresh locking in production

import * as app from '#shared/types/lexicons/app'
import { isAtIdentifierString } from '@atproto/lex'
import { scope, getOauthClientMetadata } from '#server/utils/atproto/oauth'
import { scope } from '#server/utils/atproto/oauth'
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Missing import for getNodeOAuthClient.

The function getNodeOAuthClient is called on line 29 but is not imported. This will cause a runtime error.

Proposed fix
-import { scope } from '#server/utils/atproto/oauth'
+import { scope, getNodeOAuthClient } from '#server/utils/atproto/oauth'
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import { scope } from '#server/utils/atproto/oauth'
import { scope, getNodeOAuthClient } from '#server/utils/atproto/oauth'

Comment on lines +6 to +9
if (!keys) {
console.error('Failed to load JWKs. May not be set')
return []
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Inconsistent JWKS response format on failure.

When JWKs fail to load, returning an empty array [] does not conform to the JWKS specification, which expects { "keys": [] }. OAuth clients parsing this endpoint may fail if they expect the standard JWKS structure.

Proposed fix
   if (!keys) {
     console.error('Failed to load JWKs. May not be set')
-    return []
+    return { keys: [] }
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (!keys) {
console.error('Failed to load JWKs. May not be set')
return []
}
if (!keys) {
console.error('Failed to load JWKs. May not be set')
return { keys: [] }
}

Comment on lines +1 to +6
export default defineEventHandler(async event => {
const config = useRuntimeConfig(event)
const keyset = await loadJWKs(config)
// @ts-expect-error Taken from statusphere-example-app. Throws a ts error
const pk = keyset?.findPrivateKey({ use: 'sig' })
return getOauthClientMetadata(pk?.alg)
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Find package.json to check `@atproto/oauth-client-node` version
find . -name "package.json" -type f | head -5

Repository: npmx-dev/npmx.dev

Length of output: 114


🏁 Script executed:

# Look at the file in question to understand full context
cat -n server/routes/oauth-client-metadata.json.get.ts

Repository: npmx-dev/npmx.dev

Length of output: 413


🏁 Script executed:

# Search for loadJWKs import to find its source
rg "loadJWKs" --type ts --type tsx -B2 -A2

Repository: npmx-dev/npmx.dev

Length of output: 88


🏁 Script executed:

# Search for loadJWKs import more broadly
rg "loadJWKs" -B2 -A2

Repository: npmx-dev/npmx.dev

Length of output: 1356


🏁 Script executed:

# Check main package.json for `@atproto` dependencies
cat package.json | grep -A5 -B5 "@atproto"

Repository: npmx-dev/npmx.dev

Length of output: 626


🏁 Script executed:

# Read the oauth.ts file to understand Keyset type and loadJWKs implementation
cat -n server/utils/atproto/oauth.ts

Repository: npmx-dev/npmx.dev

Length of output: 7039


🌐 Web query:

@atproto/oauth-client-node v0.3.15 Keyset findPrivateKey TypeScript types

💡 Result:

In @atproto/oauth-client-node@0.3.15, the keyset you pass in is typed by the core package as:

keyset?: Keyset | Iterable<Key | undefined | null | false>

where Keyset and Key come from @atproto/jwk. (app.unpkg.com)

In current @atproto/jwk typings, Keyset is a class and it does not have a findPrivateKey method. Instead, it exposes methods like:

  • get(search: KeySearch): K
  • list(search: KeySearch): Generator<K>
  • findKey(search: KeySearch): [key: Key, alg: string]

So keyset.findPrivateKey(...) will fail type-checking because it’s not part of the Keyset API. (app.unpkg.com)

If your goal is “get the signing key for a given kid”, you’d do something like:

const key = keyset.get({ kid, use: 'sig' }) // returns a Key
// or:
const [key, alg] = keyset.findKey({ kid, use: 'sig' })

Citations:


Remove @ts-expect-error and use the correct Keyset API method.

The suppression masks a genuine type error: Keyset (from @atproto/jwk) does not provide a findPrivateKey() method. Use keyset.get({ use: 'sig' }) or keyset.findKey({ use: 'sig' }) instead to obtain the signing key with proper type safety.

🔧 Suggested fix using Keyset.get()
 export default defineEventHandler(async event => {
   const config = useRuntimeConfig(event)
   const keyset = await loadJWKs(config)
-  // `@ts-expect-error` Taken from statusphere-example-app. Throws a ts error
-  const pk = keyset?.findPrivateKey({ use: 'sig' })
+  const pk = keyset?.get({ use: 'sig' })
   return getOauthClientMetadata(pk?.alg)
 })

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

🧹 Nitpick comments (1)
server/utils/atproto/oauth-session-store.ts (1)

26-29: Use const instead of let for non-reassigned variable.

session is never reassigned, so prefer const for clarity.

Proposed fix
-    let session = await this.storage.getItem<NodeSavedSession>(
+    const session = await this.storage.getItem<NodeSavedSession>(
       this.createStorageKey(key, serverSessionData.oauthSessionId),
     )

Comment on lines +78 to +82
// These are optional and not expected or can be used easily in local development, only in production
const keyset = await loadJWKs(config)
// @ts-expect-error Taken from statusphere-example-app. Throws a ts error
const pk = keyset?.findPrivateKey({ use: 'sig' })
const clientMetadata = getOauthClientMetadata(pk?.alg)
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's check if the file exists and examine the specific lines
cat -n server/utils/atproto/oauth.ts | sed -n '70,90p'

Repository: npmx-dev/npmx.dev

Length of output: 905


🏁 Script executed:

# Check the imports at the top of the file
head -30 server/utils/atproto/oauth.ts

Repository: npmx-dev/npmx.dev

Length of output: 1252


🏁 Script executed:

# Search for Keyset type definitions in the codebase
rg -t ts "type Keyset|interface Keyset|class Keyset" --max-count 20

Repository: npmx-dev/npmx.dev

Length of output: 43


🏁 Script executed:

# Search for findPrivateKey usage
rg "findPrivateKey" --type ts --type tsx

Repository: npmx-dev/npmx.dev

Length of output: 88


🏁 Script executed:

# Check dependencies related to jose
cat package.json | grep -A 20 '"dependencies"' | grep -i jose

Repository: npmx-dev/npmx.dev

Length of output: 43


🏁 Script executed:

# Search for findPrivateKey with corrected syntax
rg "findPrivateKey" --type ts

Repository: npmx-dev/npmx.dev

Length of output: 242


🏁 Script executed:

# Search for loadJWKs function definition
rg "loadJWKs" -A 5 --type ts

Repository: npmx-dev/npmx.dev

Length of output: 1418


🏁 Script executed:

# Check if there are node_modules or type definitions available
fd -t f "oauth-client-node" --max-depth 3

Repository: npmx-dev/npmx.dev

Length of output: 43


🏁 Script executed:

# Let's look for JoseKey which is also imported
rg "JoseKey" --type ts -B 2 -A 2

Repository: npmx-dev/npmx.dev

Length of output: 1256


🏁 Script executed:

# Get full loadJWKs function
rg "export async function loadJWKs" -A 10 --type ts

Repository: npmx-dev/npmx.dev

Length of output: 868


🏁 Script executed:

# Check the second location with the same pattern
cat -n server/routes/oauth-client-metadata.json.get.ts | sed -n '1,50p'

Repository: npmx-dev/npmx.dev

Length of output: 413


🏁 Script executed:

# Check more context around the loadJWKs and Keyset creation
rg "new Keyset" -B 5 -A 2 --type ts

Repository: npmx-dev/npmx.dev

Length of output: 499


🏁 Script executed:

# Let's verify that there are no other type-safe ways this is handled elsewhere
rg "keyset\?" --type ts -B 2 -A 2

Repository: npmx-dev/npmx.dev

Length of output: 891


🏁 Script executed:

# Check if there's any type definition or augmentation for Keyset in the project
fd -t f "tsconfig\|types\|d.ts" | head -20

Repository: npmx-dev/npmx.dev

Length of output: 43


🏁 Script executed:

# Look for any .d.ts files that might augment the Keyset type
fd "\.d\.ts" --type f

Repository: npmx-dev/npmx.dev

Length of output: 119


Remove @ts-expect-error and add type safety for findPrivateKey access.

This suppression hides a real typing gap in the Keyset type from '@atproto/oauth-client-node'. The method exists at runtime but TypeScript doesn't recognise it. This pattern appears in two places (server/utils/atproto/oauth.ts and server/routes/oauth-client-metadata.json.get.ts) and violates the requirement for strictly type-safe code.

Implement a local type guard to safely cast the Keyset or type-assert the method's availability, then remove the suppression.

🔧 Suggested fix (local type guard, remove suppression)
  const keyset = await loadJWKs(config)
-  // `@ts-expect-error` Taken from statusphere-example-app. Throws a ts error
-  const pk = keyset?.findPrivateKey({ use: 'sig' })
+  type KeysetWithFindPrivateKey = Keyset & {
+    findPrivateKey: (opts: { use: string }) => JoseKey | undefined
+  }
+  const pk =
+    keyset && 'findPrivateKey' in keyset
+      ? (keyset as KeysetWithFindPrivateKey).findPrivateKey({ use: 'sig' })
+      : undefined

Copy link
Member

@danielroe danielroe left a comment

Choose a reason for hiding this comment

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

I'll wait for @matthieusieben's review, but in the mean time I've created that key and configured it on vercel so that it's good to go from my pov.

fyi, I created two keys, one for production + a different one for preview deployments. if we should get rid of preview deployment key (maybe allow nuxt to regen a key per-preview, as we do with session passwords?) then let me know

@matthieusieben
Copy link
Contributor

matthieusieben commented Feb 11, 2026

Hey @fatfingers23,

While I wrote the @atproto/oauth-client-* libs, I am less familiar with Nuxt. So feel free to take my comments with a grain of salt. Before anything else, the version of the code here seems to work, and I don't see any security issues with it 👍. I do have some concerns, which have more to do with the way we intended the @atproto/oauth-client-node lib to work, than you implementation:

First, I am wondering if the use of useSession isn't a bit counter productive here. The h3 cookie is defined as a session cookie, so it wont persist if the browser is closed. This defeats the purpose of using a confidential client, which is to allow having long lived sessions.

Secondly, by creating a new instance for every request, you are using the NodeOAuthClient in a way that was not foreseen. I can see the benefit of doing things that way, as having access to the session cookie allows to neatly "scope" the stored data to a particular browser. I have several concerns with it though: 1) it couples the cookie management logic (which might change due to my point above) to backend data management; 2) A full featured confidential client should proactively refresh the sessions stored in the backend to prevent them from becoming stale on the AS. Since the store logic is bound to availability of the browser session, the store would not be able to keep cookies in sync; 3) Because of the two previous points, you might encounter (rare) concurrency issues when multiple instances are trying to access the same data in parallel.

Thirdly, the implementation lacks a "cleanup" logic. That logic should ideally be implemented inside the NodeOAuthClient itself (I'm adding a TODO on my end to work on it). Until I get to it, you'll need to make sure that the state and session databases don't grow indefinitely. The state DB should be cleared from session created more than e.g. 30 minutes ago, and never completed. The session database can be cleared by refreshing sessions and removing inactive ones (another possibility would be not to refresh, and instead "signout" oauth sessions that were not refreshed in a while, because the user didn't recently visit).

Here is my recommendation (and again, this is based on my basic understanding of Nitro):

  1. Create a NitroPlugin so that there is a single NodeOAuthClient instance running.
  2. The NodeOAuthClient instance can be used from the oauth-client-metadata.json and jwks.json endpoint (by returning oauthClient.clientMetadata and oauthClient.keyset.publicJwks), removing the need to expose a bunch of utils (loadJWKs, getOauthClientMetadata, ...)
  3. Create a long lived "device ID" cookie (using either a strong random value, or a server encrypted value). This cookie can be used instead of the atproto_oauth_req I introduced in fix: properly bind OAuth state data to a browser session #1327 to bind a particular oauth request to a browser (simply by adding the "device ID" in the "state" data in encodeOAuthState and checking it in decodeOAuthState)
  4. Create an additional "profile" backend storage, keyed by "device ID". This storage will basically contain what is currently in UserServerSession['public'].
  5. Change getOAuthSession() to be using the "Device ID" cookie + the "profile" storage + static oauthClient
  6. Change useServerSession() to be using the "Device ID" cookie + "profile" storage
  7. Make sure that the api/auth/session endpoint triggers an oauth refresh (to keep the session alive), simply by calling getOAuthSession() (the restore() method will automatically trigger a refresh if the session is about to expire)

Here is a skeleton of what the plugin might look like:

/// Plugin definition
export default defineNitroPlugin(nitroApp => {

  // Store keeping pending OAuth authorization requests
  const stateStore = new SimpleStoreStorage<NodeSavedStateStore>("oauth:state")

  // Store keeping credentials from completed OAuth authorization requests
  const sessionStore = new SimpleStoreStorage<NodeSavedSessionStore>("oauth:session")
  
  const oauthClient = new NodeOAuthClient({
    stateStore,
    sessionStore,
    clientMetadata,
    requestLock: getOAuthLock(),
    handleResolver,
  })

  oauthClient.addEventListener('updated', ({ details: { sub, tokenSet: { aud } } }) => {
    // An OAuth session was refreshed. we might want to check if we need to update the
    // profile info (e.g. if the `aud` changed) in the [Device ID => sub] storage.
  })
  
  oauthClient.addEventListener('deleted', ({ details: { sub } }) => {
    // An OAuth session was deleted (for any reason: failure to refresh, user logout, etc.):
    // TODO: remove the the "profile" from the "profile" storage
  })
  
  nitroApp.oauthClient = oauthClient
  
  // Prevent the "oauth:state" store from growing indefinitely with un-completed oauth requests
  // @NOTE: This should eventually be implemented by the NodeOAuthClient itself !
  // Note that this can be implemented with a TTL on the store (if that is available)
  const stopCleanup = startAsyncTask(async (signal: AbortSignal) => {
    try {
      for await (const [key, state] of stateStore.list()) {
        if (signal.aborted) return
        
        // Remove every state that was created more than 30 minutes ago
        if (state.lastUpdatedAt < Date.now() - 30 * 60 * 1e3) {
          stateStore.del(key)
        }
      }
    } catch (err) {
      // "list" triggered an error, ignore it
      console.error('Failed to list active sessions', err)
    }
  })

  nitroApp.hooks.hook('close', stopCleanup)

  // Proactively keep OAuth sessions alive
  // @NOTE: This should eventually be implemented by the NodeOAuthClient itself !
  const stopRefresh = startAsyncTask(async (signal: AbortSignal) => {
    try {
      for await (const [sub, savedSession] of sessionStore.list()) {
        if (signal.aborted) return
  
        // Note that we can keep either one , or both, branches of the if/else-if bellow (depending on what we want)
        if (wasLastRefreshedTooLongAgo(savedSession)) {
          try {
            await oauthClient.revoke(sub)
          } catch (err) {
            // No biggie
          }
        } else if (shouldRefresh(savedSession)) {
          try {
            await oauthClient.restore(key, true)
          } catch (err) {
            console.warn(`Refreshing session ${key} failed`, err);
            // No need to remove from the store, the client will call sessionStore.del()
          }
        }
      }
    } catch (err) {
      // "list" triggered an error, ignore it
      console.error('Failed to list active sessions', err)
    }
  })

  nitroApp.hooks.hook('close', stopRefresh)

})
Implementation details
function shouldRefresh({ tokenSet, lastUpdatedAt }: NodeSavedSession & { lastUpdatedAt: number }) {
  if (tokenSet.expires_at) {
    // If the server returned an expiration date, use that as signal to refresh
    return new Date(tokenSet.expires_at).getTime() <= Date.now()
  } else {
    // Otherwise, refresh once a day (every 23.5H)
    return lastUpdatedAt <= Date.now() - 23.5 * 60 * 60e3
  }
}

export function startAsyncTask(
  task: (signal: AbortSignal) => void | Promise<void>,
  intervalMs = 60 * 60 * 1e3, // 1 hour
): () => Promise<void> {
  const abortController = new AbortController()
  let promise: Promise<void> | undefined
  let timer: ReturnType<typeof setTimeout> | undefined

  const runAndSchedule = async (signal: AbortSignal) => {
    await (promise = (async () => task(signal))())
    if (!signal.aborted) {
      timer = setTimeout(runAndSchedule, intervalMs, signal)
    }
  }

  runAndSchedule(abortController.signal)

  return async () => {
    abortController.abort()
    clearTimeout(timer)
    await promise // Await pending task run to finish
  }
}

Note, with this change, you can probably use a single implementation for the "store" instead of two:

/**
 * Wrapper around {@link Storage} that implements the {@link SimpleStore} interface, required by the AT Protocol OAuth client.
 */
class SimpleStoreStorage<T extends Record<string, unknown>> implements SimpleStore<string, T & { lastUpdatedAt: number }> {
  private readonly storage: Storage<T>

  constructor(storageName: string) {
    this.storage = useStorage(storageName)
  }

  async get(key: string, options?: GetOptions): Promise<undefined | T> {
    // TODO
  }
  
  async set(key: string, value: T): Promise<void> {
    // TODO
    const data = { ...value, lastUpdatedAt: Date.now() }
  }

  async del(key: string): Promise<void> {
    // TODO
  }

  // Note: not part of the simple store interface
  async *list(): AsyncGenerator<string, T> {
    const keys = await this.storage.getKeys()
    for (const key of keys) {
      yield await this.storage.get(key)
    }
  }
}

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.

DPop key should never leave the server feat: atproto oauth session refresh, testing, and edgecase handling

3 participants