mirror your GitHub repos to tangled.org automatically
1import { JoseKey } from '@atproto/jwk-jose'
2import {
3 type NodeOAuthClientOptions,
4 type NodeSavedSession,
5 type NodeSavedSessionStore,
6 type NodeSavedState,
7 type NodeSavedStateStore,
8 NodeOAuthClient,
9} from '@atproto/oauth-client-node'
10import { sql } from 'drizzle-orm'
11import { atprotoSession, atprotoState } from '../db/schema'
12import { useDb } from './db'
13import { decrypt, encrypt } from './encryption'
14
15/**
16 * Granular permissions per https://atproto.com/specs/permission.
17 *
18 * - `atproto`: required by the OAuth profile.
19 * - `repo:sh.tangled.publicKey`: write the user's tangled SSH public key
20 * records (publish on connect, rotate from the dashboard).
21 * - `repo:sh.tangled.repo`: write `sh.tangled.repo` records (initial repo
22 * enrolment, plus future description / topic updates).
23 * - `rpc:sh.tangled.repo.create?aud=*`: call the `sh.tangled.repo.create`
24 * procedure on any knot, and by extension mint the matching service-auth
25 * JWT via the PDS. We use `aud=*` rather than pinning a specific knot
26 * because (a) the granular-scope spec requires `aud` to be either a
27 * fragmented `did:web:host#service` or `*`, but tangled knots accept
28 * plain `did:web:host` audiences on issued JWTs, and (b) it's
29 * forward-compatible with per-user knots (PLAN.md open question 1).
30 * Still narrowly scoped — only one specific procedure NSID.
31 */
32export const SYNCHUB_OAUTH_SCOPE = [
33 'atproto',
34 'repo:sh.tangled.publicKey',
35 'repo:sh.tangled.repo',
36 'rpc:sh.tangled.repo.create?aud=*',
37].join(' ')
38
39let cachedClient: NodeOAuthClient | undefined
40
41/**
42 * Build the AT Proto OAuth client. The client metadata is constructed from
43 * runtime config so a single deploy can serve different `client_id`s by
44 * environment (loopback dev vs prod).
45 *
46 * The state and session stores wrap the OAuth library's required interface and
47 * encrypt the values at rest with `encryption.ts` (xchacha20poly1305). The
48 * values contain access tokens, refresh tokens, and the user's DPoP private
49 * key — a DB read with no encryption would be account takeover.
50 */
51export async function useOAuthClient(): Promise<NodeOAuthClient> {
52 if (cachedClient) return cachedClient
53
54 const config = useRuntimeConfig()
55 const publicURL = config.public.url?.replace(/\/$/, '')
56 if (!publicURL) {
57 throw new Error('NUXT_PUBLIC_URL is not set')
58 }
59
60 const privateJwkRaw = config.atprotoPrivateJwk
61 if (!privateJwkRaw) {
62 throw new Error('NUXT_ATPROTO_PRIVATE_JWK is not set (run `pnpm gen:jwk` to create one)')
63 }
64 const key = await JoseKey.fromImportable(privateJwkRaw)
65
66 const isLoopback = publicURL.startsWith('http://127.0.0.1') || publicURL.startsWith('http://localhost')
67 const clientId = isLoopback
68 // Loopback dev: spec-defined synthetic client_id; no metadata fetched by PDS.
69 ? `http://localhost?redirect_uri=${encodeURIComponent(`${publicURL}/api/atproto/callback`)}&scope=${encodeURIComponent(SYNCHUB_OAUTH_SCOPE)}`
70 : `${publicURL}/.well-known/atproto-client-metadata.json`
71
72 const options: NodeOAuthClientOptions = {
73 clientMetadata: {
74 client_id: clientId,
75 client_name: 'synchub.to',
76 client_uri: publicURL,
77 redirect_uris: [`${publicURL}/api/atproto/callback`],
78 grant_types: ['authorization_code', 'refresh_token'],
79 response_types: ['code'],
80 scope: SYNCHUB_OAUTH_SCOPE,
81 application_type: 'web',
82 token_endpoint_auth_method: 'private_key_jwt',
83 token_endpoint_auth_signing_alg: 'ES256',
84 dpop_bound_access_tokens: true,
85 jwks_uri: `${publicURL}/.well-known/jwks.json`,
86 },
87 keyset: [key],
88 stateStore: makeStateStore(),
89 sessionStore: makeSessionStore(),
90 // Note: no requestLock supplied. Multi-instance deployments can race on
91 // concurrent token refreshes; see PLAN.md "Deferred / follow-ups".
92 }
93
94 cachedClient = new NodeOAuthClient(options)
95 return cachedClient
96}
97
98function makeStateStore(): NodeSavedStateStore {
99 return {
100 async set(key: string, value: NodeSavedState) {
101 const { ciphertext, nonce } = encrypt(JSON.stringify(value))
102 const db = useDb()
103 await db.insert(atprotoState).values({
104 key,
105 valueCiphertext: ciphertext,
106 valueNonce: nonce,
107 }).onConflictDoUpdate({
108 target: atprotoState.key,
109 set: { valueCiphertext: ciphertext, valueNonce: nonce },
110 })
111 },
112 async get(key: string) {
113 const db = useDb()
114 const rows = await db.select().from(atprotoState).where(sql`${atprotoState.key} = ${key}`)
115 if (rows.length === 0) return undefined
116 const row = rows[0]!
117 return JSON.parse(decrypt(row.valueCiphertext, row.valueNonce)) as NodeSavedState
118 },
119 async del(key: string) {
120 const db = useDb()
121 await db.delete(atprotoState).where(sql`${atprotoState.key} = ${key}`)
122 },
123 }
124}
125
126function makeSessionStore(): NodeSavedSessionStore {
127 return {
128 async set(sub: string, value: NodeSavedSession) {
129 const { ciphertext, nonce } = encrypt(JSON.stringify(value))
130 const db = useDb()
131 await db.insert(atprotoSession).values({
132 sub,
133 valueCiphertext: ciphertext,
134 valueNonce: nonce,
135 }).onConflictDoUpdate({
136 target: atprotoSession.sub,
137 set: { valueCiphertext: ciphertext, valueNonce: nonce, updatedAt: new Date() },
138 })
139 },
140 async get(sub: string) {
141 const db = useDb()
142 const rows = await db.select().from(atprotoSession).where(sql`${atprotoSession.sub} = ${sub}`)
143 if (rows.length === 0) return undefined
144 const row = rows[0]!
145 return JSON.parse(decrypt(row.valueCiphertext, row.valueNonce)) as NodeSavedSession
146 },
147 async del(sub: string) {
148 const db = useDb()
149 await db.delete(atprotoSession).where(sql`${atprotoSession.sub} = ${sub}`)
150 },
151 }
152}
153
154/** Test hook: drop the cached client. */
155export function clearOAuthClientCache() {
156 cachedClient = undefined
157}