Wiki/bridge/src/lib/baserow-jwt-manager.ts
Corentin 1644a72ccc fix(bridge): wire admin client and correct Baserow JWT URLs
- Mount /api/v1/admin routes in app builder
- Instantiate BaserowAdminClient and inject it into the container
- Add BASEROW_USER_AUTH_NOT_CONFIGURED error code
- Fix duplicate /api prefix in token-auth and token-refresh URLs
2026-05-12 06:20:49 +00:00

285 lines
9.1 KiB
TypeScript

/**
* BaserowJwtManager — auto-login service account pattern.
*
* Pourquoi ce module existe :
* Le DB token Baserow (Token brg_*) suffit pour les CRUD rows mais Baserow
* renvoie 401 PERMISSION_DENIED sur les endpoints metadata (views, tables
* detail). Ces endpoints necessitent un JWT user obtenu via token-auth.
*
* Pattern :
* - Lazy init : pas de login au boot, premier getToken() declenche le login.
* - Cache memoire : le JWT est conserve jusqu'a `exp - refreshMarginSeconds`.
* - Refresh : POST /api/user/token-refresh/ avec le token courant avant expiry.
* - Mutex : un seul refresh concurrent (lock promesse partage).
* - Fallback : si creds absentes, isEnabled() = false ; getToken() leve une
* erreur BASEROW_USER_AUTH_NOT_CONFIGURED que le caller transforme en 503.
*
* Sources :
* - baserow.io/docs/apis/rest-api : POST /api/user/token-auth/ (CLAIM L3)
* - github.com/code-watch/baserow/blob/master/docs/getting-started/api.md : JWT 60min (CLAIM L3)
* - community.baserow.io/t/jwt-token-authentication/4138 : email not username (CLAIM L3)
*/
import type { Logger } from 'pino';
// ---------------------------------------------------------------------------
// Public interface
// ---------------------------------------------------------------------------
export interface BaserowJwtManager {
/** Returns a valid JWT. Refreshes if close to expiry. */
getToken(): Promise<string>;
/** True if user credentials are configured. */
isEnabled(): boolean;
}
// ---------------------------------------------------------------------------
// Internal types
// ---------------------------------------------------------------------------
interface TokenAuthResponse {
token: string;
}
interface DecodedJwtPayload {
exp?: number;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Decode the payload of a JWT without verifying signature.
* Used only to read `exp` so we know when to refresh.
*/
function decodeJwtPayload(token: string): DecodedJwtPayload {
const parts = token.split('.');
if (parts.length !== 3) return {};
try {
const raw = parts[1];
if (!raw) return {};
// atob is available in Node 22 globalThis. Buffer fallback for safety.
const decoded =
typeof atob === 'function'
? atob(raw.replace(/-/g, '+').replace(/_/g, '/'))
: Buffer.from(raw, 'base64url').toString('utf8');
return JSON.parse(decoded) as DecodedJwtPayload;
} catch {
return {};
}
}
// ---------------------------------------------------------------------------
// Implementation
// ---------------------------------------------------------------------------
export class BaserowJwtManagerImpl implements BaserowJwtManager {
private readonly baseUrl: string;
private readonly email: string;
private readonly password: string;
private readonly refreshMarginSeconds: number;
private readonly logger: Logger;
private cachedToken: string | null = null;
private tokenExp: number | null = null; // unix timestamp seconds
// Lock: prevents concurrent logins/refreshes under burst conditions.
private inflightRefresh: Promise<string> | null = null;
constructor(opts: {
baseUrl: string;
email: string;
password: string;
refreshMarginSeconds: number;
logger: Logger;
}) {
this.baseUrl = opts.baseUrl.replace(/\/$/, '');
this.email = opts.email;
this.password = opts.password;
this.refreshMarginSeconds = opts.refreshMarginSeconds;
this.logger = opts.logger.child({ service: 'baserow-jwt-manager' });
}
isEnabled(): boolean {
return true;
}
async getToken(): Promise<string> {
// If we have a cached token that is still fresh, return it immediately.
if (this.cachedToken !== null && this.isTokenFresh()) {
return this.cachedToken;
}
// Dedup concurrent refresh: if a refresh is already in flight, await it.
if (this.inflightRefresh !== null) {
return this.inflightRefresh;
}
this.inflightRefresh = this.acquireToken().finally(() => {
this.inflightRefresh = null;
});
return this.inflightRefresh;
}
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
private isTokenFresh(): boolean {
if (this.tokenExp === null) return false;
const nowSeconds = Math.floor(Date.now() / 1000);
return this.tokenExp - this.refreshMarginSeconds > nowSeconds;
}
private storeToken(token: string): void {
this.cachedToken = token;
const payload = decodeJwtPayload(token);
if (typeof payload.exp === 'number') {
this.tokenExp = payload.exp;
this.logger.debug(
{ exp: this.tokenExp, margin: this.refreshMarginSeconds },
'jwt token stored',
);
} else {
// No exp claim — default 55 min from now to stay safe within 60min Baserow TTL.
this.tokenExp = Math.floor(Date.now() / 1000) + 55 * 60;
this.logger.warn('jwt token has no exp claim — defaulting to 55min TTL');
}
}
/**
* Acquire a token: refresh if we have a stale cached token, else login from scratch.
*/
private async acquireToken(): Promise<string> {
if (this.cachedToken !== null && !this.isTokenFresh()) {
// Try refresh first; fall back to full login on failure.
try {
const token = await this.doRefresh(this.cachedToken);
this.storeToken(token);
this.logger.info('baserow jwt refreshed');
return token;
} catch (err) {
this.logger.warn({ err }, 'baserow jwt refresh failed — falling back to full login');
// Fall through to full login below.
}
}
const token = await this.doLogin();
this.storeToken(token);
this.logger.info('baserow jwt login succeeded');
return token;
}
private async doLogin(): Promise<string> {
this.logger.debug({ email: this.email }, 'baserow jwt login');
const url = `${this.baseUrl}/user/token-auth/`;
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: this.email, password: this.password }),
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) {
const body = await res.text().catch(() => '');
this.logger.error(
{ status: res.status, email: this.email, password: '***' },
'baserow jwt login failed',
);
throw new BaserowAuthError(
`BRIDGE_BASEROW_AUTH_FAILED: login returned ${res.status}${body}`,
res.status,
);
}
const data = (await res.json()) as TokenAuthResponse;
if (!data.token) {
throw new BaserowAuthError('BRIDGE_BASEROW_AUTH_FAILED: response missing token field', 502);
}
return data.token;
}
private async doRefresh(currentToken: string): Promise<string> {
this.logger.debug('baserow jwt refresh');
const url = `${this.baseUrl}/user/token-refresh/`;
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: currentToken }),
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) {
throw new BaserowAuthError(
`BRIDGE_BASEROW_AUTH_FAILED: refresh returned ${res.status}`,
res.status,
);
}
const data = (await res.json()) as TokenAuthResponse;
if (!data.token) {
throw new BaserowAuthError(
'BRIDGE_BASEROW_AUTH_FAILED: refresh response missing token field',
502,
);
}
return data.token;
}
}
// ---------------------------------------------------------------------------
// Disabled stub (when creds not configured)
// ---------------------------------------------------------------------------
export class BaserowJwtManagerDisabled implements BaserowJwtManager {
isEnabled(): boolean {
return false;
}
getToken(): Promise<string> {
return Promise.reject(
new BaserowAuthError(
'BASEROW_USER_AUTH_NOT_CONFIGURED: set BASEROW_USER_EMAIL and BASEROW_USER_PASSWORD to enable metadata endpoints',
503,
),
);
}
}
// ---------------------------------------------------------------------------
// Error class
// ---------------------------------------------------------------------------
export class BaserowAuthError extends Error {
constructor(
message: string,
public readonly statusCode: number,
) {
super(message);
this.name = 'BaserowAuthError';
}
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
export function createBaserowJwtManager(opts: {
baseUrl: string;
email?: string;
password?: string;
refreshMarginSeconds: number;
logger: Logger;
}): BaserowJwtManager {
if (opts.email && opts.password) {
return new BaserowJwtManagerImpl({
baseUrl: opts.baseUrl,
email: opts.email,
password: opts.password,
refreshMarginSeconds: opts.refreshMarginSeconds,
logger: opts.logger,
});
}
return new BaserowJwtManagerDisabled();
}