Add OIDC login and callback against Authentik
CI / ci (push) Successful in 1m27s
Independent review Codex gpt-6-sol high: CHANGES_REQUIRED (1 high, 2 medium)
CI / ci (pull_request) Successful in 1m22s

internal/auth discovers the provider at MEALQUEST_OIDC_ISSUER with
coreos/go-oidc/v3 and runs a confidential-client authorization code flow
with golang.org/x/oauth2 (client_secret_basic).

GET /auth/login stores state, nonce and a PKCE S256 verifier in a
10-minute HMAC-signed HttpOnly __Host- cookie and redirects with scopes
"openid profile email groups offline_access". An optional "return" path
must be same-origin; anything else fails the login with 400.

GET /auth/callback clears the single-use cookie, checks state, exchanges
the code with the verifier, verifies the ID token (RS256 only, issuer,
audience, expiry) and nonce, then hands the claims to a SessionStarter.
cmd/server wires a placeholder that refuses every login until the
session layer lands.

Config gains required MEALQUEST_OIDC_ISSUER, MEALQUEST_OIDC_CLIENT_ID,
MEALQUEST_OIDC_CLIENT_SECRET_FILE and MEALQUEST_OIDC_ALLOWED_GROUPS.

Pins go-oidc v3.21.0 and oauth2 v0.36.0, the newest releases that
support Go 1.25 (oauth2 v0.37.0 requires Go 1.26).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 09:36:57 -04:00
co-authored by Claude Opus 5.5
parent 19ea8d52d8
commit a10b24c86c
11 changed files with 1175 additions and 9 deletions
+12 -4
View File
@@ -16,9 +16,15 @@ import (
// SPA's index.html.
var reservedPrefixes = []string{"/api", "/auth", "/media", "/health"}
// RouteMounter registers its own routes on the router.
type RouteMounter interface {
Mount(r chi.Router)
}
// NewRouter returns the HTTP handler for the service. version is reported by
// GET /health; assets is the web build served for every other GET.
func NewRouter(logger *slog.Logger, version string, assets fs.FS) (http.Handler, error) {
// GET /health; auth mounts the /auth routes; assets is the web build served
// for every other GET.
func NewRouter(logger *slog.Logger, version string, auth RouteMounter, assets fs.FS) (http.Handler, error) {
notFound := apiErrorHandler(logger, http.StatusNotFound, "not_found", "not found")
methodNotAllowed := apiErrorHandler(logger, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
@@ -32,12 +38,14 @@ func NewRouter(logger *slog.Logger, version string, assets fs.FS) (http.Handler,
r.NotFound(notFound)
r.Get("/health", health(logger, version))
auth.Mount(r)
r.Route("/api", func(r chi.Router) {
r.MethodNotAllowed(methodNotAllowed)
r.NotFound(notFound)
})
// Catch-all for the PWA. /auth and /media have no routes yet; the frontend
// handler passes them to notFound rather than the SPA fallback.
// Catch-all for the PWA. Unknown /auth paths and /media (no routes yet)
// reach the frontend handler, which passes them to notFound rather than
// the SPA fallback.
r.Method(http.MethodGet, "/*", web)
r.Method(http.MethodHead, "/*", web)
return r, nil