mirror your GitHub repos to tangled.org automatically
1

Configure Feed

Select the types of activity you want to include in your feed.

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 const parsed: NodeSavedState = JSON.parse(decrypt(row.valueCiphertext, row.valueNonce)) 118 return parsed 119 }, 120 async del(key: string) { 121 const db = useDb() 122 await db.delete(atprotoState).where(sql`${atprotoState.key} = ${key}`) 123 }, 124 } 125} 126 127function makeSessionStore(): NodeSavedSessionStore { 128 return { 129 async set(sub: string, value: NodeSavedSession) { 130 const { ciphertext, nonce } = encrypt(JSON.stringify(value)) 131 const db = useDb() 132 await db.insert(atprotoSession).values({ 133 sub, 134 valueCiphertext: ciphertext, 135 valueNonce: nonce, 136 }).onConflictDoUpdate({ 137 target: atprotoSession.sub, 138 set: { valueCiphertext: ciphertext, valueNonce: nonce, updatedAt: new Date() }, 139 }) 140 }, 141 async get(sub: string) { 142 const db = useDb() 143 const rows = await db.select().from(atprotoSession).where(sql`${atprotoSession.sub} = ${sub}`) 144 if (rows.length === 0) return undefined 145 const row = rows[0]! 146 const parsed: NodeSavedSession = JSON.parse(decrypt(row.valueCiphertext, row.valueNonce)) 147 return parsed 148 }, 149 async del(sub: string) { 150 const db = useDb() 151 await db.delete(atprotoSession).where(sql`${atprotoSession.sub} = ${sub}`) 152 }, 153 } 154} 155 156/** Test hook: drop the cached client. */ 157export function clearOAuthClientCache() { 158 cachedClient = undefined 159}