diff --git a/package-lock.json b/package-lock.json index 7751bb94..547cf7f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "@supabase/supabase-js": "^2.43.2", "@tiptap/core": "^2.11.5", "@types/mdx": "^2.0.13", + "@upstash/redis": "^1.35.0", "@vercel/analytics": "^1.5.0", "@vercel/sdk": "^1.3.1", "babel-plugin-react-compiler": "^19.1.0-rc.1", @@ -57,6 +58,7 @@ "react-day-picker": "^9.3.0", "react-dom": "19.1.0", "react-use-measure": "^2.1.1", + "redlock": "^5.0.0-beta.2", "rehype-parse": "^9.0.0", "rehype-remark": "^10.0.0", "rehype-stringify": "^10.0.0", @@ -6168,6 +6170,15 @@ "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==" }, + "node_modules/@upstash/redis": { + "version": "1.35.0", + "resolved": "https://registry.npmjs.org/@upstash/redis/-/redis-1.35.0.tgz", + "integrity": "sha512-WUm0Jz1xN4DBDGeJIi2Y0kVsolWRB2tsVds4SExaiLg4wBdHFMB+8IfZtBWr+BP0FvhuBr5G1/VLrJ9xzIWHsg==", + "license": "MIT", + "dependencies": { + "uncrypto": "^0.1.3" + } + }, "node_modules/@vercel/analytics": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-1.5.0.tgz", @@ -12856,6 +12867,12 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", + "license": "MIT" + }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -14383,6 +14400,18 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/redlock": { + "version": "5.0.0-beta.2", + "resolved": "https://registry.npmjs.org/redlock/-/redlock-5.0.0-beta.2.tgz", + "integrity": "sha512-2RDWXg5jgRptDrB1w9O/JgSZC0j7y4SlaXnor93H/UJm/QyDiFgBKNtrh0TI6oCXqYSaSoXxFh6Sd3VtYfhRXw==", + "license": "MIT", + "dependencies": { + "node-abort-controller": "^3.0.1" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -16176,6 +16205,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", + "license": "MIT" + }, "node_modules/undici": { "version": "6.21.2", "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.2.tgz", diff --git a/src/atproto-oauth.ts b/src/atproto-oauth.ts index 6943fa0a..3644da53 100644 --- a/src/atproto-oauth.ts +++ b/src/atproto-oauth.ts @@ -2,12 +2,15 @@ import { NodeOAuthClient, NodeSavedSession, NodeSavedState, + RuntimeLock, Session, } from "@atproto/oauth-client-node"; import { JoseKey } from "@atproto/jwk-jose"; import { oauth_metadata } from "app/api/oauth/[route]/oauth-metadata"; import { supabaseServerClient } from "supabase/serverClient"; +import { Redis } from "@upstash/redis"; +import Redlock from "redlock"; export async function createOauthClient() { let keyset = process.env.NODE_ENV === "production" @@ -15,6 +18,21 @@ export async function createOauthClient() { JoseKey.fromImportable(process.env.JOSE_PRIVATE_KEY_1!), ]) : undefined; + let requestLock: RuntimeLock | undefined; + if (process.env.NODE_ENV === "production") { + const redis = Redis.fromEnv(); + const redlock = new Redlock([redis]); + requestLock = async (key, fn) => { + // 30 seconds should be enough. Since we will be using one lock per user id + // we can be quite liberal with the lock duration here. + const lock = await redlock.acquire([key], 45e3); + try { + return await fn(); + } finally { + await lock.release(); + } + }; + } return new NodeOAuthClient({ // This object will be used to build the payload of the /client-metadata.json // endpoint metadata, exposing the client metadata to the OAuth server. @@ -28,6 +46,7 @@ export async function createOauthClient() { stateStore, // Interface to store authenticated session data sessionStore, + requestLock, }); }