diff --git a/packages/gitbook/src/lib/api-token.test.ts b/packages/gitbook/src/lib/api-token.test.ts new file mode 100644 index 000000000..370ab3d4e --- /dev/null +++ b/packages/gitbook/src/lib/api-token.test.ts @@ -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); + }); +}); diff --git a/packages/gitbook/src/lib/api-token.ts b/packages/gitbook/src/lib/api-token.ts new file mode 100644 index 000000000..4bf1d1d87 --- /dev/null +++ b/packages/gitbook/src/lib/api-token.ts @@ -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(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; + } +} diff --git a/packages/gitbook/src/lib/data/lookup.ts b/packages/gitbook/src/lib/data/lookup.ts index 71fbddc08..aa7a0fd17 100644 --- a/packages/gitbook/src/lib/data/lookup.ts +++ b/packages/gitbook/src/lib/data/lookup.ts @@ -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[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),