Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 43 additions & 41 deletions webui/static/js/spamoor.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@

(function() {
// Authentication state mirrored from window.ethpandaops.authenticatoor.
// Authentication state mirrored from the authenticatoor v2 client
// (window.ethpandaops.authenticatoor). The v2 client owns the session —
// a hidden iframe on the auth-service origin refreshes tokens before
// expiry and keeps login state in sync across every ethpandaops app and
// tab; its "status" events land in applyClientState below. Tokens are
// NOT cached here: getAuthToken() asks the client for a fresh one on
// every call.
//
// In open mode (no auth provider configured), authDisabled stays true
// and isAuthenticated is unconditionally true so all UI is unlocked.
// authError is set when an auth provider is configured but its
// client.js failed to load — the UI surfaces this as "Auth unreachable"
// rather than silently treating the request as open.
var authState = {
token: null,
user: null,
expiresAt: null,
isAuthenticated: false,
Expand Down Expand Up @@ -54,8 +60,9 @@
// exposed window.ethpandaops.authenticatoor, surface that as a visible
// error state and stay unauthenticated (so API calls produce real 401s
// rather than silent failures behind a fake-authed UI). Otherwise wire
// the login button and run checkLogin (fragment → cache → silent
// iframe) in the background.
// the login button and subscribe to the v2 client's "status" events —
// every session change (login/logout/refresh in ANY ethpandaops app or
// tab) re-renders the top bar through applyClientState.
function initAuth() {
if (isOpenMode()) {
authState.isAuthenticated = true;
Expand All @@ -65,13 +72,13 @@
}

var client = authClient();
if (!client) {
if (!client || typeof client.addEventListener !== 'function') {
authState.isAuthenticated = false;
authState.authDisabled = false;
authState.authError = 'Auth provider unreachable';
console.error('spamoor: auth provider configured (' +
window.spamoorConfig.authProviderURL +
') but client.js failed to load — API calls will return 401');
') but client.js (v2) failed to load — API calls will return 401');
updateAuthUI();
return;
}
Expand All @@ -87,20 +94,19 @@
});
}

client.checkLogin().then(function(info) {
if (info && info.authenticated) {
applyClientState(info);
}
}).catch(function() {
// Stay unauthenticated; user can click Login.
});
// The client replays the current session state once on subscribe, so
// this also settles the initial UI.
client.addEventListener('status', applyClientState);
}

// Mirror the auth client's state into our local authState.
// Mirror a TokenInfo pushed by the auth client into our local
// authState. The "refreshing" status still carries authenticated=true
// while the old token is valid, so the top bar doesn't flicker during
// background refreshes. No token is stored — getAuthToken() fetches a
// fresh one per request.
function applyClientState(info) {
authState.token = info.token || null;
authState.user = info.user || info.email || null;
authState.expiresAt = info.exp ? info.exp * 1000 : null;
authState.user = info.authenticated ? (info.user || null) : null;
authState.expiresAt = info.authenticated && info.exp ? info.exp * 1000 : null;
authState.isAuthenticated = !!info.authenticated;
authState.authDisabled = false;
updateAuthUI();
Expand Down Expand Up @@ -159,45 +165,41 @@
window.dispatchEvent(new CustomEvent('authStateChanged', { detail: authState }));
}

// Get current auth token. In open mode there's no token to send;
// returns null and authFetch leaves the request unauthenticated.
// Resolve a FRESH auth token from the v2 client — asked for on every
// call, never cached here (the client's shared frame refreshes it
// before expiry). Returns a Promise<string|null>; resolves null in open
// mode or when unauthenticated.
function getAuthToken() {
var client = authClient();
if (client) {
var t = client.getToken();
if (t) return t;
}
return null;
if (!client) return Promise.resolve(null);
return client.getToken().catch(function() { return null; });
}

// Check if user is authenticated. In open mode this is always true
// (the backend treats every request as authorized).
// (the backend treats every request as authorized). Kept in sync by the
// client's "status" events.
function isAuthenticated() {
if (authState.authDisabled) return true;
var client = authClient();
if (client) return !!client.isLoggedIn();
return false;
return !!authState.isAuthenticated;
}

// Fetch with the current bearer token attached. On 401 the auth client
// is asked to re-check (the client itself decides whether to silent-
// refresh or surface a logged-out state).
// Fetch with a fresh bearer token attached (resolved per request from
// the auth client). On 401 the client is asked for its current status
// so the top bar converges with reality.
function authFetch(url, options) {
options = options || {};
options.headers = options.headers || {};

var token = getAuthToken();
if (token) {
options.headers['Authorization'] = 'Bearer ' + token;
}

return fetch(url, options).then(function(response) {
return getAuthToken().then(function(token) {
if (token) {
options.headers['Authorization'] = 'Bearer ' + token;
}
return fetch(url, options);
}).then(function(response) {
if (response.status === 401) {
var client = authClient();
if (client) {
client.checkLogin().then(function(info) {
if (info && info.authenticated) applyClientState(info);
});
if (client && typeof client.getStatus === 'function') {
client.getStatus().then(applyClientState).catch(function() {});
}
}
return response;
Expand Down
2 changes: 1 addition & 1 deletion webui/templates/_layout/layout.html
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
};
</script>
{{ if .AuthProviderURL }}
<script src="{{ .AuthProviderURL }}/client.js"></script>
<script src="{{ .AuthProviderURL }}/client-v2.js"></script>
{{ end }}
<script src="/js/jquery.min.js"></script>
<script src="/js/bootstrap.bundle.min.js"></script>
Expand Down
49 changes: 28 additions & 21 deletions webui/templates/index/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1139,32 +1139,39 @@ <h5 class="modal-title">Effective Config</h5>
url.searchParams.set('since', lastTime);
}

/* Add auth token to SSE connection via query param (EventSource doesn't support headers) */
const token = spamoor.getAuthToken();
if (token) {
url.searchParams.set('auth', token);
}
/* Fetch a fresh auth token for the SSE query param (EventSource doesn't
support headers). getAuthToken() resolves async via the auth client. */
spamoor.getAuthToken().then((token) => {
/* Panel closed (or stream restarted) while the token was resolving */
const logRow = logsDiv.closest('.log-row');
if (logRow.classList.contains('d-none') || logEventSources.has(id)) {
return;
}

const sse = new EventSource(url.toString());
if (token) {
url.searchParams.set('auth', token);
}

sse.onmessage = (event) => {
const logs = JSON.parse(event.data);
appendLogs(logs, logsDiv);
};
const sse = new EventSource(url.toString());

sse.onerror = (error) => {
console.error('Log stream error:', error);
sse.close();
logEventSources.delete(id);
sse.onmessage = (event) => {
const logs = JSON.parse(event.data);
appendLogs(logs, logsDiv);
};

const logRow = logsDiv.closest('.log-row');
if (!logRow.classList.contains('d-none')) {
console.log('Reconnecting log stream in 2 seconds...');
setTimeout(() => connect(), 2000);
}
};
sse.onerror = (error) => {
console.error('Log stream error:', error);
sse.close();
logEventSources.delete(id);

logEventSources.set(id, sse);
if (!logRow.classList.contains('d-none')) {
console.log('Reconnecting log stream in 2 seconds...');
setTimeout(() => connect(), 2000);
}
};

logEventSources.set(id, sse);
});
}

connect();
Expand Down