-
-
Notifications
You must be signed in to change notification settings - Fork 252
feat: oauth server side and confidential client #1366
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
4b0df25
fe903a8
a2e1071
e3da0bb
7abd524
1320850
7d9fc7b
72c47ee
6c5aa3e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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() |
| 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' | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Import 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
Suggested change
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing import for The function Proposed fix-import { scope } from '#server/utils/atproto/oauth'
+import { scope, getNodeOAuthClient } from '#server/utils/atproto/oauth'📝 Committable suggestion
Suggested change
|
||||||||||
| import { UNSET_NUXT_SESSION_PASSWORD } from '#shared/utils/constants' | ||||||||||
| // @ts-expect-error virtual file from oauth module | ||||||||||
| import { clientUri } from '#oauth/config' | ||||||||||
|
|
@@ -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 | ||||||||||
|
|
@@ -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 }), | ||||||||||
| }) | ||||||||||
|
|
||||||||||
|
|
||||||||||
| 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 | ||
| }) |
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Inconsistent JWKS response format on failure. When JWKs fail to load, returning an empty array Proposed fix if (!keys) {
console.error('Failed to load JWKs. May not be set')
- return []
+ return { keys: [] }
}📝 Committable suggestion
Suggested change
|
||||||||||||||||||
|
|
||||||||||||||||||
| return keys.publicJwks | ||||||||||||||||||
| }) | ||||||||||||||||||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # First, locate and examine the file in question
find . -name "oauth-client-metadata.json.get.ts" -type fRepository: npmx-dev/npmx.dev Length of output: 110 🏁 Script executed: # Check for existing oauth utils
find . -path "*/server/utils/atproto/*" -type fRepository: 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 tsxRepository: 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.tsRepository: npmx-dev/npmx.dev Length of output: 413 🏁 Script executed: # Read oauth.ts to check for existing helpers
cat -n ./server/utils/atproto/oauth.tsRepository: 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 1Repository: npmx-dev/npmx.dev Length of output: 2001 🌐 Web query:
💡 Result: In So there is no To construct keys for that array, use Sources 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 -20Repository: npmx-dev/npmx.dev Length of output: 135 🏁 Script executed: # Look for Keyset type definition
rg "class Keyset|interface Keyset|type Keyset" --type tsRepository: 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 2Repository: npmx-dev/npmx.dev Length of output: 891 🌐 Web query:
💡 Result:
|
||
| return getOauthClientMetadata(pk?.alg) | ||
|
Comment on lines
+1
to
+6
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # Find package.json to check `@atproto/oauth-client-node` version
find . -name "package.json" -type f | head -5Repository: 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.tsRepository: 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 -A2Repository: npmx-dev/npmx.dev Length of output: 88 🏁 Script executed: # Search for loadJWKs import more broadly
rg "loadJWKs" -B2 -A2Repository: 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.tsRepository: npmx-dev/npmx.dev Length of output: 7039 🌐 Web query:
💡 Result: In keyset?: Keyset | Iterable<Key | undefined | null | false>where In current
So If your goal is “get the signing key for a given const key = keyset.get({ kid, use: 'sig' }) // returns a Key
// or:
const [key, alg] = keyset.findKey({ kid, use: 'sig' })Citations:
Remove The suppression masks a genuine type error: 🔧 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)
}) |
||
| }) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: npmx-dev/npmx.dev
Length of output: 99
🏁 Script executed:
Repository: npmx-dev/npmx.dev
Length of output: 991
🏁 Script executed:
Repository: npmx-dev/npmx.dev
Length of output: 1504
🏁 Script executed:
Repository: npmx-dev/npmx.dev
Length of output: 470
🏁 Script executed:
Repository: npmx-dev/npmx.dev
Length of output: 1545
🏁 Script executed:
Repository: npmx-dev/npmx.dev
Length of output: 352
🏁 Script executed:
Repository: npmx-dev/npmx.dev
Length of output: 1998
🏁 Script executed:
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.tsRepository: 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.tsRepository: npmx-dev/npmx.dev
Length of output: 413
🏁 Script executed:
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: truemeans 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)runtimeConfig: build-time vs runtime (the key gotcha with prerender)Nuxt has two “times” where code can run:
When a route is prerendered, the “server-side render” for that page happens at build time, so:
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)useRuntimeConfigfrom Nuxt Kit. (nuxt.com)Public vs private config and what you can “access”
runtimeConfig.publicis intended to be exposed to client-side code; non-public keys are server-only. (nuxt.com)publicruntimeConfig 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:
Citations:
🌐 Web query:
Nuxt 3 useRuntimeConfig during prerender SSG build💡 Result:
During prerender / SSG (
nuxi generateornuxi build --prerender), Nuxt boots a Nuxt/Nitro instance at build time, renders your pages, and writes out static HTML +payload.jsoninto.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
useRuntimeConfig()while prerendering (it’s server-side during the build). It will see full config on the server, including private keys. (dev.nuxt.com)runtimeConfig.publicis available client-side, and its values are effectively baked into the generated output/payload. (new.nuxtjs.cn)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
Set env vars for the build step (CI/CD), not just on the hosting platform
Nuxt notes
.env/process.envare 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)Don’t read private config in client code
Client-side only has access to
runtimeConfig.public. (new.nuxtjs.cn)If you need config at Nuxt “build time” (module/hooks), use Nuxt Kit
In modules/build tooling,
@nuxt/kitexposesuseRuntimeConfig()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.jsonendpoint callsuseRuntimeConfig()to readoauthJwkOnefrom environment. Withprerender: true, this evaluation happens at build time (whereOAUTH_JWK_ONEis 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: 60to allow server-side regeneration with access to runtime configuration:Suggested change
Note:
/oauth-client-metadata.jsonhas the same dependency onoauthJwkOneand should also be reconsidered for ISR.📝 Committable suggestion