- 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
285 lines
9.1 KiB
TypeScript
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();
|
|
}
|