Skip to content

Commit f9bdefb

Browse files
committed
fix: add locking implementation
1 parent 257effe commit f9bdefb

5 files changed

Lines changed: 82 additions & 4 deletions

File tree

nuxt.config.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import process from 'node:process'
12
import { currentLocales } from './config/i18n'
23

34
export default defineNuxtConfig({
@@ -47,6 +48,11 @@ export default defineNuxtConfig({
4748

4849
runtimeConfig: {
4950
sessionPassword: '',
51+
// Upstash Redis for distributed OAuth token refresh locking in production
52+
upstash: {
53+
redisRestUrl: process.env.KV_REST_API_URL || '',
54+
redisRestToken: process.env.KV_REST_API_TOKEN || '',
55+
},
5056
},
5157

5258
devtools: { enabled: true },

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
"@nuxtjs/i18n": "10.2.1",
5151
"@shikijs/langs": "3.21.0",
5252
"@shikijs/themes": "3.21.0",
53+
"@upstash/redis": "1.36.1",
5354
"@vercel/kv": "3.0.0",
5455
"@vueuse/core": "14.1.0",
5556
"@vueuse/nuxt": "14.1.0",

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

server/utils/atproto/lock.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import type { RuntimeLock } from '@atproto/oauth-client-node'
2+
import { requestLocalLock } from '@atproto/oauth-client-node'
3+
import { Redis } from '@upstash/redis'
4+
5+
type Awaitable<T> = T | PromiseLike<T>
6+
7+
/**
8+
* Creates a distributed lock using Upstash Redis.
9+
* Falls back gracefully if the lock cannot be acquired.
10+
*/
11+
function createUpstashLock(redis: Redis): RuntimeLock {
12+
return async <T>(key: string, fn: () => Awaitable<T>): Promise<T> => {
13+
const lockKey = `oauth:lock:${key}`
14+
const lockValue = crypto.randomUUID()
15+
const lockTTL = 30 // seconds
16+
17+
// Try to acquire lock with NX (only set if not exists) and EX (expire)
18+
const acquired = await redis.set(lockKey, lockValue, {
19+
nx: true,
20+
ex: lockTTL,
21+
})
22+
23+
if (!acquired) {
24+
// Another instance holds the lock, wait briefly and retry once
25+
await new Promise(resolve => setTimeout(resolve, 100))
26+
const retryAcquired = await redis.set(lockKey, lockValue, {
27+
nx: true,
28+
ex: lockTTL,
29+
})
30+
if (!retryAcquired) {
31+
// Still can't acquire, proceed without lock (better than failing)
32+
// The worst case is a token refresh race, which will just require re-auth
33+
return await fn()
34+
}
35+
}
36+
37+
try {
38+
return await fn()
39+
} finally {
40+
// Release lock only if we still own it (compare-and-delete)
41+
const currentValue = await redis.get(lockKey)
42+
if (currentValue === lockValue) {
43+
await redis.del(lockKey)
44+
}
45+
}
46+
}
47+
}
48+
49+
/**
50+
* Returns the appropriate lock mechanism based on environment:
51+
* - Production with Upstash config: distributed Redis lock
52+
* - Otherwise: in-memory lock (sufficient for single instance)
53+
*/
54+
export function getOAuthLock(): RuntimeLock {
55+
const config = useRuntimeConfig()
56+
57+
// Use distributed lock in production if Upstash is configured
58+
if (!import.meta.dev && config.upstashRedisRestUrl && config.upstashRedisRestToken) {
59+
const redis = new Redis({
60+
url: config.upstashRedisRestUrl,
61+
token: config.upstashRedisRestToken,
62+
})
63+
return createUpstashLock(redis)
64+
}
65+
66+
// Fall back to in-memory lock for dev/preview or when Redis isn't configured
67+
return requestLocalLock
68+
}

server/utils/atproto/oauth.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
1-
import type { OAuthClientMetadataInput } from '@atproto/oauth-client-node'
2-
import type { EventHandlerRequest, H3Event } from 'h3'
3-
import type { OAuthSession } from '@atproto/oauth-client-node'
1+
import type { OAuthClientMetadataInput, OAuthSession } from '@atproto/oauth-client-node'
2+
import type { EventHandlerRequest, H3Event, SessionManager } from 'h3'
43
import { NodeOAuthClient } from '@atproto/oauth-client-node'
54
import { parse } from 'valibot'
5+
import { getOAuthLock } from '#server/utils/atproto/lock'
66
import { useOAuthStorage } from '#server/utils/atproto/storage'
77
import { UNSET_NUXT_SESSION_PASSWORD } from '#shared/utils/constants'
88
import { OAuthMetadataSchema } from '#shared/schemas/oauth'
9-
import type { SessionManager } from 'h3'
109
// TODO: limit scope as features gets added. atproto just allows login so no scary login screen till we have scopes
1110
export const scope = 'atproto'
1211

@@ -49,6 +48,7 @@ async function getOAuthSession(event: H3Event): Promise<OAuthSession | undefined
4948
stateStore,
5049
sessionStore,
5150
clientMetadata,
51+
requestLock: getOAuthLock(),
5252
})
5353

5454
const currentSession = await sessionStore.get()

0 commit comments

Comments
 (0)