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
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.url?.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}