/** * 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; /** 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 | 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 { // 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 { 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 { 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 { 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 { 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(); }