From 412516d47b07c50bdd98cfeb58b1c10d97b919eb Mon Sep 17 00:00:00 2001 From: ahmedisam99 Date: Mon, 12 Oct 2026 11:54:35 +0300 Subject: [PATCH 1/2] fix(gitlab): share one token refresh between concurrent callers GitLab invalidates a refresh token when it is used. Two deployments that found the token expired at the same time sent the same refresh token, and the second one failed with "Failed to refresh token: Bad Request". --- packages/server/src/utils/providers/gitlab.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/server/src/utils/providers/gitlab.ts b/packages/server/src/utils/providers/gitlab.ts index 6cb777b75..1221fd5a4 100644 --- a/packages/server/src/utils/providers/gitlab.ts +++ b/packages/server/src/utils/providers/gitlab.ts @@ -11,7 +11,7 @@ import { TRPCError } from "@trpc/server"; import { quote } from "shell-quote"; import type { z } from "zod"; -export const refreshGitlabToken = async (gitlabProviderId: string) => { +const refreshGitlabTokenIfExpired = async (gitlabProviderId: string) => { const gitlabProvider = await findGitlabById(gitlabProviderId); const currentTime = Math.floor(Date.now() / 1000); @@ -56,6 +56,23 @@ export const refreshGitlabToken = async (gitlabProviderId: string) => { return data; }; +// GitLab invalidates a refresh token when it is used, so concurrent callers must share one refresh +const pendingRefreshes = new Map< + string, + ReturnType +>(); + +export const refreshGitlabToken = (gitlabProviderId: string) => { + let refresh = pendingRefreshes.get(gitlabProviderId); + if (!refresh) { + refresh = refreshGitlabTokenIfExpired(gitlabProviderId).finally(() => { + pendingRefreshes.delete(gitlabProviderId); + }); + pendingRefreshes.set(gitlabProviderId, refresh); + } + return refresh; +}; + export const haveGitlabRequirements = (gitlabProvider: Gitlab) => { return !!(gitlabProvider?.accessToken && gitlabProvider?.refreshToken); }; From 20701ba272c45a4ff1025bab5a7bbfba3b87635a Mon Sep 17 00:00:00 2001 From: ahmedisam99 Date: Mon, 12 Oct 2026 11:54:35 +0300 Subject: [PATCH 2/2] test(gitlab): cover concurrent token refreshes --- .../git-provider/gitlab-refresh-token.test.ts | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 apps/dokploy/__test__/git-provider/gitlab-refresh-token.test.ts diff --git a/apps/dokploy/__test__/git-provider/gitlab-refresh-token.test.ts b/apps/dokploy/__test__/git-provider/gitlab-refresh-token.test.ts new file mode 100644 index 000000000..71b4421f3 --- /dev/null +++ b/apps/dokploy/__test__/git-provider/gitlab-refresh-token.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const store = vi.hoisted(() => ({ + provider: {} as Record, +})); + +vi.mock("@dokploy/server/services/gitlab", () => ({ + findGitlabById: vi.fn(async () => ({ ...store.provider })), + updateGitlab: vi.fn(async (_id: string, values: Record) => { + store.provider = { ...store.provider, ...values }; + }), +})); + +const { refreshGitlabToken } = await import( + "@dokploy/server/utils/providers/gitlab" +); + +const GITLAB_ID = "gitlab-1"; + +// Behaves like GitLab: a refresh returns a new pair and invalidates the refresh token it used +const createGitlab = () => { + let validRefreshToken = "refresh-1"; + let issued = 1; + let failNext = false; + + const fetch = vi.fn(async (_url: string, init: RequestInit) => { + const body = new URLSearchParams(init.body as string); + // Keeps the request open, so concurrent callers overlap as two deployments do + await new Promise((resolve) => setTimeout(resolve, 20)); + + if (failNext) { + failNext = false; + return new Response(null, { + status: 500, + statusText: "Internal Server Error", + }); + } + + if (body.get("refresh_token") !== validRefreshToken) { + return new Response(null, { status: 400, statusText: "Bad Request" }); + } + + issued++; + validRefreshToken = `refresh-${issued}`; + return Response.json({ + access_token: `access-${issued}`, + refresh_token: validRefreshToken, + expires_in: 7200, + }); + }); + + return { + fetch, + failNextRequest: () => { + failNext = true; + }, + }; +}; + +describe("refreshGitlabToken", () => { + let gitlab: ReturnType; + + beforeEach(() => { + store.provider = { + gitlabId: GITLAB_ID, + gitlabUrl: "https://gitlab.example.com", + gitlabInternalUrl: null, + applicationId: "app-id", + secret: "app-secret", + accessToken: "access-1", + refreshToken: "refresh-1", + expiresAt: 0, + }; + gitlab = createGitlab(); + vi.stubGlobal("fetch", gitlab.fetch); + }); + + it("sends one refresh request when two callers find the token expired at the same time", async () => { + await Promise.all([ + refreshGitlabToken(GITLAB_ID), + refreshGitlabToken(GITLAB_ID), + ]); + + expect(gitlab.fetch).toHaveBeenCalledTimes(1); + expect(store.provider.refreshToken).toBe("refresh-2"); + }); + + it("refreshes again after a failed refresh", async () => { + gitlab.failNextRequest(); + + await expect(refreshGitlabToken(GITLAB_ID)).rejects.toThrow( + "Failed to refresh token: Internal Server Error", + ); + await refreshGitlabToken(GITLAB_ID); + + expect(gitlab.fetch).toHaveBeenCalledTimes(2); + expect(store.provider.refreshToken).toBe("refresh-2"); + }); +});