Replace basic auth with Microsoft 365 sign-in

nginx now checks every dashboard and API request with oauth2-proxy
(auth_request), which signs users in with Microsoft Entra ID. Who may
sign in is controlled in Entra: a single-tenant app registration with
"Assignment required", so only assigned users/groups get through.

- deploy/oauth2-proxy.cfg (entra-id provider, auth-only mode, secure
  cookie, trusted proxy = localhost) and deploy/oauth2-proxy.service
- nginx: pages redirect to Microsoft sign-in and back to the same URL;
  API calls get a plain 401 (the page already asks to reload);
  /api/v1/ingest stays key-only for collectors; /oauth2/auth is
  internal; the signed-in user is passed to the API in headers nginx
  sets (so browsers can't spoof them); dashboard files are served with
  Cache-Control: no-cache so updates show up at once
- GET /api/v1/me; the top bar shows the signed-in user and a Sign out
  that also signs out of Microsoft (otherwise SSO signs straight back in)
- README: Entra and server setup steps

Tested locally with oauth2-proxy 7.15.5 and nginx 1.31 running the
deploy configs (TLS, self-signed): redirects to the tenant's sign-in
with the return URL kept, 401s, ingest bypass, sign-out allow-list,
cookie flags, security headers. The signed-in path was tested with a
stand-in for oauth2-proxy's auth answer; a real Entra sign-in wasn't.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-08 11:31:35 -06:00
co-authored by Claude Opus 5.5
parent 8d0e6dca71
commit 2fa9eea274
13 changed files with 231 additions and 18 deletions
+4
View File
@@ -49,6 +49,10 @@ const httpApi = {
getRun(id) {
return request(`/runs/${encodeURIComponent(id)}`);
},
/** GET /me → { email, name } of the Microsoft 365 user, both null without sign-in */
getMe() {
return request('/me');
},
};
// Mock is loaded lazily so it never ships in the production request path.
+18
View File
@@ -56,6 +56,9 @@ const dom = {
empty: $('#empty-state'),
search: $('#search'),
refreshBtn: $('#refresh-btn'),
signedIn: $('#signed-in'),
signedInEmail: $('#signed-in-email'),
signOut: $('#sign-out'),
lastUpdated: $('#last-updated'),
errorBanner: $('#error-banner'),
dialog: $('#detail-dialog'),
@@ -524,5 +527,20 @@ dom.search.addEventListener('input', () => {
dom.refreshBtn.addEventListener('click', () => refresh().then(schedulePoll));
// ---------- Signed-in user ----------
async function showSignedInUser() {
try {
const me = await api.getMe();
const who = me?.email || me?.name;
if (!who) return; // no Microsoft 365 sign-in in front of the dashboard (dev, mock)
dom.signedInEmail.textContent = who;
dom.signOut.href = CONFIG.signOutUrl;
dom.signedIn.hidden = false;
} catch (err) {
console.error(err); // not worth a banner: the jobs request will surface real problems
}
}
// ---------- Boot ----------
showSignedInUser();
refresh().then(schedulePoll);
+4
View File
@@ -12,4 +12,8 @@ export const CONFIG = Object.freeze({
historyDays: 30,
// ?mock=1 → use in-browser sample data, no backend needed
useMock: params.has('mock'),
// oauth2-proxy clears the dashboard session, then hands over to Microsoft's sign-out page.
// Signing out of the dashboard alone would be undone at once: the browser's Microsoft session
// would sign it straight back in.
signOutUrl: '/oauth2/sign_out?rd=' + encodeURIComponent('https://login.microsoftonline.com/common/oauth2/v2.0/logout'),
});
+3
View File
@@ -126,4 +126,7 @@ export const mockApi = {
}
throw new Error('Run not found');
},
async getMe() {
return { email: null, name: null }; // no sign-in in mock mode
},
};