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:
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user