diff --git a/src/modules/repo/services/octo.ts b/src/modules/repo/services/octo.ts index 659c0ba..d05314b 100644 --- a/src/modules/repo/services/octo.ts +++ b/src/modules/repo/services/octo.ts @@ -1,6 +1,6 @@ import { Octokit } from "@octokit/rest" -import { getAccessToken } from "@/modules/user/service/signIn" +import { getAccessToken, refreshToken } from "@/modules/user/service/signIn" export const DEFAULT_OCTOKIT_TIMEOUT_MS = 8_000 @@ -20,3 +20,21 @@ export const getOctokit = async (): Promise => { return octokit } + +const isUnauthorized = (error: unknown): boolean => + (error as { status?: number })?.status === 401 + +// Runs an Octokit call; on 401, force-refreshes the token and retries once. +// Rethrows the original error if the refresh fails or the retry still 401s. +export const runWithAuthRetry = async ( + call: (octokit: Octokit) => Promise +): Promise => { + try { + return await call(await getOctokit()) + } catch (error) { + if (!isUnauthorized(error)) throw error + const refreshed = await refreshToken({ force: true }).catch(() => null) + if (!refreshed) throw error + return await call(await getOctokit()) + } +} diff --git a/src/modules/user/service/signIn.ts b/src/modules/user/service/signIn.ts index bf877a1..2a37f89 100644 --- a/src/modules/user/service/signIn.ts +++ b/src/modules/user/service/signIn.ts @@ -38,7 +38,13 @@ export const needToRefreshToken = async () => { return isBefore(expirationDate, minimumViableDate) } -export const refreshToken = async () => { +let inFlightRefresh: Promise | null = null + +const performRefresh = async ({ + force +}: { + force: boolean +}): Promise => { const accessToken = await data.get< DataType.GithubAccessToken, GithubAccessToken @@ -48,7 +54,7 @@ export const refreshToken = async () => { return null } - const needRefresh = await needToRefreshToken() + const needRefresh = force || (await needToRefreshToken()) if (needRefresh) { const authenticationServerURL = new URL(AUTHENTICATION_SERVER) @@ -70,6 +76,18 @@ export const refreshToken = async () => { return accessToken } +// GitHub refresh tokens are single-use, so concurrent refreshes race and the +// loser ends up with a revoked refresh token. Dedupe in-flight calls. +export const refreshToken = async ({ + force = false +}: { force?: boolean } = {}): Promise => { + if (inFlightRefresh) return inFlightRefresh + inFlightRefresh = performRefresh({ force }).finally(() => { + inFlightRefresh = null + }) + return inFlightRefresh +} + export const getAccessToken = async () => { const response = await data.get< DataType.GithubAccessToken,