oidc: authenticate SPA users via Authentik
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled

- Add OIDC proxy endpoints (GET config, POST token) to API server
- Implement PKCE Authorization Code flow in SPA
- Enable Authentik login tab in Config page
- Handle callback + auto-refresh + session restore
- Add restart: unless-stopped to all persistent services
- Configure OIDC issuer + client_id in docker-compose
This commit is contained in:
2026-07-13 22:17:31 +02:00
parent 4c4afc4783
commit 7b0a0f01b5
7 changed files with 501 additions and 14 deletions

View File

@@ -12,6 +12,7 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log/slog"
"math/big"
"net/http"
@@ -127,6 +128,16 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config) http.Hand
}
})
// OIDC endpoints — unauthenticated. The SPA needs the issuer + client_id
// to build the authorization URL, and uses the token proxy to exchange
// authorization codes and refresh tokens without CORS issues.
r.Get("/api/v1/auth/oidc-config", func(w http.ResponseWriter, req *http.Request) {
s.serveOIDCConfig(w, req, cfg)
})
r.Post("/api/v1/auth/oidc-token", func(w http.ResponseWriter, req *http.Request) {
s.serveOIDCToken(w, req, cfg)
})
strict := gen.NewStrictHandlerWithOptions(s, nil, gen.StrictHTTPServerOptions{
RequestErrorHandlerFunc: func(w http.ResponseWriter, req *http.Request, err error) {
writeProblem(w, req, http.StatusBadRequest, "bad request", err.Error())
@@ -530,6 +541,122 @@ func requestLogger(next http.Handler) http.Handler {
})
}
// resolveOIDCEndpointURL derives an endpoint URL from the issuer by walking
// up one path segment. Authentik's issuer is per-provider
// (e.g. .../application/o/oikos/) but shared endpoints live at the parent
// path (.../application/o/<suffix>).
func resolveOIDCEndpointURL(issuer, suffix string) string {
u, err := url.Parse(issuer)
if err != nil {
return strings.TrimRight(issuer, "/") + suffix
}
u.Path = strings.TrimRight(u.Path, "/")
if idx := strings.LastIndex(u.Path, "/"); idx >= 0 {
u.Path = u.Path[:idx]
}
u.Path += suffix
return u.String()
}
// resolveOIDCTokenURL derives the token endpoint URL from the issuer.
func resolveOIDCTokenURL(issuer string) string {
return resolveOIDCEndpointURL(issuer, "/token/")
}
// serveOIDCConfig returns the OIDC issuer and client_id so the SPA can build
// authorization URLs without hardcoding them.
func (s *Server) serveOIDCConfig(w http.ResponseWriter, _ *http.Request, cfg config.Config) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"issuer": cfg.OIDCIssuer,
"client_id": cfg.OIDCClientID,
"authorization_endpoint": resolveOIDCEndpointURL(cfg.OIDCIssuer, "/authorize/"),
})
}
// tokenExchangeBody mirrors the JSON the SPA sends to the token proxy.
type tokenExchangeBody struct {
GrantType string `json:"grant_type"`
Code string `json:"code,omitempty"`
CodeVerifier string `json:"code_verifier,omitempty"`
RedirectURI string `json:"redirect_uri,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"`
}
// serveOIDCToken proxies authorization_code and refresh_token grants to the
// OIDC provider's token endpoint. The SPA can't POST directly to Authentik
// because of CORS; this proxy avoids the cross-origin problem entirely.
func (s *Server) serveOIDCToken(w http.ResponseWriter, req *http.Request, cfg config.Config) {
if cfg.OIDCIssuer == "" || cfg.OIDCClientID == "" {
writeProblem(w, req, http.StatusServiceUnavailable, "oidc not configured", "")
return
}
body, err := io.ReadAll(req.Body)
if err != nil {
writeProblem(w, req, http.StatusBadRequest, "invalid body", err.Error())
return
}
var tb tokenExchangeBody
if err := json.Unmarshal(body, &tb); err != nil {
writeProblem(w, req, http.StatusBadRequest, "invalid token request", err.Error())
return
}
// Build the form-encoded body for Authentik's token endpoint
form := url.Values{}
form.Set("client_id", cfg.OIDCClientID)
if cfg.OIDCClientSecret != "" {
form.Set("client_secret", cfg.OIDCClientSecret)
}
switch tb.GrantType {
case "authorization_code":
form.Set("grant_type", "authorization_code")
form.Set("code", tb.Code)
form.Set("code_verifier", tb.CodeVerifier)
form.Set("redirect_uri", tb.RedirectURI)
case "refresh_token":
form.Set("grant_type", "refresh_token")
form.Set("refresh_token", tb.RefreshToken)
default:
writeProblem(w, req, http.StatusBadRequest, "unsupported grant_type", tb.GrantType)
return
}
tokenURL := resolveOIDCTokenURL(cfg.OIDCIssuer)
client := &http.Client{Timeout: 15 * time.Second, Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: false},
}}
resp, err := client.Post(tokenURL, "application/x-www-form-urlencoded", strings.NewReader(form.Encode()))
if err != nil {
slog.Error("oidc token proxy failed", "error", err)
writeProblem(w, req, http.StatusBadGateway, "token endpoint unreachable", err.Error())
return
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
writeProblem(w, req, http.StatusInternalServerError, "read token response failed", err.Error())
return
}
if resp.StatusCode >= 400 {
slog.Warn("oidc token endpoint returned error", "status", resp.StatusCode, "body", string(respBody))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
w.Write(respBody)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Pragma", "no-cache")
w.Write(respBody)
}
// ListenAndServe runs the API server with graceful shutdown on ctx cancel
// (SG4): stop accepting, drain in-flight for up to 30s, then exit.
func ListenAndServe(ctx context.Context, pool *db.Pool, cfg config.Config) error {