···11+/**
22+ * Generate an ES256 JWK private key for AT Proto OAuth client signing.
33+ * Print the JSON-encoded private JWK on stdout. Add it to your env as
44+ * `NUXT_ATPROTO_PRIVATE_JWK`. The public half is exposed at
55+ * /.well-known/jwks.json by the running app.
66+ *
77+ * Usage:
88+ * pnpm gen:jwk > .jwk.json
99+ * NUXT_ATPROTO_PRIVATE_JWK="$(cat .jwk.json)" pnpm dev
1010+ */
1111+import { JoseKey } from '@atproto/jwk-jose'
1212+1313+const key = await JoseKey.generate(['ES256'], crypto.randomUUID().slice(0, 8))
1414+process.stdout.write(JSON.stringify(key.privateJwk))
1515+process.stdout.write('\n')
+27
server/api/atproto/callback.get.ts
···11+import { userIdentity } from '~~/server/db/schema'
22+33+export default defineEventHandler(async event => {
44+ const url = getRequestURL(event)
55+ const params = url.searchParams
66+77+ const client = await useOAuthClient()
88+ const { session, state } = await client.callback(params)
99+1010+ const installationId = state ? Number(state) : NaN
1111+ if (!Number.isFinite(installationId)) {
1212+ throw createError({ statusCode: 400, statusMessage: 'invalid state (missing installation id)' })
1313+ }
1414+1515+ const db = useDb()
1616+ await db.insert(userIdentity).values({
1717+ did: session.did,
1818+ handle: null, // resolved separately; we don't have it from the session blob
1919+ installationId,
2020+ updatedAt: new Date(),
2121+ }).onConflictDoUpdate({
2222+ target: userIdentity.did,
2323+ set: { installationId, updatedAt: new Date() },
2424+ })
2525+2626+ await sendRedirect(event, '/dashboard', 302)
2727+})
+27
server/api/atproto/login.get.ts
···11+export default defineEventHandler(async event => {
22+ const query = getQuery(event)
33+ const handleRaw = query.handle
44+ const installationIdRaw = query.installationId
55+66+ if (typeof handleRaw !== 'string' || !handleRaw.trim()) {
77+ throw createError({ statusCode: 400, statusMessage: 'handle is required' })
88+ }
99+ if (typeof installationIdRaw !== 'string' || !/^\d+$/.test(installationIdRaw)) {
1010+ throw createError({ statusCode: 400, statusMessage: 'installationId is required' })
1111+ }
1212+1313+ const handle = handleRaw.trim()
1414+ const installationId = installationIdRaw
1515+1616+ const client = await useOAuthClient()
1717+1818+ // Round-trip the installation id via OAuth `state`. The library wraps and
1919+ // signs `state` itself (PKCE + state CSRF protection are handled internally),
2020+ // so this is safe to use as an opaque link key.
2121+ const url = await client.authorize(handle, {
2222+ state: installationId,
2323+ scope: 'atproto transition:generic',
2424+ })
2525+2626+ await sendRedirect(event, url.toString(), 302)
2727+})
···11+export default defineEventHandler(async () => {
22+ const client = await useOAuthClient()
33+ // The library builds the canonical client_metadata document for us.
44+ return client.clientMetadata
55+})
+5
server/routes/.well-known/jwks.json.get.ts
···11+export default defineEventHandler(async () => {
22+ const client = await useOAuthClient()
33+ // Public half only; private keys never leave the server.
44+ return client.jwks
55+})
+133
server/utils/atproto-oauth.ts
···11+import { JoseKey } from '@atproto/jwk-jose'
22+import {
33+ type NodeOAuthClientOptions,
44+ type NodeSavedSession,
55+ type NodeSavedSessionStore,
66+ type NodeSavedState,
77+ type NodeSavedStateStore,
88+ NodeOAuthClient,
99+} from '@atproto/oauth-client-node'
1010+import { sql } from 'drizzle-orm'
1111+import { atprotoSession, atprotoState } from '../db/schema'
1212+import { useDb } from './db'
1313+import { decrypt, encrypt } from './encryption'
1414+1515+let cachedClient: NodeOAuthClient | undefined
1616+1717+/**
1818+ * Build the AT Proto OAuth client. The client metadata is constructed from
1919+ * runtime config so a single deploy can serve different `client_id`s by
2020+ * environment (loopback dev vs prod).
2121+ *
2222+ * The state and session stores wrap the OAuth library's required interface and
2323+ * encrypt the values at rest with `encryption.ts` (xchacha20poly1305). The
2424+ * values contain access tokens, refresh tokens, and the user's DPoP private
2525+ * key — a DB read with no encryption would be account takeover.
2626+ */
2727+export async function useOAuthClient(): Promise<NodeOAuthClient> {
2828+ if (cachedClient) return cachedClient
2929+3030+ const config = useRuntimeConfig()
3131+ const publicURL = config.public.publicURL?.replace(/\/$/, '')
3232+ if (!publicURL) {
3333+ throw new Error('NUXT_PUBLIC_URL is not set')
3434+ }
3535+3636+ const privateJwkRaw = config.atprotoPrivateJwk
3737+ if (!privateJwkRaw) {
3838+ throw new Error('NUXT_ATPROTO_PRIVATE_JWK is not set (run `pnpm gen:jwk` to create one)')
3939+ }
4040+ const key = await JoseKey.fromImportable(privateJwkRaw)
4141+4242+ const isLoopback = publicURL.startsWith('http://127.0.0.1') || publicURL.startsWith('http://localhost')
4343+ const clientId = isLoopback
4444+ // Loopback dev: spec-defined synthetic client_id; no metadata fetched by PDS.
4545+ ? `http://localhost?redirect_uri=${encodeURIComponent(`${publicURL}/api/atproto/callback`)}&scope=${encodeURIComponent('atproto transition:generic')}`
4646+ : `${publicURL}/.well-known/atproto-client-metadata.json`
4747+4848+ const options: NodeOAuthClientOptions = {
4949+ clientMetadata: {
5050+ client_id: clientId,
5151+ client_name: 'synchub.to',
5252+ client_uri: publicURL,
5353+ redirect_uris: [`${publicURL}/api/atproto/callback`],
5454+ grant_types: ['authorization_code', 'refresh_token'],
5555+ response_types: ['code'],
5656+ scope: 'atproto transition:generic',
5757+ application_type: 'web',
5858+ token_endpoint_auth_method: 'private_key_jwt',
5959+ token_endpoint_auth_signing_alg: 'ES256',
6060+ dpop_bound_access_tokens: true,
6161+ jwks_uri: `${publicURL}/.well-known/jwks.json`,
6262+ },
6363+ keyset: [key],
6464+ stateStore: makeStateStore(),
6565+ sessionStore: makeSessionStore(),
6666+ // Note: no requestLock supplied. Multi-instance deployments can race on
6767+ // concurrent token refreshes; see PLAN.md "Deferred / follow-ups".
6868+ }
6969+7070+ cachedClient = new NodeOAuthClient(options)
7171+ return cachedClient
7272+}
7373+7474+function makeStateStore(): NodeSavedStateStore {
7575+ return {
7676+ async set(key: string, value: NodeSavedState) {
7777+ const { ciphertext, nonce } = encrypt(JSON.stringify(value))
7878+ const db = useDb()
7979+ await db.insert(atprotoState).values({
8080+ key,
8181+ valueCiphertext: ciphertext,
8282+ valueNonce: nonce,
8383+ }).onConflictDoUpdate({
8484+ target: atprotoState.key,
8585+ set: { valueCiphertext: ciphertext, valueNonce: nonce },
8686+ })
8787+ },
8888+ async get(key: string) {
8989+ const db = useDb()
9090+ const rows = await db.select().from(atprotoState).where(sql`${atprotoState.key} = ${key}`)
9191+ if (rows.length === 0) return undefined
9292+ const row = rows[0]!
9393+ return JSON.parse(decrypt(row.valueCiphertext, row.valueNonce)) as NodeSavedState
9494+ },
9595+ async del(key: string) {
9696+ const db = useDb()
9797+ await db.delete(atprotoState).where(sql`${atprotoState.key} = ${key}`)
9898+ },
9999+ }
100100+}
101101+102102+function makeSessionStore(): NodeSavedSessionStore {
103103+ return {
104104+ async set(sub: string, value: NodeSavedSession) {
105105+ const { ciphertext, nonce } = encrypt(JSON.stringify(value))
106106+ const db = useDb()
107107+ await db.insert(atprotoSession).values({
108108+ sub,
109109+ valueCiphertext: ciphertext,
110110+ valueNonce: nonce,
111111+ }).onConflictDoUpdate({
112112+ target: atprotoSession.sub,
113113+ set: { valueCiphertext: ciphertext, valueNonce: nonce, updatedAt: new Date() },
114114+ })
115115+ },
116116+ async get(sub: string) {
117117+ const db = useDb()
118118+ const rows = await db.select().from(atprotoSession).where(sql`${atprotoSession.sub} = ${sub}`)
119119+ if (rows.length === 0) return undefined
120120+ const row = rows[0]!
121121+ return JSON.parse(decrypt(row.valueCiphertext, row.valueNonce)) as NodeSavedSession
122122+ },
123123+ async del(sub: string) {
124124+ const db = useDb()
125125+ await db.delete(atprotoSession).where(sql`${atprotoSession.sub} = ${sub}`)
126126+ },
127127+ }
128128+}
129129+130130+/** Test hook: drop the cached client. */
131131+export function clearOAuthClientCache() {
132132+ cachedClient = undefined
133133+}
+49
server/utils/encryption.ts
···11+import crypto from 'node:crypto'
22+import { xchacha20poly1305 } from '@noble/ciphers/chacha.js'
33+44+/**
55+ * Authenticated encryption with a key from runtime config (`NUXT_ENCRYPTION_KEY`,
66+ * base64-encoded 32 bytes). Used to wrap anything sensitive at the app layer
77+ * before it lands in the DB: AT Proto session blobs, SSH private keys.
88+ *
99+ * The KEK is held only in env. If it's lost, every encrypted row becomes
1010+ * unreadable. KEK rotation is a future concern \u2014 see PLAN.md.
1111+ */
1212+const NONCE_BYTES = 24
1313+let cachedKey: Uint8Array | undefined
1414+1515+function getKey(): Uint8Array {
1616+ if (cachedKey) return cachedKey
1717+ // Read process.env directly rather than via useRuntimeConfig() so this helper
1818+ // is callable from outside a Nitro request context (e.g. tests, scripts).
1919+ // Nuxt's runtime config still declares the var for documentation; the env
2020+ // name is the same.
2121+ const raw = process.env.NUXT_ENCRYPTION_KEY
2222+ if (!raw) {
2323+ throw new Error('NUXT_ENCRYPTION_KEY is not set (expected base64-encoded 32 bytes)')
2424+ }
2525+ const decoded = Buffer.from(raw, 'base64')
2626+ if (decoded.length !== 32) {
2727+ throw new Error(`NUXT_ENCRYPTION_KEY must decode to 32 bytes, got ${decoded.length}`)
2828+ }
2929+ cachedKey = new Uint8Array(decoded)
3030+ return cachedKey
3131+}
3232+3333+export function encrypt(plaintext: string): { ciphertext: Buffer, nonce: Buffer } {
3434+ const nonce = crypto.randomBytes(NONCE_BYTES)
3535+ const cipher = xchacha20poly1305(getKey(), new Uint8Array(nonce))
3636+ const ciphertext = cipher.encrypt(new TextEncoder().encode(plaintext))
3737+ return { ciphertext: Buffer.from(ciphertext), nonce }
3838+}
3939+4040+export function decrypt(ciphertext: Buffer, nonce: Buffer): string {
4141+ const cipher = xchacha20poly1305(getKey(), new Uint8Array(nonce))
4242+ const plaintext = cipher.decrypt(new Uint8Array(ciphertext))
4343+ return new TextDecoder().decode(plaintext)
4444+}
4545+4646+/** Test/utility hook: drop the cached key so the next call re-reads runtime config. */
4747+export function clearEncryptionKeyCache() {
4848+ cachedKey = undefined
4949+}