Skip to content

feat: GitHub OAuth authentication for the dashboard - #153

Open
sebykrueger wants to merge 2 commits into
dmarcguardhq:mainfrom
sebykrueger:feat/dashboard-auth-github
Open

sebykrueger wants to merge 2 commits into
dmarcguardhq:mainfrom
sebykrueger:feat/dashboard-auth-github

Conversation

@sebykrueger

Copy link
Copy Markdown

Summary

Adds opt-in GitHub OAuth login for the dashboard. When the auth block is absent or auth.enabled=false, the dashboard runs unauthenticated as today (no breaking change). When enabled, /api/* and / require a signed session cookie obtained via GitHub OAuth; /metrics stays open so Prometheus scrapers keep working.

  • New internal/auth package: HMAC-SHA256 signed cookie sessions (no server-side store), GitHub OAuth client with /user + /user/emails calls to extract login + verified primary email, allowlist matching (case-insensitive emails OR usernames), and middleware that returns 303→/auth/login for browsers and 401 JSON for /api/* callers.
  • New /auth/login, /auth/callback, /auth/logged-out, /auth/logout handlers. Logout lands on a dedicated static page rather than redirecting back through GitHub — otherwise the user gets silently re-authenticated and Logout looks broken.
  • New /api/auth/me endpoint returns {login, email, logout_url} for the current session, used by the SPA.
  • Frontend: an Account section in the existing Settings modal showing the GitHub @username and email, plus a Logout button. Hidden when auth is disabled.
  • New --gen-session-secret CLI flag prints a 32-byte base64 secret.

Why GitHub OAuth (not OIDC)?

GitHub doesn't implement OIDC (no discovery doc, no ID tokens, separate /user/emails API call to get the verified email). Treating it as a first-class provider rather than forcing it through a generic OIDC abstraction kept the code simpler and removes the need for users to stand up a federating IDP just to log in with their GitHub account. A future PR can add a generic OIDC provider alongside if there's demand for Google/Microsoft/Authentik/Keycloak.

Config shape

"auth": {
  "enabled": true,
  "client_id": "Iv1.xxxx",
  "client_secret": "xxxx",
  "redirect_url": "https://dmarc.example.com/auth/callback",
  "session_secret": "<--gen-session-secret>",
  "allowed_users": ["sebykrueger"],
  "allowed_emails": ["seb@example.com"],
  "session_ttl_days": 7
}

ValidateAuth() refuses to start when auth.enabled=true and the allowlist is empty (otherwise nobody could log in). All fields also available as env vars (AUTH_ENABLED, AUTH_CLIENT_ID, AUTH_ALLOWED_USERS=a,b,c, etc.) for Docker/k8s secrets.

Security notes

  • Sessions are stateless HMAC-signed cookies (HttpOnly; SameSite=Lax; Secure flag inferred from https:// redirect URL). No server-side session store.
  • OAuth state is a separate signed token, validated against a short-lived state cookie scoped to /auth/callback for CSRF protection on the callback.
  • /metrics is intentionally not auth-gated so existing Prometheus scrapers don't break.
  • Removing someone from the allowlist doesn't kill their existing session (cookie keeps working until session_ttl_days). To force-evict immediately, rotate auth.session_secret. Documented as a known gotcha in the README.

Test plan

  • go test ./... — session sign/verify round-trip + tamper detection + expiry, state token round-trip + expiry, secret-too-short rejection, allowlist case-insensitive matching for emails and logins, empty allowlist denies all, middleware decision matrix (no cookie → 303 browser / 401 api, garbage cookie → same, valid cookie → pass through with populated context for /api/auth/me), full config validation matrix.
  • go vet ./... clean.
  • go build ./... clean for both pure-Go and CGO modes.
  • Manual end-to-end against a real GitHub OAuth App on http://localhost:8080: login flow, allowlist denial, logout flow with the dedicated landing page (verified you don't get silently re-authenticated through GitHub).
  • Verified /metrics stays open with auth enabled.
  • Verified existing deployments without an auth block continue to run unauthenticated (AuthConfig{Enabled: false} is the zero value).

Adds opt-in dashboard authentication via GitHub OAuth. When the auth block
is absent or auth.enabled is false, the dashboard runs unauthenticated as
before. When enabled, /api/* and / require a valid signed session cookie;
/metrics stays open for Prometheus scrapers.

Implementation
- internal/auth package (~470 LOC):
  - session.go: HMAC-SHA256 signed cookie format
    base64url(payload).base64url(hmac). Carries sub/email/iat/exp; no
    server-side store. State token uses the same key with 10-min TTL for
    CSRF protection on the OAuth callback.
  - github.go: authorization code flow against GitHub OAuth, then
    /user and /user/emails calls to extract login + verified primary
    email.
  - middleware.go: Allowlist (case-insensitive email and login matching)
    plus an HTTP middleware that returns 401 application/json for /api/*
    and 303 to /auth/login for everything else.
  - handlers.go: /auth/login, /auth/callback, /auth/logout. Secure
    cookie flag inferred from the redirect URL scheme.

- AuthConfig in internal/config/config.go with env-var support
  (AUTH_ENABLED, AUTH_CLIENT_ID, AUTH_CLIENT_SECRET, AUTH_REDIRECT_URL,
  AUTH_SESSION_SECRET, AUTH_ALLOWED_USERS, AUTH_ALLOWED_EMAILS,
  AUTH_SESSION_TTL_DAYS). ValidateAuth() refuses to start when auth is
  enabled but the allowlist is empty.

- internal/api/server.go gains WithAuth(handlers, signer). Internal split
  into protectedMux (gated routes: /api/*, /) and the outer mux that
  carries /metrics and /auth/*.

- main.go --gen-session-secret flag prints a base64-encoded 32-byte
  random secret suitable for AuthConfig.SessionSecret.

Tests
- Session sign/verify round-trip, tamper detection, expiry enforcement,
  state-token round-trip and expiry, secret-too-short rejection.
- Allowlist case-insensitive matching for emails and logins, empty
  allowlist denies all.
- Middleware decision matrix: missing cookie → 303 (browser) or 401 JSON
  (api), invalid cookie → same, valid cookie → pass through.
- Config validation matrix: every required field, allowlist non-empty
  invariant, user-only allowlist accepted.

Smoke test verified locally: --gen-session-secret produces 44-char
secret, /api/statistics returns 401 JSON, / redirects to /auth/login,
/auth/login redirects to github.com with signed state, /metrics stays
open, garbage cookies are rejected, state cookie is HttpOnly + SameSite=Lax.
…out page

Backend
- New /api/auth/me endpoint returns the current user as
  {login, email, logout_url}. Behind the same auth middleware so
  unauthenticated requests get the standard 401 JSON.
- Auth middleware now stores an *Identity (constructed from the
  verified session) in the request context. UserFromContext() retrieves
  it for handlers like /api/auth/me.
- Logout no longer redirects to /auth/login. That path immediately
  bounced the user through GitHub and (because GitHub remembers prior
  consent) silently re-authenticated them — making Logout look broken.
  Logout now lands on a dedicated /auth/logged-out page with an
  explicit "Sign in again" button, so the user has to actively re-auth.

Frontend
- src/lib/api.js: getCurrentUser() helper that calls /api/auth/me and
  returns null on 401 so callers can render gracefully when auth is
  disabled or the session expired.
- src/components/settings/SettingsModal.vue: an "Account" section in
  the settings modal showing the GitHub @username and email, plus a
  Logout button. Section is hidden when getCurrentUser returns null.

Tests
- Regression: TestMiddleware_PopulatesUserContext asserts that a valid
  session causes UserFromContext to return a usable Identity. The
  earlier "valid session passes through" test only checked that the
  next handler was invoked, which silently masked a bug where
  *Session was being stored in the context but UserFromContext
  type-asserted to *Identity (always returning nil and breaking
  /api/auth/me).
@meysam81

Copy link
Copy Markdown
Collaborator

auth is not the focus of this repo

the self-hosted setup in parse-dmarc allows team to quickly spin up the server without hassle

nginx and other proxies handle auth properly and quite better

@meysam81 meysam81 added the wontfix This will not be worked on label Sep 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

wontfix This will not be worked on

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants