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
+55 -8
View File
@@ -5,8 +5,9 @@ Proof of concept backup dashboard, intended to display backup jobs from multiple
``` ```
collectors ──POST /api/v1/ingest (API key)──▶ ┌──────────────┐ collectors ──POST /api/v1/ingest (API key)──▶ ┌──────────────┐
│ FastAPI + │ │ FastAPI + │
browser ──nginx (basic auth, TLS)──▶ /api/* ─▶│ SQLite │ browser ──nginx (TLS)──▶ /api/* ─────────────▶│ SQLite │
└─ static files: public/ └──────────────┘ │ └─ static files: public/ └──────────────┘
└─ auth_request ─▶ oauth2-proxy ─▶ Microsoft Entra ID (Microsoft 365 sign-in)
``` ```
- `public/`: static frontend (no build step). Add `?mock=1` to the URL to use built-in sample data. - `public/`: static frontend (no build step). Add `?mock=1` to the URL to use built-in sample data.
@@ -72,12 +73,58 @@ The OpenAPI schema is at `/api/v1/openapi.json`.
## Authentication ## Authentication
- **Dashboard users:** nginx basic auth over HTTPS (`deploy/nginx.conf`). Every user can see every tenant. - **Dashboard users:** Microsoft 365 sign-in. nginx checks every request with
*Planned:* Microsoft 365 sign-in through [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/) [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/), which signs users in with Microsoft
with the Entra ID provider and nginx `auth_request`. That's an nginx-only change. The frontend Entra ID. Who may sign in is controlled in Entra (users or a group assigned to the app). Every
already tells the user to reload when a session expires (HTTP 401). signed-in user can see every tenant. Sessions last 12 hours; signing in again is usually silent.
The dashboard shows who's signed in; **Sign out** also signs out of Microsoft in that browser,
otherwise the next visit would sign straight back in.
- **Collectors:** per-collector bearer keys, checked by the app. Only a SHA-256 hash is stored. - **Collectors:** per-collector bearer keys, checked by the app. Only a SHA-256 hash is stored.
To revoke one, run `python manage.py revoke-key NAME`. To revoke one, run `python manage.py revoke-key NAME`. `/api/v1/ingest` is the only path that
doesn't need Microsoft sign-in.
### Microsoft 365 sign-in setup
**In the Microsoft Entra admin center** (entra.microsoft.com), as an admin of your tenant:
1. *App registrations → New registration*. Name: `Backup Dashboard`. Supported account types:
**Accounts in this organizational directory only** (single tenant). Redirect URI: platform
**Web**, `https://<your dashboard host>/oauth2/callback`. Register.
2. On its *Overview*, note the **Application (client) ID** and **Directory (tenant) ID**.
3. *Certificates & secrets → New client secret*. Copy the secret's **Value** now (it's only shown
once). Note its expiry date: sign-in stops working when it expires, so set a reminder to
create a new one before then.
4. *Enterprise applications → Backup Dashboard → Properties*: set **Assignment required** to
**Yes**. Then *Users and groups → Add user/group* and add the people (or a group) who should
see the dashboard. Everyone else in the tenant is refused by Microsoft before reaching it.
No API permissions beyond the default `User.Read` are needed.
**On the dashboard server:**
1. Install oauth2-proxy 7.8 or newer from its
[releases](https://github.com/oauth2-proxy/oauth2-proxy/releases) as `/usr/local/bin/oauth2-proxy`,
and create its user: `sudo useradd --system --no-create-home oauth2-proxy`.
2. Install the config and the client secret, readable only by root and oauth2-proxy:
```bash
sudo install -d -m 750 -g oauth2-proxy /etc/oauth2-proxy
sudo install -m 640 -g oauth2-proxy deploy/oauth2-proxy.cfg /etc/oauth2-proxy/backup-dashboard.cfg
sudo sh -c 'umask 027; printf %s "<client secret value>" > /etc/oauth2-proxy/client-secret' && sudo chgrp oauth2-proxy /etc/oauth2-proxy/client-secret
```
In `/etc/oauth2-proxy/backup-dashboard.cfg`, fill in `<TENANT_ID>`, `<APPLICATION_CLIENT_ID>`,
`<DASHBOARD_HOST>`, and `<COOKIE_SECRET>`, generated with:
`openssl rand -base64 32 | tr -- '+/' '-_'`
3. Install `deploy/oauth2-proxy.service` into `/etc/systemd/system/`, then
`sudo systemctl daemon-reload && sudo systemctl enable --now oauth2-proxy`.
Check it with `journalctl -u oauth2-proxy -n 20`: it should say "OAuthProxy configured for
Microsoft Entra ID".
4. Install the new `deploy/nginx.conf` (keeping your server name and certificate paths), then
`sudo nginx -t && sudo systemctl reload nginx`. Basic auth and its htpasswd file are no longer
used.
**Troubleshooting:** sign-in problems show in `journalctl -u oauth2-proxy`. Microsoft's own
errors (wrong redirect URI, user not assigned, expired secret) appear on its sign-in page with an
`AADSTS…` code.
## Development ## Development
@@ -96,7 +143,7 @@ BACKUP_DASH_SERVE_STATIC=1 uvicorn --factory app.main:create_app --reload
1. Copy the repo to `/opt/backup-dashboard`, create a venv at `/opt/backup-dashboard/venv`, and run `pip install -r backend/requirements.txt`. 1. Copy the repo to `/opt/backup-dashboard`, create a venv at `/opt/backup-dashboard/venv`, and run `pip install -r backend/requirements.txt`.
2. Copy `deploy/backup-dashboard.env` to `/etc/backup-dashboard/backup-dashboard.env`. Both the service and `manage.py` read their settings from it. 2. Copy `deploy/backup-dashboard.env` to `/etc/backup-dashboard/backup-dashboard.env`. Both the service and `manage.py` read their settings from it.
3. Create a `backupdash` system user, then install `deploy/backup-dashboard-api.service` into `/etc/systemd/system/` and enable it. The database is created on first start. 3. Create a `backupdash` system user, then install `deploy/backup-dashboard-api.service` into `/etc/systemd/system/` and enable it. The database is created on first start.
4. Copy `public/` to `/var/www/backup-dashboard/public`, install `deploy/nginx.conf`, get a certificate with certbot, and create users with `htpasswd -B`. 4. Copy `public/` to `/var/www/backup-dashboard/public`, install `deploy/nginx.conf`, get a certificate with certbot, and set up Microsoft 365 sign-in (see [Microsoft 365 sign-in setup](#microsoft-365-sign-in-setup)).
5. Run `manage.py` as the service user. It finds the database through the env file: 5. Run `manage.py` as the service user. It finds the database through the env file:
```bash ```bash
cd /opt/backup-dashboard/backend cd /opt/backup-dashboard/backend
+2 -2
View File
@@ -1,7 +1,7 @@
"""Collector (machine) authentication. """Collector (machine) authentication.
Dashboard *users* are authenticated by nginx in front of the app (basic auth today, Dashboard *users* are authenticated by nginx in front of the app (Microsoft 365 sign-in via
oauth2-proxy + Microsoft Entra ID later), so nothing here deals with people. oauth2-proxy), so nothing here deals with people.
""" """
import hashlib import hashlib
+10
View File
@@ -25,6 +25,16 @@ def health(conn: sqlite3.Connection = Depends(db.get_db)):
return {"status": "ok"} return {"status": "ok"}
@router.get("/me")
def me(request: Request):
# Set by nginx from oauth2-proxy's sign-in check (nginx overwrites anything the browser sends).
# Display only: access control happens in nginx. Empty in dev or without Microsoft sign-in.
return {
"email": request.headers.get("x-forwarded-email") or None,
"name": request.headers.get("x-forwarded-preferred-username") or request.headers.get("x-forwarded-user") or None,
}
@router.get("/jobs") @router.get("/jobs")
def list_jobs(conn: sqlite3.Connection = Depends(db.get_db)): def list_jobs(conn: sqlite3.Connection = Depends(db.get_db)):
return {"generated_at": to_db(utcnow()), "jobs": repo.list_jobs(conn)} return {"generated_at": to_db(utcnow()), "jobs": repo.list_jobs(conn)}
+6
View File
@@ -122,3 +122,9 @@ def test_tenant_name_is_case_insensitive(client):
body["tenant"] = "ACME CORP" body["tenant"] = "ACME CORP"
client.post("/api/v1/ingest", json=body) client.post("/api/v1/ingest", json=body)
assert len(client.get("/api/v1/jobs").json()["jobs"]) == 1 assert len(client.get("/api/v1/jobs").json()["jobs"]) == 1
def test_me_reports_signed_in_user_from_proxy_headers(client):
assert client.get("/api/v1/me").json() == {"email": None, "name": None}
r = client.get("/api/v1/me", headers={"X-Forwarded-Email": "[email protected]", "X-Forwarded-User": "abc-123"})
assert r.json() == {"email": "[email protected]", "name": "abc-123"}
+54 -8
View File
@@ -1,7 +1,7 @@
server { server {
listen 80; listen 80;
server_name backups.example.com; server_name backups.example.com;
# Basic auth sends the password with every request — never serve this over plain HTTP # Session cookies and API keys must never cross plain HTTP
return 301 https://$host$request_uri; return 301 https://$host$request_uri;
} }
@@ -23,26 +23,72 @@ server {
add_header Referrer-Policy "no-referrer" always; add_header Referrer-Policy "no-referrer" always;
add_header Strict-Transport-Security "max-age=31536000" always; add_header Strict-Transport-Security "max-age=31536000" always;
# Dashboard users. Create with: htpasswd -B -c /etc/nginx/backup-dashboard.htpasswd <user> # ---------- Microsoft 365 sign-in ----------
# To move to Microsoft 365 sign-in later, replace these two lines with an auth_request to # oauth2-proxy (deploy/oauth2-proxy.cfg) runs on 127.0.0.1:4180 and does the sign-in with
# oauth2-proxy (provider=entra-id); the app itself doesn't need to change. # Entra ID. Every request below asks it "is this browser signed in?" via auth_request.
auth_basic "Backup Monitor";
auth_basic_user_file /etc/nginx/backup-dashboard.htpasswd; # Its own pages: /oauth2/start (go to Microsoft), /oauth2/callback, /oauth2/sign_out
location /oauth2/ {
proxy_pass http://127.0.0.1:4180;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
# Where to return after signing in: the page that was asked for
proxy_set_header X-Auth-Request-Redirect $request_uri;
# Entra ID session cookies are large
proxy_buffer_size 16k;
proxy_buffers 8 16k;
}
# The sign-in check itself, only reachable as an auth_request subrequest
location = /oauth2/auth {
internal;
proxy_pass http://127.0.0.1:4180;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Uri $request_uri;
proxy_set_header Content-Length "";
proxy_pass_request_body off;
proxy_buffer_size 16k;
proxy_buffers 8 16k;
}
# ---------- Dashboard ----------
location / { location / {
auth_request /oauth2/auth;
# Not signed in: straight to Microsoft's sign-in, then back to this page
error_page 401 = /oauth2/start;
# Make browsers check for a newer page/script on every load (a cheap 304 when unchanged),
# so an update shows up at once. (expires, not add_header: an add_header here would drop
# the security headers above.)
expires -1;
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
location /api/ { location /api/ {
auth_request /oauth2/auth;
# No redirect here: API calls from the page get a plain 401, and the page asks the user to
# reload, which goes through sign-in above.
# Who's signed in, for the dashboard's "signed in as" (proxy_set_header replaces any value
# the browser sent, so these can't be spoofed)
auth_request_set $auth_email $upstream_http_x_auth_request_email;
auth_request_set $auth_user $upstream_http_x_auth_request_user;
auth_request_set $auth_name $upstream_http_x_auth_request_preferred_username;
proxy_set_header X-Forwarded-Email $auth_email;
proxy_set_header X-Forwarded-User $auth_user;
proxy_set_header X-Forwarded-Preferred-Username $auth_name;
proxy_pass http://127.0.0.1:8000; proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
# Collectors authenticate with their own API key (Authorization: Bearer …), checked by the app # Collectors authenticate with their own API key (Authorization: Bearer …), checked by the app,
# so no Microsoft sign-in here
location = /api/v1/ingest { location = /api/v1/ingest {
auth_basic off;
client_max_body_size 256k; client_max_body_size 256k;
proxy_pass http://127.0.0.1:8000; proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host; proxy_set_header Host $host;
+46
View File
@@ -0,0 +1,46 @@
# oauth2-proxy config for Backup Dashboard's Microsoft 365 (Entra ID) sign-in.
# Install as /etc/oauth2-proxy/backup-dashboard.cfg, readable only by root and the oauth2-proxy
# user (it holds the cookie secret). Needs oauth2-proxy 7.8 or newer for the entra-id provider.
# Replace the four <...> values; see the README's "Microsoft 365 sign-in" section.
## Where it listens: only nginx talks to it
http_address = "127.0.0.1:4180"
reverse_proxy = true
# Only nginx (on this machine) may supply X-Forwarded-* headers
trusted_proxy_ips = ["127.0.0.1/32", "::1/128"]
## Entra ID app registration (single tenant)
provider = "entra-id"
oidc_issuer_url = "https://login.microsoftonline.com/<TENANT_ID>/v2.0"
client_id = "<APPLICATION_CLIENT_ID>"
# The client secret's *value*, in a file of its own, root:oauth2-proxy, mode 640
client_secret_file = "/etc/oauth2-proxy/client-secret"
redirect_url = "https://<DASHBOARD_HOST>/oauth2/callback"
## Who may sign in
# Anyone in the tenant that Entra lets through. Limit it in Entra itself: Enterprise
# applications -> this app -> Properties -> "Assignment required" = Yes, then assign users or a
# group. Entra then refuses everyone else before they ever reach the dashboard.
email_domains = ["*"]
# The user's sign-in name (UPN), which every work account has. The "email" claim is only present
# when the account has a mailbox address.
oidc_email_claim = "preferred_username"
## Session cookie
# Generate with: openssl rand -base64 32 | tr -- '+/' '-_'
cookie_secret = "<COOKIE_SECRET>"
cookie_name = "_backup_dashboard"
cookie_secure = true
cookie_samesite = "lax"
# Sign in again (usually silently, via the Microsoft session) after this long
cookie_expire = "12h"
## Behaviour with nginx's auth_request
# Auth-only: nginx serves the dashboard; oauth2-proxy just answers "signed in or not"
upstreams = ["static://202"]
# Put the user in X-Auth-Request-* headers on /oauth2/auth responses, for nginx to pass on
set_xauthrequest = true
# Go straight to Microsoft instead of showing a "Sign in with..." page first
skip_provider_button = true
# Allow "Sign out" to continue to Microsoft's sign-out page
whitelist_domains = ["login.microsoftonline.com"]
+18
View File
@@ -0,0 +1,18 @@
[Unit]
Description=oauth2-proxy (Microsoft 365 sign-in for Backup Dashboard)
After=network-online.target
Wants=network-online.target
[Service]
User=oauth2-proxy
Group=oauth2-proxy
ExecStart=/usr/local/bin/oauth2-proxy --config=/etc/oauth2-proxy/backup-dashboard.cfg
Restart=on-failure
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
+6
View File
@@ -71,6 +71,12 @@ body {
} }
.topbar__title { margin: 0; font-size: 1.25rem; } .topbar__title { margin: 0; font-size: 1.25rem; }
.topbar__meta { display: flex; align-items: center; gap: 0.75rem; font-size: 0.875rem; } .topbar__meta { display: flex; align-items: center; gap: 0.75rem; font-size: 0.875rem; }
/* Only when shown: display:flex on the bare class would override the hidden attribute */
.topbar__user:not([hidden]) {
display: flex; align-items: center; gap: 0.75rem;
padding-left: 0.75rem; border-left: 1px solid var(--border);
}
a.btn { text-decoration: none; }
.container { .container {
max-width: 1400px; max-width: 1400px;
+5
View File
@@ -14,6 +14,11 @@
<div class="topbar__meta"> <div class="topbar__meta">
<span id="last-updated" class="muted" aria-live="polite">Loading…</span> <span id="last-updated" class="muted" aria-live="polite">Loading…</span>
<button id="refresh-btn" class="btn" type="button">Refresh</button> <button id="refresh-btn" class="btn" type="button">Refresh</button>
<!-- Filled in from /api/v1/me when signed in with Microsoft 365 -->
<span id="signed-in" class="topbar__user" hidden>
<span id="signed-in-email" class="muted"></span>
<a id="sign-out" class="btn" href="#">Sign out</a>
</span>
</div> </div>
</header> </header>
+4
View File
@@ -49,6 +49,10 @@ const httpApi = {
getRun(id) { getRun(id) {
return request(`/runs/${encodeURIComponent(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. // 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'), empty: $('#empty-state'),
search: $('#search'), search: $('#search'),
refreshBtn: $('#refresh-btn'), refreshBtn: $('#refresh-btn'),
signedIn: $('#signed-in'),
signedInEmail: $('#signed-in-email'),
signOut: $('#sign-out'),
lastUpdated: $('#last-updated'), lastUpdated: $('#last-updated'),
errorBanner: $('#error-banner'), errorBanner: $('#error-banner'),
dialog: $('#detail-dialog'), dialog: $('#detail-dialog'),
@@ -524,5 +527,20 @@ dom.search.addEventListener('input', () => {
dom.refreshBtn.addEventListener('click', () => refresh().then(schedulePoll)); 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 ---------- // ---------- Boot ----------
showSignedInUser();
refresh().then(schedulePoll); refresh().then(schedulePoll);
+4
View File
@@ -12,4 +12,8 @@ export const CONFIG = Object.freeze({
historyDays: 30, historyDays: 30,
// ?mock=1 → use in-browser sample data, no backend needed // ?mock=1 → use in-browser sample data, no backend needed
useMock: params.has('mock'), 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'); throw new Error('Run not found');
}, },
async getMe() {
return { email: null, name: null }; // no sign-in in mock mode
},
}; };