Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions app/components/Header/AuthModal.client.vue
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,14 @@ watch(handleInput, newHandleInput => {
handleInput.value = normalized
}
})

watch(user, async newUser => {
if (newUser?.relogin) {
await authRedirect(newUser.did, {
redirectTo: route.fullPath,
})
}
})
</script>

<template>
Expand Down
7 changes: 7 additions & 0 deletions modules/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { provider } from 'std-env'

// Storage key for fetch cache - must match shared/utils/fetch-cache-config.ts
const FETCH_CACHE_STORAGE_BASE = 'fetch-cache'
// Storage key for OAuth cache - must match server/utils/atproto/storage.ts
const OAUTH_CACHE_STORAGE_BASE = 'atproto:oauth'

export default defineNuxtModule({
meta: {
Expand Down Expand Up @@ -37,6 +39,11 @@ export default defineNuxtModule({
...nitroConfig.storage[FETCH_CACHE_STORAGE_BASE],
driver: 'vercel-runtime-cache',
}

nitroConfig.storage[OAUTH_CACHE_STORAGE_BASE] = {
...nitroConfig.storage[OAUTH_CACHE_STORAGE_BASE],
driver: 'vercel-runtime-cache',
}
}

const env = process.env.VERCEL_ENV
Expand Down
2 changes: 2 additions & 0 deletions nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export default defineNuxtConfig({
github: {
orgToken: '',
},
oauthJwkOne: process.env.OAUTH_JWK_ONE || undefined,
// Upstash Redis for distributed OAuth token refresh locking in production
upstash: {
redisRestUrl: process.env.UPSTASH_KV_REST_API_URL || process.env.KV_REST_API_URL || '',
Expand Down Expand Up @@ -122,6 +123,7 @@ export default defineNuxtConfig({
'/_avatar/**': { isr: 3600, proxy: 'https://www.gravatar.com/avatar/**' },
'/opensearch.xml': { isr: true },
'/oauth-client-metadata.json': { prerender: true },
'/.well-known/jwks.json': { prerender: true },
Comment on lines 125 to +126
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, find and examine the nuxt.config.ts file
find . -name "nuxt.config.ts" -type f

Repository: npmx-dev/npmx.dev

Length of output: 99


🏁 Script executed:

# Search for references to oauthJwkOne to understand how it's used
rg "oauthJwkOne" -A 3 -B 3

Repository: npmx-dev/npmx.dev

Length of output: 991


🏁 Script executed:

# Search for JWKS endpoint implementation
rg "well-known/jwks" -A 5 -B 5

Repository: npmx-dev/npmx.dev

Length of output: 1504


🏁 Script executed:

# Look for runtimeConfig configuration in the project
rg "runtimeConfig" -A 3 -B 1

Repository: npmx-dev/npmx.dev

Length of output: 470


🏁 Script executed:

# Search for the actual JWKS endpoint implementation
rg "jwks\.json" -A 10 -B 2

Repository: npmx-dev/npmx.dev

Length of output: 1545


🏁 Script executed:

# Look for server routes
find . -path "./server/routes/**" -type f | head -20

Repository: npmx-dev/npmx.dev

Length of output: 352


🏁 Script executed:

# Search for any route handling JWKS
rg "\.well-known" -A 10 -B 2

Repository: npmx-dev/npmx.dev

Length of output: 1998


🏁 Script executed:

# Look at the full nuxt.config.ts to understand the routeRules context
sed -n '115,140p' ./nuxt.config.ts

Repository: npmx-dev/npmx.dev

Length of output: 1334


🏁 Script executed:

# Read the actual JWKS endpoint implementation
cat -n ./server/routes/.well-known/jwks.json.get.ts

Repository: npmx-dev/npmx.dev

Length of output: 449


🏁 Script executed:

# Also check the oauth-client-metadata endpoint for comparison
cat -n ./server/routes/oauth-client-metadata.json.get.ts

Repository: npmx-dev/npmx.dev

Length of output: 413


🏁 Script executed:

# Search for how loadJWKs is used in the codebase
rg "loadJWKs" -A 5 -B 2

Repository: npmx-dev/npmx.dev

Length of output: 1834


🌐 Web query:

Nuxt 3 prerender routeRules runtimeConfig access build time vs runtime

💡 Result:

What happens when you set routeRules: { '/some/**': { prerender: true } }

  • prerender: true means Nuxt/Nitro will render that route at build time and write a static HTML file into .output/public/... (or equivalent) which is then served as a file, not computed per request. (nuxt.com)
  • This is part of Nuxt “hybrid rendering” where different routes can be prerendered/SSR/CSR/ISR/SWR, etc. (nuxt.com)

runtimeConfig: build-time vs runtime (the key gotcha with prerender)

Nuxt has two “times” where code can run:

  • Build time (prerender/generate time): your page’s server-render step is executed during the build.
  • Runtime (request time): your server renders pages when a request comes in.

When a route is prerendered, the “server-side render” for that page happens at build time, so:

  • Any useRuntimeConfig() usage that influences HTML for prerendered pages will be evaluated at build time, and the resulting HTML is frozen until the next build. (Because the output is a static file.) (nuxt.com)
  • Nuxt explicitly supports reading the resolved runtime config at build time (e.g., in modules) via useRuntimeConfig from Nuxt Kit. (nuxt.com)

Public vs private config and what you can “access”

  • runtimeConfig.public is intended to be exposed to client-side code; non-public keys are server-only. (nuxt.com)
  • But even public runtimeConfig values used during prerender become baked into the generated output, because prerender output is static HTML generated at build time. (nuxt.com)

Practical rule of thumb

If a value must change without rebuilding (per environment, per deployment, per request), then:

  • Don’t prerender that page’s HTML (use SSR/SWR/ISR instead), or
  • Keep the page prerendered but fetch dynamic/runtime values at runtime (e.g., from an API endpoint) so they aren’t baked into the HTML. (Prerendered HTML itself will not change without a rebuild.) (nuxt.com)

Citations:


🌐 Web query:

Nuxt 3 useRuntimeConfig during prerender SSG build

💡 Result:

During prerender / SSG (nuxi generate or nuxi build --prerender), Nuxt boots a Nuxt/Nitro instance at build time, renders your pages, and writes out static HTML + payload.json into .output/public. (nuxt.com)
So useRuntimeConfig() is evaluated with whatever config/env vars exist at build time—not at “runtime on the static host”.

What you can (and can’t) do

  • You can use useRuntimeConfig() while prerendering (it’s server-side during the build). It will see full config on the server, including private keys. (dev.nuxt.com)
  • In the generated static site, there is no server runtime config. Only runtimeConfig.public is available client-side, and its values are effectively baked into the generated output/payload. (new.nuxtjs.cn)
  • If you were hoping to change useRuntimeConfig() values after deployment (without rebuilding), that won’t work with pure static hosting—you need an SSR/server deployment (or rebuild per environment).

Common gotchas / fixes

  1. Set env vars for the build step (CI/CD), not just on the hosting platform
    Nuxt notes .env/process.env are used during development and build/generate, while production runtime should use platform env vars (which won’t apply to already-generated static files). (nuxt.com)

  2. Don’t read private config in client code
    Client-side only has access to runtimeConfig.public. (new.nuxtjs.cn)

  3. If you need config at Nuxt “build time” (module/hooks), use Nuxt Kit
    In modules/build tooling, @nuxt/kit exposes useRuntimeConfig() to access the resolved runtime config at build time. (nuxt.com)

If you share what you’re trying to accomplish (e.g., “different API base URL per environment without rebuild”, or “use secret to fetch data during prerender”), I can suggest the cleanest Nuxt pattern for that setup.

Citations:


Prerendered JWKS endpoint will serve empty keys at runtime, breaking OAuth authentication.

The /.well-known/jwks.json endpoint calls useRuntimeConfig() to read oauthJwkOne from environment. With prerender: true, this evaluation happens at build time (where OAUTH_JWK_ONE is typically unavailable in CI/CD), causing the endpoint to return an empty array []. This empty response gets baked into the prerendered HTML file and is served to all requests, rendering OAuth inoperative regardless of whether the environment variable exists at runtime.

Change to isr: 60 to allow server-side regeneration with access to runtime configuration:

Suggested change
-    '/.well-known/jwks.json': { prerender: true },
+    '/.well-known/jwks.json': { isr: 60 },

Note: /oauth-client-metadata.json has the same dependency on oauthJwkOne and should also be reconsidered for ISR.

📝 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
'/oauth-client-metadata.json': { prerender: true },
'/.well-known/jwks.json': { prerender: true },
'/oauth-client-metadata.json': { prerender: true },
'/.well-known/jwks.json': { isr: 60 },

// never cache
'/api/auth/**': { isr: false, cache: false },
'/api/social/**': { isr: false, cache: false },
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"generate:sprite": "node scripts/generate-file-tree-sprite.ts",
"generate:fixtures": "node scripts/generate-fixtures.ts",
"generate:lexicons": "lex build --lexicons lexicons --out shared/types/lexicons --clear",
"generate:jwk": "node scripts/gen-jwk.ts",
"test": "vite test",
"test:a11y": "pnpm build:test && LIGHTHOUSE_COLOR_MODE=dark pnpm test:a11y:prebuilt && LIGHTHOUSE_COLOR_MODE=light pnpm test:a11y:prebuilt",
"test:a11y:prebuilt": "./scripts/lighthouse.sh",
Expand Down
11 changes: 11 additions & 0 deletions scripts/gen-jwk.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { JoseKey } from '@atproto/oauth-client-node'

async function run() {
const kid = Date.now().toString()
const key = await JoseKey.generate(['ES256'], kid)
const jwk = key.privateJwk

console.log(JSON.stringify(jwk))
}

await run()
23 changes: 7 additions & 16 deletions server/api/auth/atproto.get.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,16 @@
import type { OAuthSession } from '@atproto/oauth-client-node'
import { NodeOAuthClient, OAuthCallbackError } from '@atproto/oauth-client-node'
import { OAuthCallbackError } from '@atproto/oauth-client-node'
import { createError, getQuery, sendRedirect, setCookie, getCookie, deleteCookie } from 'h3'
import type { H3Event } from 'h3'
import { getOAuthLock } from '#server/utils/atproto/lock'
import { useOAuthStorage } from '#server/utils/atproto/storage'
import { SLINGSHOT_HOST } from '#shared/utils/constants'
import { useServerSession } from '#server/utils/server-session'
import { handleResolver } from '#server/utils/atproto/oauth'
import { handleApiError } from '#server/utils/error-handler'
import type { DidString } from '@atproto/lex'
import { Client } from '@atproto/lex'
import * as com from '#shared/types/lexicons/com'
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'

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'

import { UNSET_NUXT_SESSION_PASSWORD } from '#shared/utils/constants'
// @ts-expect-error virtual file from oauth module
import { clientUri } from '#oauth/config'
Expand All @@ -28,17 +25,8 @@ export default defineEventHandler(async event => {
}

const query = getQuery(event)
const clientMetadata = getOauthClientMetadata()
const session = await useServerSession(event)
const { stateStore, sessionStore } = useOAuthStorage(session)

const atclient = new NodeOAuthClient({
stateStore,
sessionStore,
clientMetadata,
requestLock: getOAuthLock(),
handleResolver,
})
const atclient = await getNodeOAuthClient(session, config)

if (query.handle) {
// Initiate auth flow
Expand Down Expand Up @@ -69,7 +57,10 @@ export default defineEventHandler(async event => {
const redirectUrl = await atclient.authorize(query.handle, {
scope,
prompt: query.create ? 'create' : undefined,
ui_locales: query.locale?.toString(),
// TODO: I do not beleive this is working as expected on
// a unsupported locale on the PDS. Gives Invalid at body.ui_locales
// Commenting out for now
// ui_locales: query.locale?.toString(),
state: encodeOAuthState(event, { redirectPath }),
})

Expand Down
15 changes: 13 additions & 2 deletions server/api/auth/session.get.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,23 @@
import { PublicUserSessionSchema } from '#shared/schemas/publicUserSession'
import { safeParse } from 'valibot'

export default defineEventHandler(async event => {
const serverSession = await useServerSession(event)
export default eventHandlerWithOAuthSession(async (event, _, serverSession) => {
const result = safeParse(PublicUserSessionSchema, serverSession.data.public)
if (!result.success) {
return null
}

// A one time redirect to upgrade the previous sessions.
// Can remove in 2 weeks from merge if we'd like
if (serverSession.data.oauthSession && serverSession.data?.public?.did) {
await serverSession.update({
oauthSession: undefined,
})
return {
...result.output,
relogin: true,
}
}

return result.output
})
12 changes: 12 additions & 0 deletions server/routes/.well-known/jwks.json.get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { loadJWKs } from '#server/utils/atproto/oauth'

export default defineEventHandler(async event => {
const config = useRuntimeConfig(event)
const keys = await loadJWKs(config)
if (!keys) {
console.error('Failed to load JWKs. May not be set')
return []
}
Comment on lines +6 to +9
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: [] }
}


return keys.publicJwks
})
8 changes: 6 additions & 2 deletions server/routes/oauth-client-metadata.json.get.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
export default defineEventHandler(() => {
return getOauthClientMetadata()
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' })
Comment on lines +3 to +5
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'.

return getOauthClientMetadata(pk?.alg)
Comment on lines +1 to +6
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)
 })

})
67 changes: 54 additions & 13 deletions server/utils/atproto/oauth-session-store.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,59 @@
import type { NodeSavedSession, NodeSavedSessionStore } from '@atproto/oauth-client-node'
import type { UserServerSession } from '#shared/types/userSession'
import type { SessionManager } from 'h3'
import { OAUTH_CACHE_STORAGE_BASE } from '#server/utils/atproto/storage'

// Refresh tokens from a confidential client should last for 180 days, each new refresh of access token resets
// the expiration with the new refresh token. Shorting to 179 days to keep it a bit simpler since we rely on redis to clear sessions
// Note: This expiration only lasts this long in production. Local dev is 2 weeks
const SESSION_EXPIRATION = CACHE_MAX_AGE_ONE_DAY * 179

export class OAuthSessionStore implements NodeSavedSessionStore {
private readonly session: SessionManager<UserServerSession>
private readonly serverSession: SessionManager<UserServerSession>
private readonly cache: CacheAdapter

constructor(session: SessionManager<UserServerSession>) {
this.session = session
this.serverSession = session
this.cache = getCacheAdapter(OAUTH_CACHE_STORAGE_BASE)
}

private createStorageKey(did: string, sessionId: string) {
return `sessions:${did}:${sessionId}`
}

async get(): Promise<NodeSavedSession | undefined> {
const sessionData = this.session.data
if (!sessionData) return undefined
return sessionData.oauthSession
async get(key: string): Promise<NodeSavedSession | undefined> {
const serverSessionData = this.serverSession.data
if (!serverSessionData) return undefined
if (!serverSessionData.oauthSessionId) {
console.warn('[oauth session store] No oauthSessionId found in session data')
return undefined
}

let session = await this.cache.get<NodeSavedSession>(
this.createStorageKey(key, serverSessionData.oauthSessionId),
)
return session ?? undefined
}

async set(_key: string, val: NodeSavedSession) {
// We are ignoring the key since the mapping is already done in the session
async set(key: string, val: NodeSavedSession) {
const serverSessionData = this.serverSession.data
let sessionId
if (!serverSessionData?.oauthSessionId) {
sessionId = crypto.randomUUID()
await this.serverSession.update({
oauthSessionId: sessionId,
})
} else {
sessionId = serverSessionData.oauthSessionId
}
try {
await this.session.update({
oauthSession: val,
await this.cache.set<NodeSavedSession>(
this.createStorageKey(key, sessionId),
val,
SESSION_EXPIRATION,
)
await this.serverSession.update({
lastUpdatedAt: new Date(),
})
} catch (error) {
// Not sure if this has been happening. But helps with debugging
Expand All @@ -31,9 +65,16 @@ export class OAuthSessionStore implements NodeSavedSessionStore {
}
}

async del() {
await this.session.update({
oauthSession: undefined,
async del(key: string) {
const serverSessionData = this.serverSession.data
if (!serverSessionData) return undefined
if (!serverSessionData.oauthSessionId) {
console.warn('[oauth session store] No oauthSessionId found in session data')
return undefined
}
await this.cache.delete(this.createStorageKey(key, serverSessionData.oauthSessionId))
await this.serverSession.update({
oauthSessionId: undefined,
})
}
}
44 changes: 31 additions & 13 deletions server/utils/atproto/oauth-state-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,47 @@ import type { NodeSavedState, NodeSavedStateStore } from '@atproto/oauth-client-
import type { UserServerSession } from '#shared/types/userSession'
import type { SessionManager } from 'h3'

// It is recommended that oauth state is only saved for 30 minutes
const STATE_EXPIRATION = CACHE_MAX_AGE_ONE_MINUTE * 30

export class OAuthStateStore implements NodeSavedStateStore {
private readonly session: SessionManager<UserServerSession>
private readonly serverSession: SessionManager<UserServerSession>
private readonly cache: CacheAdapter

constructor(session: SessionManager<UserServerSession>) {
this.session = session
this.serverSession = session
this.cache = getCacheAdapter(OAUTH_CACHE_STORAGE_BASE)
}

private createStorageKey(did: string, sessionId: string) {
return `state:${did}:${sessionId}`
}

async get(): Promise<NodeSavedState | undefined> {
const sessionData = this.session.data
if (!sessionData) return undefined
return sessionData.oauthState
async get(key: string): Promise<NodeSavedState | undefined> {
const serverSessionData = this.serverSession.data
if (!serverSessionData) return undefined
if (!serverSessionData.oauthStateId) return undefined
const state = await this.cache.get<NodeSavedState>(
this.createStorageKey(key, serverSessionData.oauthStateId),
)
return state ?? undefined
}

async set(_key: string, val: NodeSavedState) {
// We are ignoring the key since the mapping is already done in the session
await this.session.update({
oauthState: val,
async set(key: string, val: NodeSavedState) {
let stateId = crypto.randomUUID()
await this.serverSession.update({
oauthStateId: stateId,
})
await this.cache.set<NodeSavedState>(this.createStorageKey(key, stateId), val, STATE_EXPIRATION)
}

async del() {
await this.session.update({
oauthState: undefined,
async del(key: string) {
const serverSessionData = this.serverSession.data
if (!serverSessionData) return undefined
if (!serverSessionData.oauthStateId) return undefined
await this.cache.delete(this.createStorageKey(key, serverSessionData.oauthStateId))
await this.serverSession.update({
oauthStateId: undefined,
})
}
}
Loading
Loading