Add API token expiration check (#4532)

This commit is contained in:
conico974
2026-08-21 11:29:38 +02:00
committed by GitHub
parent 90b54682e5
commit 49c993f181
3 changed files with 101 additions and 13 deletions
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'bun:test';
import jwt from 'jsonwebtoken';
import { isAPITokenExpired } from './api-token';
function signToken(exp: number | undefined) {
return jwt.sign(exp === undefined ? {} : { exp }, 'secret');
}
describe('isAPITokenExpired', () => {
const now = Math.floor(Date.now() / 1000);
it('returns false for a token valid for a while', () => {
expect(isAPITokenExpired(signToken(now + 3600))).toBe(false);
});
it('returns true for an expired token', () => {
expect(isAPITokenExpired(signToken(now - 60))).toBe(true);
});
it('returns true for a token expiring within the margin', () => {
expect(isAPITokenExpired(signToken(now + 60))).toBe(true);
});
it('returns false for a token without an expiration', () => {
expect(isAPITokenExpired(signToken(undefined))).toBe(false);
});
it('returns false for a token that cannot be decoded', () => {
expect(isAPITokenExpired('not-a-jwt')).toBe(false);
});
});
+26
View File
@@ -0,0 +1,26 @@
import { jwtDecode } from 'jwt-decode';
import type { SiteAPIToken } from '@gitbook/api';
/**
* Refresh slightly ahead of the real expiry so the token survives the rest of the request.
* The API mints tokens on a UTC-midnight bucket that stay valid until 00:05 the next day, so this
* margin must stay under that 5min overlap: outside it, a refresh returns the very same token.
*/
const EXPIRATION_MARGIN_SECONDS = 120;
/**
* Check if a site API token is expired, or about to expire.
*/
export function isAPITokenExpired(apiToken: string): boolean {
try {
const decoded = jwtDecode<SiteAPIToken & { exp?: number }>(apiToken);
return (
typeof decoded.exp === 'number' &&
decoded.exp < Date.now() / 1000 + EXPIRATION_MARGIN_SECONDS
);
} catch {
// A token we cannot decode is not one we can refresh.
return false;
}
}
+43 -13
View File
@@ -1,13 +1,17 @@
import type { PublishedSiteContentLookup, SiteVisitorPayload } from '@gitbook/api';
import type { GitBookAPI, PublishedSiteContentLookup, SiteVisitorPayload } from '@gitbook/api';
import { apiClient } from './api';
import { getExposableError } from './errors';
import type { DataFetcherResponse } from './types';
import { getURLLookupAlternatives, stripURLSearch } from './urls';
import { isAPITokenExpired } from '@/lib/api-token';
import { race, tryCatch } from '@/lib/async';
import { getLogger } from '@/lib/logger';
import { joinPath, joinPathWithBaseURL } from '@/lib/paths';
import { trace } from '@/lib/tracing';
type ResolveBody = Parameters<GitBookAPI['urls']['resolvePublishedContentByUrl']>[0];
interface LookupPublishedContentByUrlInput {
url: string;
redirectOnError: boolean;
@@ -28,24 +32,50 @@ export async function lookupPublishedContentByUrl(
const result = await race(lookup.urls, async (alternative, { signal }) => {
const api = apiClient({ apiToken: input.apiToken });
const callResult = await trace(
const resolveURL = (cacheBust?: string) =>
tryCatch(
api.urls.resolvePublishedContentByUrl(
{
url: alternative.url,
...(input.visitorPayload ? { visitor: input.visitorPayload } : {}),
redirectOnError: input.redirectOnError,
// Temporary: the API caches this POST by request body, so an unknown
// field is enough to miss the cache and get a freshly minted token.
...(cacheBust ? { cacheBust } : {}),
} as ResolveBody, //TODO: remove cast when we are sure that everything is good
{ signal }
)
);
let callResult = await trace(
{
operation: 'resolvePublishedContentByUrl',
name: alternative.url,
},
() =>
tryCatch(
api.urls.resolvePublishedContentByUrl(
{
url: alternative.url,
...(input.visitorPayload ? { visitor: input.visitorPayload } : {}),
redirectOnError: input.redirectOnError,
},
{ signal }
)
)
() => resolveURL()
);
const resolved = callResult.error ? null : callResult.data.data;
if (
resolved &&
!('redirect' in resolved) &&
// Only alternatives we'd actually accept are worth a second round-trip.
(alternative.primary || resolved.complete) &&
isAPITokenExpired(resolved.apiToken)
) {
getLogger().warn(
'resolvePublishedContentByUrl returned an expired API token, retrying without cache'
);
callResult = await trace(
{
operation: 'resolvePublishedContentByUrlUncached',
name: alternative.url,
},
// The value only needs to differ between calls, so `Math.random` is enough.
() => resolveURL(Math.random().toString(36).slice(2))
);
}
if (callResult.error) {
if (alternative.primary) {
// We only return an error for the primary alternative (full URL),