import { JoseKey } from '@atproto/jwk-jose' import { type NodeOAuthClientOptions, type NodeSavedSession, type NodeSavedSessionStore, type NodeSavedState, type NodeSavedStateStore, NodeOAuthClient, } from '@atproto/oauth-client-node' import { sql } from 'drizzle-orm' import { atprotoSession, atprotoState } from '../db/schema' import { useDb } from './db' import { decrypt, encrypt } from './encryption' /** * Granular permissions per https://atproto.com/specs/permission. * * - `atproto`: required by the OAuth profile. * - `repo:sh.tangled.publicKey`: write the user's tangled SSH public key * records (publish on connect, rotate from the dashboard). * - `repo:sh.tangled.repo`: write `sh.tangled.repo` records (initial repo * enrolment, plus future description / topic updates). * - `rpc:sh.tangled.repo.create?aud=*`: call the `sh.tangled.repo.create` * procedure on any knot, and by extension mint the matching service-auth * JWT via the PDS. We use `aud=*` rather than pinning a specific knot * because (a) the granular-scope spec requires `aud` to be either a * fragmented `did:web:host#service` or `*`, but tangled knots accept * plain `did:web:host` audiences on issued JWTs, and (b) it's * forward-compatible with per-user knots (PLAN.md open question 1). * Still narrowly scoped — only one specific procedure NSID. */ export const SYNCHUB_OAUTH_SCOPE = [ 'atproto', 'repo:sh.tangled.publicKey', 'repo:sh.tangled.repo', 'rpc:sh.tangled.repo.create?aud=*', ].join(' ') let cachedClient: NodeOAuthClient | undefined /** * Build the AT Proto OAuth client. The client metadata is constructed from * runtime config so a single deploy can serve different `client_id`s by * environment (loopback dev vs prod). * * The state and session stores wrap the OAuth library's required interface and * encrypt the values at rest with `encryption.ts` (xchacha20poly1305). The * values contain access tokens, refresh tokens, and the user's DPoP private * key — a DB read with no encryption would be account takeover. */ export async function useOAuthClient(): Promise { if (cachedClient) return cachedClient const config = useRuntimeConfig() const publicURL = config.public.url?.replace(/\/$/, '') if (!publicURL) { throw new Error('NUXT_PUBLIC_URL is not set') } const privateJwkRaw = config.atprotoPrivateJwk if (!privateJwkRaw) { throw new Error('NUXT_ATPROTO_PRIVATE_JWK is not set (run `pnpm gen:jwk` to create one)') } const key = await JoseKey.fromImportable(privateJwkRaw) const isLoopback = publicURL.startsWith('http://127.0.0.1') || publicURL.startsWith('http://localhost') const clientId = isLoopback // Loopback dev: spec-defined synthetic client_id; no metadata fetched by PDS. ? `http://localhost?redirect_uri=${encodeURIComponent(`${publicURL}/api/atproto/callback`)}&scope=${encodeURIComponent(SYNCHUB_OAUTH_SCOPE)}` : `${publicURL}/.well-known/atproto-client-metadata.json` const options: NodeOAuthClientOptions = { clientMetadata: { client_id: clientId, client_name: 'synchub.to', client_uri: publicURL, redirect_uris: [`${publicURL}/api/atproto/callback`], grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], scope: SYNCHUB_OAUTH_SCOPE, application_type: 'web', token_endpoint_auth_method: 'private_key_jwt', token_endpoint_auth_signing_alg: 'ES256', dpop_bound_access_tokens: true, jwks_uri: `${publicURL}/.well-known/jwks.json`, }, keyset: [key], stateStore: makeStateStore(), sessionStore: makeSessionStore(), // Note: no requestLock supplied. Multi-instance deployments can race on // concurrent token refreshes; see PLAN.md "Deferred / follow-ups". } cachedClient = new NodeOAuthClient(options) return cachedClient } function makeStateStore(): NodeSavedStateStore { return { async set(key: string, value: NodeSavedState) { const { ciphertext, nonce } = encrypt(JSON.stringify(value)) const db = useDb() await db.insert(atprotoState).values({ key, valueCiphertext: ciphertext, valueNonce: nonce, }).onConflictDoUpdate({ target: atprotoState.key, set: { valueCiphertext: ciphertext, valueNonce: nonce }, }) }, async get(key: string) { const db = useDb() const rows = await db.select().from(atprotoState).where(sql`${atprotoState.key} = ${key}`) if (rows.length === 0) return undefined const row = rows[0]! const parsed: NodeSavedState = JSON.parse(decrypt(row.valueCiphertext, row.valueNonce)) return parsed }, async del(key: string) { const db = useDb() await db.delete(atprotoState).where(sql`${atprotoState.key} = ${key}`) }, } } function makeSessionStore(): NodeSavedSessionStore { return { async set(sub: string, value: NodeSavedSession) { const { ciphertext, nonce } = encrypt(JSON.stringify(value)) const db = useDb() await db.insert(atprotoSession).values({ sub, valueCiphertext: ciphertext, valueNonce: nonce, }).onConflictDoUpdate({ target: atprotoSession.sub, set: { valueCiphertext: ciphertext, valueNonce: nonce, updatedAt: new Date() }, }) }, async get(sub: string) { const db = useDb() const rows = await db.select().from(atprotoSession).where(sql`${atprotoSession.sub} = ${sub}`) if (rows.length === 0) return undefined const row = rows[0]! const parsed: NodeSavedSession = JSON.parse(decrypt(row.valueCiphertext, row.valueNonce)) return parsed }, async del(sub: string) { const db = useDb() await db.delete(atprotoSession).where(sql`${atprotoSession.sub} = ${sub}`) }, } } /** * True when restoring an OAuth session failed because the session no longer * exists or can't be refreshed: the user revoked the app on their PDS, the * refresh token expired, or the session row was never written / already * deleted. The library signals all of these by throwing `TokenRefreshError` / * `TokenRevokedError`; we also match the message for resilience across library * versions. Callers treat this as a benign drop rather than a retryable error, * so per-DID work doesn't loop forever once a user disconnects. */ export function isSessionGone(err: unknown): boolean { if (!err || typeof err !== 'object') return false const name = 'name' in err && typeof err.name === 'string' ? err.name : '' if (name === 'TokenRefreshError' || name === 'TokenRevokedError') return true const message = 'message' in err && typeof err.message === 'string' ? err.message : '' return /session was deleted|token (has been )?revoked|no refresh token/i.test(message) } /** * Restore an OAuth session for `did`, or null when the session is gone (see * `isSessionGone`). Any other failure re-throws so genuine/transient errors * still surface to the queue's retry. */ export async function restoreSessionOrNull(did: string) { const client = await useOAuthClient() try { return await client.restore(did) } catch (err) { if (isSessionGone(err)) return null throw err } } /** Test hook: drop the cached client. */ export function clearOAuthClientCache() { cachedClient = undefined }