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 15let cachedClient: NodeOAuthClient | undefined 16 17/** 18 * Build the AT Proto OAuth client. The client metadata is constructed from 19 * runtime config so a single deploy can serve different `client_id`s by 20 * environment (loopback dev vs prod). 21 * 22 * The state and session stores wrap the OAuth library's required interface and 23 * encrypt the values at rest with `encryption.ts` (xchacha20poly1305). The 24 * values contain access tokens, refresh tokens, and the user's DPoP private 25 * key — a DB read with no encryption would be account takeover. 26 */ 27export async function useOAuthClient(): Promise<NodeOAuthClient> { 28 if (cachedClient) return cachedClient 29 30 const config = useRuntimeConfig() 31 const publicURL = config.public.publicURL?.replace(/\/$/, '') 32 if (!publicURL) { 33 throw new Error('NUXT_PUBLIC_URL is not set') 34 } 35 36 const privateJwkRaw = config.atprotoPrivateJwk 37 if (!privateJwkRaw) { 38 throw new Error('NUXT_ATPROTO_PRIVATE_JWK is not set (run `pnpm gen:jwk` to create one)') 39 } 40 const key = await JoseKey.fromImportable(privateJwkRaw) 41 42 const isLoopback = publicURL.startsWith('http://127.0.0.1') || publicURL.startsWith('http://localhost') 43 const clientId = isLoopback 44 // Loopback dev: spec-defined synthetic client_id; no metadata fetched by PDS. 45 ? `http://localhost?redirect_uri=${encodeURIComponent(`${publicURL}/api/atproto/callback`)}&scope=${encodeURIComponent('atproto transition:generic')}` 46 : `${publicURL}/.well-known/atproto-client-metadata.json` 47 48 const options: NodeOAuthClientOptions = { 49 clientMetadata: { 50 client_id: clientId, 51 client_name: 'synchub.to', 52 client_uri: publicURL, 53 redirect_uris: [`${publicURL}/api/atproto/callback`], 54 grant_types: ['authorization_code', 'refresh_token'], 55 response_types: ['code'], 56 scope: 'atproto transition:generic', 57 application_type: 'web', 58 token_endpoint_auth_method: 'private_key_jwt', 59 token_endpoint_auth_signing_alg: 'ES256', 60 dpop_bound_access_tokens: true, 61 jwks_uri: `${publicURL}/.well-known/jwks.json`, 62 }, 63 keyset: [key], 64 stateStore: makeStateStore(), 65 sessionStore: makeSessionStore(), 66 // Note: no requestLock supplied. Multi-instance deployments can race on 67 // concurrent token refreshes; see PLAN.md "Deferred / follow-ups". 68 } 69 70 cachedClient = new NodeOAuthClient(options) 71 return cachedClient 72} 73 74function makeStateStore(): NodeSavedStateStore { 75 return { 76 async set(key: string, value: NodeSavedState) { 77 const { ciphertext, nonce } = encrypt(JSON.stringify(value)) 78 const db = useDb() 79 await db.insert(atprotoState).values({ 80 key, 81 valueCiphertext: ciphertext, 82 valueNonce: nonce, 83 }).onConflictDoUpdate({ 84 target: atprotoState.key, 85 set: { valueCiphertext: ciphertext, valueNonce: nonce }, 86 }) 87 }, 88 async get(key: string) { 89 const db = useDb() 90 const rows = await db.select().from(atprotoState).where(sql`${atprotoState.key} = ${key}`) 91 if (rows.length === 0) return undefined 92 const row = rows[0]! 93 return JSON.parse(decrypt(row.valueCiphertext, row.valueNonce)) as NodeSavedState 94 }, 95 async del(key: string) { 96 const db = useDb() 97 await db.delete(atprotoState).where(sql`${atprotoState.key} = ${key}`) 98 }, 99 } 100} 101 102function makeSessionStore(): NodeSavedSessionStore { 103 return { 104 async set(sub: string, value: NodeSavedSession) { 105 const { ciphertext, nonce } = encrypt(JSON.stringify(value)) 106 const db = useDb() 107 await db.insert(atprotoSession).values({ 108 sub, 109 valueCiphertext: ciphertext, 110 valueNonce: nonce, 111 }).onConflictDoUpdate({ 112 target: atprotoSession.sub, 113 set: { valueCiphertext: ciphertext, valueNonce: nonce, updatedAt: new Date() }, 114 }) 115 }, 116 async get(sub: string) { 117 const db = useDb() 118 const rows = await db.select().from(atprotoSession).where(sql`${atprotoSession.sub} = ${sub}`) 119 if (rows.length === 0) return undefined 120 const row = rows[0]! 121 return JSON.parse(decrypt(row.valueCiphertext, row.valueNonce)) as NodeSavedSession 122 }, 123 async del(sub: string) { 124 const db = useDb() 125 await db.delete(atprotoSession).where(sql`${atprotoSession.sub} = ${sub}`) 126 }, 127 } 128} 129 130/** Test hook: drop the cached client. */ 131export function clearOAuthClientCache() { 132 cachedClient = undefined 133}