feat(auth): add force option and runWithAuthRetry helper

GitHub refresh tokens are single-use, so concurrent refreshes race and
the loser ends up with a revoked token — dedupe in-flight calls.
This commit is contained in:
Julien Calixte
2026-05-17 21:10:13 +02:00
parent e39ac32e43
commit c412c75cfd
2 changed files with 39 additions and 3 deletions

View File

@@ -1,6 +1,6 @@
import { Octokit } from "@octokit/rest" 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 export const DEFAULT_OCTOKIT_TIMEOUT_MS = 8_000
@@ -20,3 +20,21 @@ export const getOctokit = async (): Promise<Octokit> => {
return octokit 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 <T>(
call: (octokit: Octokit) => Promise<T>
): Promise<T> => {
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())
}
}

View File

@@ -38,7 +38,13 @@ export const needToRefreshToken = async () => {
return isBefore(expirationDate, minimumViableDate) return isBefore(expirationDate, minimumViableDate)
} }
export const refreshToken = async () => { let inFlightRefresh: Promise<GithubAccessToken | null> | null = null
const performRefresh = async ({
force
}: {
force: boolean
}): Promise<GithubAccessToken | null> => {
const accessToken = await data.get< const accessToken = await data.get<
DataType.GithubAccessToken, DataType.GithubAccessToken,
GithubAccessToken GithubAccessToken
@@ -48,7 +54,7 @@ export const refreshToken = async () => {
return null return null
} }
const needRefresh = await needToRefreshToken() const needRefresh = force || (await needToRefreshToken())
if (needRefresh) { if (needRefresh) {
const authenticationServerURL = new URL(AUTHENTICATION_SERVER) const authenticationServerURL = new URL(AUTHENTICATION_SERVER)
@@ -70,6 +76,18 @@ export const refreshToken = async () => {
return accessToken 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<GithubAccessToken | null> => {
if (inFlightRefresh) return inFlightRefresh
inFlightRefresh = performRefresh({ force }).finally(() => {
inFlightRefresh = null
})
return inFlightRefresh
}
export const getAccessToken = async () => { export const getAccessToken = async () => {
const response = await data.get< const response = await data.get<
DataType.GithubAccessToken, DataType.GithubAccessToken,