Add single-user GitHub OAuth, hardened for internet exposure

muse drives a download client and writes to the media library, so exposing it
publicly needs more than a login form.

It fails closed. Auth is on unless MUSE_AUTH=off is set explicitly, and when
it is on with incomplete config the app refuses to start rather than serving
open. The mode worth engineering against is not a broken login page, it is a
misconfigured deploy quietly publishing an unauthenticated download client.

Sessions are server-side: the cookie carries a 256-bit random token and
nothing else, and only its SHA-256 is stored. There is no signed payload to
get subtly wrong, a stolen database yields no usable cookies, and logout
revokes for real. The allowlist is re-checked on every request so removing a
user takes effect immediately rather than at session expiry.

OAuth state is compared with secrets.compare_digest, so a callback the user
did not initiate is rejected. MUSE_BASE_URL comes from config rather than
X-Forwarded-* because those headers are attacker-controllable and this one is
a redirect target.

CSP is default-src 'self' with no unsafe-inline, which is why the stylesheet
and script moved out of index.html into static files -- an injected <script>
now cannot execute. Also frame-ancestors 'none', base-uri 'none', nosniff,
DENY, no-referrer and HSTS. FastAPI's /docs, /redoc and /openapi.json are
disabled so the login page does not hand out a map of every endpoint.

/healthz is the only unauthenticated route and returns a bare ok, so the
container healthcheck works without leaking anything.

Verified: misconfigured auth refuses to boot; unauthenticated / redirects and
/api/* returns 401; a forged state is rejected 400; a junk cookie is 401; a
valid session for a non-allowlisted user is 401; all headers present; and the
UI renders with no CSP violations in the console.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
aryansinghandClaude Opus 5 committed 2026-08-04 12:09:51 -05:00
1 parent 10ccc282d4
commit bc46fd80b3
6 files changed
+486 -187

No files matched your search

+53
View File
@@ -105,6 +105,59 @@ can actually serve you (free upload slot, short queue), then filename
plausibility, then bitrate. A marginally better file behind 200 queued users
loses to a good-enough one available now.
## Authentication
Single-user GitHub OAuth, built so this can face the internet.
**It fails closed.** Auth is on unless `MUSE_AUTH=off` is set explicitly, and
if it is on with incomplete config the app *refuses to start*. The failure mode
worth engineering against isn't a broken login page — it's a misconfigured
deploy quietly serving an unauthenticated download client to the internet.
### Setting it up
1. <https://github.com/settings/developers> → **New OAuth App**
- Homepage URL: `https://muse.yourdomain.com`
- Authorization callback URL: `https://muse.yourdomain.com/auth/callback`
2. Generate a client secret.
3. Set `MUSE_GITHUB_CLIENT_ID`, `MUSE_GITHUB_CLIENT_SECRET`,
`MUSE_ALLOWED_USERS` (your GitHub login), and `MUSE_BASE_URL`.
| variable | default | meaning |
|---|---|---|
| `MUSE_AUTH` | `github` | `github` or `off` |
| `MUSE_GITHUB_CLIENT_ID` | — | OAuth app client id |
| `MUSE_GITHUB_CLIENT_SECRET` | — | OAuth app secret |
| `MUSE_ALLOWED_USERS` | — | comma-separated GitHub logins; **nobody else gets in** |
| `MUSE_BASE_URL` | — | external URL, e.g. `https://muse.yourdomain.com` |
| `MUSE_COOKIE_SECURE` | `true` | set `false` only for plain-http LAN testing |
| `MUSE_SESSION_DAYS` | `7` | session lifetime |
### What it does
- **Server-side sessions.** The cookie carries a 256-bit random token and
nothing else; the database stores only its SHA-256. No signed payload to get
wrong, a stolen database yields no usable cookies, and logout genuinely
revokes. Cookie is `HttpOnly`, `Secure`, `SameSite=Lax`.
- **Allowlist re-checked every request**, so removing someone locks them out
immediately rather than whenever their session expires.
- **OAuth `state`** is generated per login and compared with
`secrets.compare_digest`, so a callback you didn't initiate is rejected.
- **Strict CSP** — `default-src 'self'`, no `unsafe-inline`. This is why the
stylesheet and script are separate files instead of inline in the page: an
injected `<script>` cannot execute. Plus `frame-ancestors 'none'`,
`base-uri 'none'`, `nosniff`, `X-Frame-Options: DENY`, `no-referrer`, HSTS.
- **No API docs** — `/docs`, `/redoc` and `/openapi.json` are disabled, so the
login page doesn't hand out a map of every endpoint.
- **Denied logins are logged** with the GitHub username — on an
internet-facing box that's your signal someone found the page.
- `/healthz` is the only unauthenticated route and returns a bare `{"ok":true}`
— no versions, no config, no counts.
Behind a reverse proxy, `MUSE_BASE_URL` is read from config rather than from
`X-Forwarded-*` headers on purpose: those are attacker-controllable and this
one is a redirect target.
## Running it
```yaml