mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-22 02:23:30 +00:00
Add API token expiration check (#4532)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user