/**
* BeNomad BeMap JavaScript API — TilesAuth (v2.0)
*
* JWT lifecycle for the BeNomad Tiles Worker. Replaces the buggy
* global `window.fetch` patch that the v1 prototype shipped with.
*
* Responsibilities:
* - POST credentials to `Context.getTilesLoginUrl()` and store the JWT.
* - Parse the `exp` claim and schedule **proactive** renewal 5 min before
* expiry; fall back to 55 min when `exp` is unparseable.
* - Renew **reactively** on `401` errors caught from MapLibre's `error`
* event, then trigger a `setStyle()` reload to retry pending tiles.
* - On `document.visibilitychange` → `visible`: full re-login if the token
* is already expired, proactive refresh if within 5 min, no-op otherwise.
* - On `navigator.onLine === false`: queue the renewal until `online`.
* - Expose `attachTransformRequest(nativeMap)` that injects the
* `X-Session-Token` header on every request whose origin matches the
* configured `tilesHost`. The closure captures `getToken()` not the
* token value, so renewal is transparent.
* - Keep the last 3 tokens in `_history` for diagnostics.
*
* Tokens are stored according to `Context.tokenStorage`:
* - `'session'` — `sessionStorage` (default; cleared on tab close)
* - `'local'` — `localStorage` (persists across reloads)
* - `'memory'` — never persisted
*
* @since 2.0.0
* @public
* @constructor
* @param {bemap.Context} ctx
* @param {Object} [options]
* @param {Function} [options.onError] Called with a `bemap.Error` on auth / network failures.
* @param {Function} [options.onToken] Called with `{ reason, token }` on token changes.
*/
bemap.TilesAuth = function (ctx, options) {
var opts = options || {};
this.ctx = ctx;
this._token = null;
this._exp = 0;
this._history = [];
this._renewalTimer = null;
this._queuedRenewal = false;
this._onError = typeof opts.onError === 'function' ? opts.onError : function () {};
this._onToken = typeof opts.onToken === 'function' ? opts.onToken : function () {};
// Resolved tile-auth wire config `{ mode, credentials, tokenHeader, tokenParam }`.
// Per-map `options.tilesAuth` overrides the Context's `tilesAuth`, both
// normalised (unset ⇒ `mode:'auto'`). Read by the fetch interceptor,
// transformRequest, login and logout so the consumer chooses the wire auth.
this._authConfig = (bemap.Context && bemap.Context._resolveTilesAuth)
? bemap.Context._resolveTilesAuth(opts.tilesAuth, ctx && ctx.tilesAuth)
: { mode: 'header', credentials: 'include', tokenHeader: 'X-Session-Token', tokenParam: 'token' };
// Turn the default `mode:'auto'` into a concrete wire mode ONCE, here, so every
// downstream site (interceptor, transformRequest, login, logout, TilesStyle)
// sees a resolved `cookie`/`header`/`query`: same registrable domain as
// `tilesHost` ⇒ cookie (first-party, zero preflight); cross-site ⇒ header
// (token carried explicitly, works in incognito). Explicit modes pass through.
if (bemap.Context && bemap.Context._resolveAutoMode) {
this._authConfig = bemap.Context._resolveAutoMode(this._authConfig, ctx && ctx.tilesHost);
}
// PROXY MODE (EVMOVE-307 / decision D1) — the cookie wire CANNOT work behind a
// proxy: the session cookie is set by the login RESPONSE, which now comes from
// the proxy's origin, so it is never sent on the DIRECT tile requests to
// `tilesHost` ⇒ every tile 401s. Force the header wire (the token comes back in
// the login body, which the proxy relays unchanged). An explicit `query` pin is
// honoured (it also carries the token explicitly); an explicit `cookie` pin is
// overridden with a one-shot warning — a config that cannot work must not fail
// silently. Unreachable without `ctx.proxy`, so no existing consumer changes.
var _ctxProxy = ctx && (typeof ctx.getProxy === 'function' ? ctx.getProxy() : ctx.proxy);
if (_ctxProxy && this._authConfig && this._authConfig.mode !== 'query') {
if (this._authConfig.mode === 'cookie' && !bemap.TilesAuth._proxyCookieWarned) {
bemap.TilesAuth._proxyCookieWarned = true;
if (typeof console !== 'undefined' && console.warn) {
console.warn(
'[bemap.TilesAuth] tilesAuth:\'cookie\' cannot work with `proxy` set —\n' +
' the session cookie would belong to the proxy origin and would never\n' +
' be sent on the direct tile requests to tilesHost. Using the\n' +
' `header` wire (X-Session-Token) instead.'
);
}
}
this._authConfig.mode = 'header';
}
this._storageKey = 'bemap_tiles_token';
this._storage = this._resolveStorage(ctx && ctx.tokenStorage);
this._restoreFromStorage();
this._installLifecycleHooks();
};
bemap.TilesAuth.STORAGE_KEY = 'bemap_tiles_token';
/* ============================================================
* Module-scope interceptor state — NOT enumerable on the
* `bemap.TilesAuth` constructor so that same-origin scripts
* cannot pre-poison the registry by writing to
* `bemap.TilesAuth._registry['mptiles-api-beta.benomad.net'] = …`.
* Test code reaches in via the accessor helpers below.
* ============================================================ */
var _tilesAuthState = {
registry: {}, // { tilesHost → auth }
patched: false,
originalFetch: null,
fastHostList: [], // for cheap substring short-circuit in the wrapper
rangePolicy: null // shared bemap.RangeFetchPolicy (lazily created)
};
// Accessors retained for tests (they need to reset / inspect state).
bemap.TilesAuth._getRegistry = function () { return _tilesAuthState.registry; };
bemap.TilesAuth._resetForTest = function () {
_tilesAuthState.registry = {};
_tilesAuthState.patched = false;
_tilesAuthState.originalFetch = null;
_tilesAuthState.fastHostList = [];
_tilesAuthState.rangePolicy = null;
};
// Backwards-compatibility shims for the older test file — read-only.
Object.defineProperty(bemap.TilesAuth, '_registry', { get: function () { return _tilesAuthState.registry; }, set: function (v) { _tilesAuthState.registry = v || {}; } });
Object.defineProperty(bemap.TilesAuth, '_fetchPatched', { get: function () { return _tilesAuthState.patched; }, set: function (v) { _tilesAuthState.patched = !!v; } });
Object.defineProperty(bemap.TilesAuth, '_originalFetch', { get: function () { return _tilesAuthState.originalFetch; }, set: function (v) { _tilesAuthState.originalFetch = v; } });
/**
* Install a scoped `window.fetch` wrapper that injects `X-Session-Token`
* on every request whose host matches `tilesHost`. The wrapper awaits
* the in-flight login when the token is not yet available, so the very
* first PMTiles range request fired by `pmtiles.js` (which bypasses
* MapLibre's `transformRequest`) transparently waits for the JWT.
*
* Idempotent — calling it twice on the same page is a no-op. Multi-host
* scenarios accumulate by registering the second auth's host into a
* shared map.
*
* @public
* @since 2.0.0
* @param {bemap.TilesAuth} auth
* @param {String} tilesHost
*/
/**
* Stamp auth onto a Worker-origin tile/style request per the resolved config.
* Returns `{ input, init }` (query mode may rewrite the URL). NEVER mutates the
* caller's `init`. `token` is only needed for `header` / `query` modes.
* - `cookie` (default): `init.credentials = cfg.credentials` — no custom header
* and no query param, so a Range GET stays a CORS-safelisted "simple" request
* (no preflight).
* - `header`: set `cfg.tokenHeader` to the token; credentials left at the
* browser default (no cookie).
* - `query`: append `cfg.tokenParam=<token>` to the URL. (The SW strips
* token/jwt/X-Session-Token from its cache key, so caching still works.)
* @private
*/
function _applyTileAuth(input, init, cfg, token) {
var out = Object.assign({}, init || {});
// By the time a request reaches here the mode is concrete (the constructor
// resolved any `'auto'`); `header` is the safe fallback if a mode ever went missing.
var mode = (cfg && cfg.mode) || 'header';
if (mode === 'header') {
if (token) {
var headers = new Headers((init && init.headers) || (input && input.headers) || {});
headers.set((cfg && cfg.tokenHeader) || 'X-Session-Token', token);
out.headers = headers;
}
return { input: input, init: out };
}
if (mode === 'query') {
if (token) {
var param = (cfg && cfg.tokenParam) || 'token';
var url = (typeof input === 'string') ? input : (input && input.url) || '';
if (url) {
var sep = (url.indexOf('?') === -1) ? '?' : '&';
var newUrl = url + sep + encodeURIComponent(param) + '=' + encodeURIComponent(token);
// Query mode targets idempotent tile/style GETs (which have no body).
// `new Request(newUrl, input)` MOVES the body stream, so it must not be
// used for a Request with a body (POST/PUT) — safe here because tile reads
// are always GET (and /api/login is short-circuited above).
input = (typeof input === 'string') ? newUrl : new Request(newUrl, input);
}
}
return { input: input, init: out };
}
// 'cookie' (default)
out.credentials = (cfg && cfg.credentials) || 'include';
return { input: input, init: out };
}
bemap.TilesAuth.installFetchInterceptor = function (auth, tilesHost) {
if (!auth || !tilesHost || typeof window === 'undefined' || typeof window.fetch !== 'function') return;
_tilesAuthState.registry[tilesHost] = auth;
if (_tilesAuthState.fastHostList.indexOf(tilesHost) === -1) _tilesAuthState.fastHostList.push(tilesHost);
if (_tilesAuthState.patched) return;
_tilesAuthState.patched = true;
_tilesAuthState.originalFetch = window.fetch.bind(window);
var orig = function (input, init) { return _tilesAuthState.originalFetch(input, init); };
window.fetch = function (input, init) {
var url = (typeof input === 'string') ? input : (input && input.url) || '';
if (!url) return orig(input, init);
// Cheap substring short-circuit — avoids the cost of `new URL()`
// parsing on the dozens of unrelated fetches a typical page issues.
var fast = false;
for (var i = 0; i < _tilesAuthState.fastHostList.length; i++) {
if (url.indexOf(_tilesAuthState.fastHostList[i]) !== -1) { fast = true; break; }
}
if (!fast) return orig(input, init);
// Now confirm with strict host parsing (substring would match
// `https://evil.com/?h=mptiles-api-beta.benomad.net`).
var host = null;
try { host = new URL(url, window.location.href).host; } catch (e) { /* malformed */ }
if (!host) return orig(input, init);
var registered = _tilesAuthState.registry[host];
if (!registered) return orig(input, init);
// Login itself uses Basic Auth (+ the Set-Cookie it stores) — never touch
// /api/login here.
if (url.indexOf('/api/login') !== -1) return orig(input, init);
// For every other Worker URL, await login, then stamp auth per the
// registered auth's resolved config (`_applyTileAuth`): cookie (default) →
// `credentials:'include'`, no custom header → no CORS preflight; header →
// the token header; query → `?token=` on the URL. RangeFetchPolicy still
// applies `cache:'no-store'` afterwards (it only touches `cache`, so
// whatever `_applyTileAuth` set survives). Never mutates the caller's init.
return registered.whenTokenReady().then(function (token) {
var applied = _applyTileAuth(input, init, registered._authConfig, token);
var newInit = applied.init;
if (bemap.RangeFetchPolicy) {
if (!_tilesAuthState.rangePolicy) _tilesAuthState.rangePolicy = new bemap.RangeFetchPolicy({ rangeCacheMode: bemap.RangeFetchPolicy.defaultMode || 'auto' });
newInit = _tilesAuthState.rangePolicy.decorate(applied.input, newInit);
}
return orig(applied.input, newInit);
}, function () {
// Login failed — let the request fly unchanged so the server's 401 surfaces
// through the normal error channels (this wrapper never swallows it).
return orig(input, init);
});
};
};
/**
* Uninstall the per-host registration. When the registry becomes empty,
* restore the original `window.fetch` so SPA navigation does not leak
* the patch across map lifecycles.
* @public
* @since 2.0.0
*/
bemap.TilesAuth.uninstallFetchInterceptor = function (tilesHost) {
if (!tilesHost) return;
delete _tilesAuthState.registry[tilesHost];
var idx = _tilesAuthState.fastHostList.indexOf(tilesHost);
if (idx !== -1) _tilesAuthState.fastHostList.splice(idx, 1);
var empty = true;
for (var k in _tilesAuthState.registry) { if (Object.prototype.hasOwnProperty.call(_tilesAuthState.registry, k)) { empty = false; break; } }
if (empty && _tilesAuthState.patched && _tilesAuthState.originalFetch) {
window.fetch = _tilesAuthState.originalFetch;
_tilesAuthState.patched = false;
_tilesAuthState.originalFetch = null;
}
};
bemap.TilesAuth.prototype._resolveStorage = function (mode) {
if (mode === 'memory') return null;
try {
if (mode === 'local' && typeof localStorage !== 'undefined') return localStorage;
if (typeof sessionStorage !== 'undefined') return sessionStorage;
} catch (e) { /* private mode / disabled storage */ }
return null;
};
bemap.TilesAuth.prototype._restoreFromStorage = function () {
if (!this._storage) return;
try {
var raw = this._storage.getItem(this._storageKey);
if (!raw) return;
var parsed = JSON.parse(raw);
if (parsed && parsed.token) {
this._token = parsed.token;
this._exp = parsed.exp || 0;
this._scheduleRenewal();
}
} catch (e) { /* ignore corrupt storage */ }
};
bemap.TilesAuth.prototype._persist = function () {
if (!this._storage) return;
try {
this._storage.setItem(this._storageKey, JSON.stringify({ token: this._token, exp: this._exp }));
} catch (e) { /* quota / disabled */ }
};
bemap.TilesAuth.prototype._clearStorage = function () {
if (!this._storage) return;
try { this._storage.removeItem(this._storageKey); } catch (e) { /* ignore */ }
};
/**
* Parse the `exp` claim from a JWT, return its value in seconds since epoch,
* or `0` when the token cannot be parsed.
* @private
*/
bemap.TilesAuth.prototype._parseExp = function (token) {
if (!token || typeof token !== 'string') return 0;
var parts = token.split('.');
if (parts.length < 2) return 0;
try {
var payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
while (payload.length % 4) payload += '=';
var json = (typeof atob === 'function')
? atob(payload)
: Buffer.from(payload, 'base64').toString('utf8');
var claims = JSON.parse(json);
return typeof claims.exp === 'number' ? claims.exp : 0;
} catch (e) { return 0; }
};
/**
* INTERNAL — how long `ctx.tilesSessionProvider` may take before the login is
* failed. `whenTokenReady()` runs on EVERY tiles fetch, so a provider that never
* settles would stall the whole map with no error and no grey-tile recovery.
* A static (not an option) so it stays out of the public surface; tests override
* it. Any value `<= 0` (or a non-number) disables the timeout.
* @private
*/
bemap.TilesAuth._sessionTimeoutMs = 10000;
/**
* INTERNAL — normalise every way the session provider can go wrong into ONE
* `bemap.Error`. The session id is NEVER interpolated into the message: an
* uncaught-error handler or crash reporter must not capture it.
* @private
* @param {String} message
* @param {*} [cause]
* @return {bemap.Error}
*/
bemap.TilesAuth._sessionError = function (message, cause, code) {
var e = new bemap.Error({ code: code || bemap.Error.UNAUTHORIZED, message: message });
// `!== undefined`, not truthiness: a provider may reject with 0 or '' and the
// cause is still worth keeping for diagnostics.
if (cause !== undefined) e.cause = cause;
return e;
};
/**
* INTERNAL — the longest session id we will put in a header. Not a protocol
* limit; a guard so a buggy provider cannot push an unbounded value onto every
* login and renewal.
* @private
*/
bemap.TilesAuth._sessionMaxLength = 4096;
/**
* INTERNAL — invoke the session provider under a timeout, converting a
* synchronous throw, a rejected promise, a non-string, a blank string and a
* header-hostile value into the same single error contract.
* @private
* @param {Function} provider
* @return {Promise<String>} the trimmed session id
*/
bemap.TilesAuth._callSessionProvider = function (provider) {
// `Promise.resolve().then(...)` so a SYNCHRONOUS throw inside the provider
// takes exactly the same path as a rejected promise — one error contract.
var call = Promise.resolve().then(function () {
return provider();
}).then(function (sessionId) {
var sid = (typeof sessionId === 'string') ? sessionId.trim() : '';
if (!sid) {
throw bemap.TilesAuth._sessionError(
'TilesAuth.login: the tiles session provider resolved no usable session');
}
// Anything outside printable US-ASCII cannot go in a header: `fetch` fails
// the request while building it, and for a NUL/control byte the engine's
// message QUOTES THE RAW VALUE — which would put the session id into an
// error a crash reporter captures, defeating the whole redaction rule. So
// the check is an allow-list, not a `[\r\n]` deny-list: CR, LF, NUL,
// U+2028/U+2029 and every non-Latin-1 code point are refused here, by us,
// with a message that names nothing.
if (/[^\x20-\x7E]/.test(sid)) {
throw bemap.TilesAuth._sessionError(
'TilesAuth.login: the tiles session provider returned a session with characters that cannot be sent in a header');
}
if (sid.length > bemap.TilesAuth._sessionMaxLength) {
throw bemap.TilesAuth._sessionError(
'TilesAuth.login: the tiles session provider returned a session longer than ' +
bemap.TilesAuth._sessionMaxLength + ' characters');
}
return sid;
}, function (cause) {
throw bemap.TilesAuth._sessionError('TilesAuth.login: the tiles session provider failed', cause);
});
var ms = bemap.TilesAuth._sessionTimeoutMs;
if (!(ms > 0)) return call;
return new Promise(function (resolve, reject) {
var settled = false;
var timer = setTimeout(function () {
if (settled) return;
settled = true;
// NETWORK, not UNAUTHORIZED: a portal that is slow has not refused us, and
// a caller reading `code` should be free to retry rather than treat it as
// a hard auth failure.
reject(bemap.TilesAuth._sessionError(
'TilesAuth.login: the tiles session provider timed out after ' + ms + ' ms',
undefined, bemap.Error.NETWORK));
}, ms);
call.then(function (v) {
if (settled) return;
settled = true; clearTimeout(timer); resolve(v);
}, function (e) {
if (settled) return;
settled = true; clearTimeout(timer); reject(e);
});
});
};
/**
* Issue the login POST and persist the resulting JWT.
*
* Extracted from `login()` verbatim so the CREDENTIAL-building step can be
* synchronous (Basic / proxy) or asynchronous (session mode) without duplicating
* the request chain — and, more importantly, so the existing paths keep their
* exact scheduling: `login()` still issues their `fetch` during the call itself.
* @private
* @param {String} url
* @param {Object} headers
* @param {String} credentials
* @param {String} [redirect] `fetch`'s redirect mode. Only session mode passes
* one; omitted for Basic/proxy so their request init stays identical.
* @return {Promise<String>} the new JWT
*/
bemap.TilesAuth.prototype._startLogin = function (url, headers, credentials, redirect) {
var self = this;
var init = {
method: 'POST',
credentials: credentials,
headers: headers
};
// Added only when asked for, so the Basic/proxy init object is byte-identical
// to the one this code has always sent.
if (redirect) init.redirect = redirect;
return fetch(url, init).then(function (resp) {
if (!resp.ok) {
var code = resp.status === 401 ? bemap.Error.UNAUTHORIZED
: resp.status === 403 ? bemap.Error.FORBIDDEN
: resp.status === 429 ? bemap.Error.RATE_LIMITED
: bemap.Error.NETWORK;
var e = new bemap.Error({ code: code, status: resp.status, url: url, message: 'TilesAuth.login failed' });
self._onError(e);
throw e;
}
return resp.json().catch(function () { return null; });
}).then(function (body) {
var token = (body && (body.token || body.jwt || body.accessToken)) || null;
if (!token) {
var e = new bemap.Error({ code: bemap.Error.UNAUTHORIZED, message: 'TilesAuth.login: response had no token' });
self._onError(e);
throw e;
}
if (self._token) {
// History records only the exp claim, NEVER the full JWT — see
// SECURITY review. Subscribers that log `auth.getHistory()` must
// not be able to exfiltrate a still-valid expired token.
self._history.unshift({ exp: self._exp });
if (self._history.length > 3) self._history.length = 3;
}
self._token = token;
self._exp = self._parseExp(token);
self._persist();
self._scheduleRenewal();
// Only emit `'refresh'` notifications from refresh(); skip the
// synthetic `'login'` event during a refresh cycle so subscribers
// see exactly one event per logical operation.
if (!self._inRefresh) {
self._onToken({ reason: 'login', exp: self._exp });
}
return token;
});
};
/**
* POST credentials and persist the new JWT.
* @public
* @param {String} [login] Defaults to `ctx.login`.
* @param {String} [password] Defaults to `ctx.password`.
* @return {Promise<String>} The new JWT.
*/
bemap.TilesAuth.prototype.login = function (login, password) {
var self = this;
// Track the in-flight login promise so whenTokenReady() can await it
// without starting a second parallel login on every tile request.
if (this._loginInFlight) return this._loginInFlight;
var url = this.ctx && this.ctx.getTilesLoginUrl();
if (!url) {
return Promise.reject(new bemap.Error({
code: bemap.Error.UNAUTHORIZED,
message: 'TilesAuth.login: no tilesHost configured on Context'
}));
}
var user = login || (this.ctx && this.ctx.login);
var pass = password || (this.ctx && this.ctx.password);
// PROXY MODE (EVMOVE-307): credentials live on the proxy, so a credential-less
// Context is the NORMAL, supported case — skip both the rejection and the
// warning. No `Authorization` is ever sent; instead the proxy needs to know
// which mptiles environment will be read (each signs tokens with its own
// secret) via `X-BeMap-Tiles-Host`, plus the optional `X-BeMap-Env` selector.
var isProxy = !!(this.ctx && (typeof this.ctx.getProxy === 'function' ? this.ctx.getProxy() : this.ctx.proxy));
// SESSION MODE (INTERNAL) — the host application already holds a BeMap session
// and cannot ship a password. Consulted ONLY when proxy has not already won:
// the provider does real work (a call to the portal) and must never run for a
// login that would not have used it. ONLY a function enables the mode — a
// truthy non-function falls through to the normal credential path and its
// existing error, instead of silently sending a request that can only 401.
var sessionProvider = (!isProxy && this.ctx && typeof this.ctx.tilesSessionProvider === 'function')
? this.ctx.tilesSessionProvider : null;
// A provider that was SET but is not callable is a wiring mistake in the host
// application, not a customer misconfiguration — report it precisely, and only
// to whoever already tried to use the internal mode. The customer-facing
// missing-credentials message below stays deliberately unchanged: naming an
// undocumented option there would advertise it to every customer who ever
// forgets a password.
// The constructor and setter already report this, so the only way to arrive
// here is a RAW property write — which `bemap.RuntimeConfig`'s `set` trap does
// for every unknown key, straight onto the shared Context.
if (!isProxy && !sessionProvider && this.ctx && this.ctx.tilesSessionProvider &&
bemap.Context && typeof bemap.Context._warnBadSessionProvider === 'function') {
bemap.Context._warnBadSessionProvider(this.ctx.tilesSessionProvider);
}
if (!isProxy && !sessionProvider && (!user || !pass)) {
// Loud, one-shot warning so the developer sees the cause immediately
// in the DevTools console (instead of just an unexplained 401 on the
// first PMTiles range request).
if (!bemap.TilesAuth._missingCredsWarned) {
bemap.TilesAuth._missingCredsWarned = true;
if (typeof console !== 'undefined' && console.warn) {
console.warn(
'[bemap.TilesAuth] No credentials on the Context — BeNomad Tiles\n' +
' requests will all 401 because no /api/login POST can be sent.\n' +
' Set `login` and `password` on your `new bemap.Context({...})`,\n' +
' OR remove `tilesHost` to fall back to WMS.'
);
}
}
return Promise.reject(new bemap.Error({
code: bemap.Error.UNAUTHORIZED,
message: 'TilesAuth.login: missing credentials on Context (set ctx.login + ctx.password, or remove ctx.tilesHost)'
}));
}
// Computed SYNCHRONOUSLY and exactly where it always was, so a `btoa` failure
// (a non-Latin-1 password) still throws out of `login()` the way it does
// today. Skipped entirely when a credential-less mode is active — session mode
// must never evaluate `user + ':' + pass`.
var basic = (isProxy || sessionProvider) ? null : ('Basic ' + ((typeof btoa === 'function')
? btoa(user + ':' + pass)
: Buffer.from(user + ':' + pass).toString('base64')));
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
self._queuedRenewal = true;
var err = new bemap.Error({ code: bemap.Error.OFFLINE, message: 'TilesAuth.login: offline, queued' });
self._onError(err);
return Promise.reject(err);
}
// In COOKIE mode, `credentials` MUST be sent so the browser STORES the
// Worker's `Set-Cookie: session=…; HttpOnly; Secure; SameSite=None` — without
// it the cookie is never stored and every tile 401s despite a valid in-memory
// token. In header/query mode the consumer opted out of cookies, so we use
// `'same-origin'` (no cross-site cookie); the body token still drives auth.
var loginCredentials = (self._authConfig && self._authConfig.mode === 'cookie')
? (self._authConfig.credentials || 'include')
: 'same-origin';
// Header set: credentials mode sends Basic; proxy mode sends NO credential and
// instead tells the proxy which tiles env to mint for (+ the env selector when
// configured). `getProxyHeaders()` only yields X-BeMap-Env for a non-empty
// bemapEnv, so the literal string "null" can never reach the wire.
var loginHeaders = { 'Accept': 'application/json' };
var promise;
if (sessionProvider) {
// The ONLY asynchronous path: the provider returns a Promise, so the header
// cannot be built synchronously. The two-argument `.then` is deliberate —
// the rejection handler sees provider failures ONLY, never a failure of
// `_startLogin` (which reports through `_onError` itself), so a login can
// never fire `_onError` twice.
promise = bemap.TilesAuth._callSessionProvider(sessionProvider).then(function (sessionId) {
// The provider round trip is the one place a login can stay pending for
// seconds, so it is the one place `destroy()` can realistically land in
// the middle of. Stop here rather than let _startLogin re-arm a renewal
// timer on a dead instance. (Basic/proxy issue their fetch in the same
// tick, so this window is specific to session mode.)
if (self._destroyed) {
throw bemap.TilesAuth._sessionError('TilesAuth.login: aborted — the auth instance was destroyed');
}
loginHeaders['X-BeMap-Session'] = sessionId;
// `redirect:'error'` for session mode ONLY. A browser strips
// `Authorization` on a cross-origin redirect, but it forwards a CUSTOM
// header — so a mis-pointed or hijacked login endpoint could bounce the
// session id to another origin. Basic/proxy keep their existing init.
return self._startLogin(url, loginHeaders, loginCredentials, 'error');
}, function (err) {
self._onError(err);
throw err;
});
} else {
if (isProxy) {
if (self.ctx.tilesHost) loginHeaders['X-BeMap-Tiles-Host'] = self.ctx.tilesHost;
if (typeof self.ctx.getProxyHeaders === 'function') {
var ph = self.ctx.getProxyHeaders();
for (var phk in ph) {
if (Object.prototype.hasOwnProperty.call(ph, phk)) loginHeaders[phk] = ph[phk];
}
}
} else {
loginHeaders['Authorization'] = basic;
}
// Unchanged scheduling: the request is issued during this call, exactly as
// before the session branch existed.
promise = this._startLogin(url, loginHeaders, loginCredentials);
}
// Track and clear on settle. Both success and failure clear, so a
// failed login does not pin a poisoned promise on subsequent calls.
this._loginInFlight = promise;
promise.then(
function () { self._loginInFlight = null; },
function () { self._loginInFlight = null; }
);
return promise;
};
/**
* Resolve with the current valid JWT, kicking off (or joining) a login
* if no token is available. The synchronisation primitive that the
* fetch interceptor uses to delay PMTiles range requests until login
* completes — without making the MapLibreMap constructor itself async.
*
* @public
* @since 2.0.0
* @return {Promise<String|null>}
*/
bemap.TilesAuth.prototype.whenTokenReady = function () {
if (this._token && this.isValid()) return Promise.resolve(this._token);
if (this._loginInFlight) return this._loginInFlight;
return this.login();
};
/** @return {String|null} */
bemap.TilesAuth.prototype.getToken = function () { return this._token; };
/** @return {Boolean} */
bemap.TilesAuth.prototype.isValid = function () {
if (!this._token) return false;
if (!this._exp) return true; // unparseable exp — assume valid until 401
var nowSec = Date.now() / 1000;
return this._exp > nowSec + 30;
};
/**
* Returns the last-3 history of superseded tokens — only the `exp` claim,
* never the raw JWT.
* @return {Array<{exp:Number}>}
*/
bemap.TilesAuth.prototype.getHistory = function () { return this._history.slice(); };
/**
* Proactive renewal — re-uses the original credentials. Returns the new
* token. Subscribers see exactly one `_onToken({ reason: 'refresh' })`
* event per logical refresh; the synthetic `'login'` event is suppressed
* during refresh to avoid the 3-events-per-cycle problem.
*
* @public
* @return {Promise<String>}
*/
bemap.TilesAuth.prototype.refresh = function () {
if (this._refreshInFlight) return this._refreshInFlight;
var self = this;
// `_inRefresh` suppresses the synthetic 'login' event inside login()'s .then.
// Only claim it when refresh actually STARTS the login — if a plain login() is
// already in-flight we JOIN it, and must NOT suppress its 'login' event for
// the original caller (the flag is read later, in that login's .then).
var startsLogin = !this._loginInFlight;
if (startsLogin) this._inRefresh = true;
var p = this.login().then(function (t) {
if (startsLogin) self._inRefresh = false;
self._refreshInFlight = null;
self._onToken({ reason: 'refresh', exp: self._exp });
return t;
}, function (err) {
if (startsLogin) self._inRefresh = false;
self._refreshInFlight = null;
throw err;
});
this._refreshInFlight = p;
return p;
};
bemap.TilesAuth.prototype._scheduleRenewal = function () {
if (this._renewalTimer) {
clearTimeout(this._renewalTimer);
this._renewalTimer = null;
}
var ms;
if (this._exp) {
ms = (this._exp * 1000) - Date.now() - 5 * 60 * 1000;
} else {
ms = 55 * 60 * 1000;
}
ms = Math.max(0, Math.min(24 * 60 * 60 * 1000, ms));
var self = this;
this._renewalTimer = setTimeout(function () {
self.refresh().catch(function () { /* error already surfaced */ });
}, ms);
};
bemap.TilesAuth.prototype._installLifecycleHooks = function () {
// Idempotency guard — re-entrant constructors / subclass init must
// not stack duplicate listeners. `destroy()` removes whatever set
// here, but only the first set; a second install would leak.
if (this._lifecycleInstalled) return;
this._lifecycleInstalled = true;
var self = this;
if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') {
this._visibilityHandler = function () {
if (document.visibilityState !== 'visible') return;
if (!self._token) return;
if (!self.isValid()) {
self.refresh().catch(function () { /* surfaced via _onError */ });
} else if (self._exp && (self._exp - Date.now() / 1000) < 5 * 60) {
self.refresh().catch(function () { /* surfaced */ });
}
};
document.addEventListener('visibilitychange', this._visibilityHandler);
}
if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
this._onlineHandler = function () {
if (self._queuedRenewal) {
self._queuedRenewal = false;
self.refresh().catch(function () { /* surfaced */ });
}
};
window.addEventListener('online', this._onlineHandler);
}
};
/**
* Build the MapLibre `transformRequest` function. Injects
* `X-Session-Token` on every request whose origin matches `ctx.tilesHost`.
* The closure captures `this.getToken` so token renewal is transparent.
*
* Surface the result as `map.native = new maplibregl.Map({ transformRequest: auth.buildTransformRequest() })`.
*
* @public
* @return {Function}
*/
bemap.TilesAuth.prototype.buildTransformRequest = function () {
var self = this;
var host = this.ctx && this.ctx.tilesHost;
return function (url, resourceType) {
if (!host) return { url: url };
var matches = false;
try {
var parsed = new URL(url, (typeof window !== 'undefined' && window.location && window.location.href) || 'http://localhost/');
matches = (parsed.host === host);
} catch (e) {
matches = (url.indexOf('://' + host) > -1) || (url.indexOf('//' + host) === 0);
}
if (!matches) return { url: url }; // other origin — no auth, no leak
// On the tiles host, authenticate per the resolved config (MapLibre GL v5
// RequestParameters honours `credentials` and `headers` for style/glyph/
// sprite/XYZ). Default cookie mode sends `credentials` and NO header → no
// CORS preflight. (pmtiles archive Range reads bypass transformRequest and
// are handled by the global fetch interceptor.)
var cfg = self._authConfig || { mode: 'header', credentials: 'include', tokenHeader: 'X-Session-Token' };
if (cfg.mode === 'header') {
var ht = (typeof self.getToken === 'function') ? self.getToken() : null;
var headers = {};
if (ht) headers[cfg.tokenHeader || 'X-Session-Token'] = ht;
return { url: url, headers: headers };
}
if (cfg.mode === 'query') {
var qt = (typeof self.getToken === 'function') ? self.getToken() : null;
if (qt) {
var sep = (url.indexOf('?') === -1) ? '?' : '&';
return { url: url + sep + encodeURIComponent(cfg.tokenParam || 'token') + '=' + encodeURIComponent(qt) };
}
return { url: url };
}
return { url: url, credentials: cfg.credentials || 'include' };
};
};
/**
* Mark a tile / style request as failed with HTTP 401 — triggers a refresh
* followed by a `setStyle` reload of the current style spec, so the renewed
* token is picked up by every pending request.
*
* @public
* @param {Object} nativeMap The map to reload — normally the bemap.MapLibreMap
* WRAPPER (so its overlay-replay listener fires and `{_internal:true}` is
* honoured), or a raw `map.native` instance (which ignores the option).
* @param {Object} [currentStyleSpec] Style currently displayed, for the reload.
* @return {Promise<String>}
*/
bemap.TilesAuth.prototype.handle401 = function (nativeMap, currentStyleSpec) {
// N simultaneous 401 errors → one shared refresh + one shared
// setStyle reload. Without this dedup, a 401-storm produces N
// parallel `setStyle` calls and N overlay catalog replays.
if (this._handle401InFlight) return this._handle401InFlight;
var self = this;
var p = this.refresh().then(function (token) {
if (nativeMap && currentStyleSpec && typeof nativeMap.setStyle === 'function') {
// _internal: a 401-driven reload re-applies the SAME current spec, so it
// must not latch _userSetStyle on the wrapper (that would permanently
// suppress the init-time server default-style refresh). The wrapper reads
// the option; the native MapLibre map ignores the unknown key.
try { nativeMap.setStyle(currentStyleSpec, { _internal: true }); } catch (e) { /* ignore */ }
}
self._handle401InFlight = null;
return token;
}, function (err) {
self._handle401InFlight = null;
throw err;
});
this._handle401InFlight = p;
return p;
};
/**
* Clear the token, the history, the storage, and the renewal timer.
* Optionally POST to the Worker logout endpoint to revoke server-side.
*
* @public
* @param {Object} [options]
* @param {Boolean} [options.serverSide=false] When true, fire-and-forget POST `/api/logout`.
* @return {Promise<void>}
*/
bemap.TilesAuth.prototype.logout = function (options) {
var opts = options || {};
var token = this._token;
this._token = null;
this._exp = 0;
this._history.length = 0;
this._clearStorage();
if (this._renewalTimer) {
clearTimeout(this._renewalTimer);
this._renewalTimer = null;
}
if (opts.serverSide && this.ctx && typeof fetch === 'function') {
var url = this.ctx.getTilesLogoutUrl();
if (url && token) {
// Revoke per the resolved auth config so the Worker identifies the right
// session: cookie → send credentials (+ the token header as a harmless
// fallback for a header-keyed Worker); header → the configured header;
// query → the token on the URL.
var acfg = this._authConfig || { mode: 'header', credentials: 'include', tokenHeader: 'X-Session-Token', tokenParam: 'token' };
var lurl = url;
var linit = { method: 'POST' };
if (acfg.mode === 'query') {
var sep = (lurl.indexOf('?') === -1) ? '?' : '&';
lurl = lurl + sep + encodeURIComponent(acfg.tokenParam || 'token') + '=' + encodeURIComponent(token);
} else if (acfg.mode === 'header') {
linit.headers = {};
linit.headers[acfg.tokenHeader || 'X-Session-Token'] = token;
} else {
linit.credentials = acfg.credentials || 'include';
linit.headers = { 'X-Session-Token': token };
}
return fetch(lurl, linit)
.then(function () {})
.catch(function () {});
}
}
return Promise.resolve();
};
/**
* Detach lifecycle listeners. Call from `MapLibreMap.remove()`.
* @public
*/
bemap.TilesAuth.prototype.destroy = function () {
this._destroyed = true;
if (this._renewalTimer) {
clearTimeout(this._renewalTimer);
this._renewalTimer = null;
}
if (typeof document !== 'undefined' && this._visibilityHandler) {
document.removeEventListener('visibilitychange', this._visibilityHandler);
this._visibilityHandler = null;
}
if (typeof window !== 'undefined' && this._onlineHandler) {
window.removeEventListener('online', this._onlineHandler);
this._onlineHandler = null;
}
this._lifecycleInstalled = false;
// Remove our entry from the interceptor's registry — if we were the
// last entry, the wrapper restores `window.fetch` to its original.
var host = this.ctx && this.ctx.tilesHost;
if (host && typeof bemap.TilesAuth.uninstallFetchInterceptor === 'function') {
bemap.TilesAuth.uninstallFetchInterceptor(host);
}
};