diff --git a/STATUS.md b/STATUS.md index b22a4a8..e1bead2 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,5 +1,27 @@ # fancy-dns — development status and continuation plan +## Console authentication update + +OIDC login now starts automatically, with server-owned state and PKCE. Go +exchanges the authorization code, verifies the access token, and stores it in +an in-memory session addressed by a Secure, HttpOnly, SameSite=Lax cookie. +Cookie mutations require an exact configured console Origin. Sessions reverify +the access token and cannot outlive its signed expiry. Logout revokes the local +session and offers an explicit sign-in button rather than immediately logging +back in. API keys and bearer tokens remain available for machine clients. +`api.auth.oidc_redirect_uri` names the registered HTTPS callback; when omitted, +an HTTPS audience supplies the origin with `/auth/oidc/callback`. The current +AuthClient already registers that callback, so provider CORS changes are no +longer needed. Pending logins and sessions are process-local; a restart requires +sign-in, and multiple replicas need sticky routing (as existing basic sessions do). +No refresh token is requested; expiry starts another provider sign-in using SSO. + +Console cleanup removes the unused bootstrap refresh editor, hides Save when +configuration writes are unavailable, removes script instructions from login, +and fixes the shell's identity/read-only display by moving it inside its provider. +Validation: `make check` passes, including race tests; frontend lint and production +build pass with warnings. These changes have not been deployed. + Last updated after the round that finished the **server shutdown paths**: every transport's ordering with a query in flight is now a test, and it found two real bugs — an idle client could hold a shutdown for the whole grace diff --git a/deploy/compose/.env.example b/deploy/compose/.env.example new file mode 100644 index 0000000..a073b26 --- /dev/null +++ b/deploy/compose/.env.example @@ -0,0 +1,2 @@ +# Replace with a unique random secret before running. Keep the real .env private. +FANCYDNS_API_KEY=replace-with-a-long-random-value diff --git a/deploy/compose/README.md b/deploy/compose/README.md new file mode 100644 index 0000000..e579b6f --- /dev/null +++ b/deploy/compose/README.md @@ -0,0 +1,86 @@ +# Run fancy-dns outside Kubernetes + +This Compose deployment follows `deploy/k8s/00-config.yaml` and +`deploy/k8s/10-deployment.yaml`. It builds the image from this checkout instead +of pulling `registry.k6n.net/fancydns`, avoiding a bootstrap dependency on the +cluster registry DNS. + +## Prepare and start + +From the repository root: + +```sh +mkdir -p deploy/compose/{data,zones,rpz,tls} +cp deploy/compose/.env.example deploy/compose/.env +``` + +Edit `deploy/compose/.env` (keep it private) and set a random API key as +`FANCYDNS_API_KEY=`. Install the certificate and private key +for `dns.k6n.net` as `deploy/compose/tls/fullchain.pem` and +`deploy/compose/tls/privkey.pem`. Add the same zone files used by the cluster to +`deploy/compose/zones/`. Keep private key and `.env` out of source control. + +The container runs as UID/GID 65532 with a read-only root filesystem, so make +the persistent directory writable first, for example: + +```sh +sudo chown -R 65532:65532 deploy/compose/data +docker compose --env-file deploy/compose/.env -f deploy/compose/compose.yaml build +docker compose --env-file deploy/compose/.env -f deploy/compose/compose.yaml run --rm fancy-dns -config /etc/fancy-dns/config.yaml -check +docker compose --env-file deploy/compose/.env -f deploy/compose/compose.yaml up -d +``` + +The DNS ports publish on all host interfaces: UDP/TCP 53, TCP 853, and TCP 443. +The admin API is published only on `127.0.0.1:8080`; the embedded UI is disabled +by default for backup-resolver use. To enable it, set `api.ui: true` in +`config.yaml` and restart. Use an authenticated TLS reverse proxy for remote +access. Check that nothing already owns host port +53 before starting. The sample mounts TLS files and enables DoT and DoH, so the +files must exist before startup. + +## How configuration is shared + +The repository is the source of truth, but there is currently **no automatic +config distribution** between Kubernetes and the bare-metal Compose host. +Kubernetes GitOps reconciles `deploy/k8s` from `main` (see `gitops/README.md`); +the bare-metal host must get the updated checkout and have Compose restarted. +Kubernetes gets its config from the ConfigMap in `deploy/k8s/00-config.yaml`; +Compose reads the checked-in `deploy/compose/config.yaml` and files under +`deploy/compose/`. Treat these as two deployment configs: commit reviewed +changes to both when a shared resolver setting changes. The Compose config now +includes the same `local.` forward zone to `10.10.0.1:53` as Kubernetes. + +The shared inputs to keep aligned are upstreams/forward zones, resolver policy, +cache sizing/TTL, blocklist sources and actions, ACLs, local zone files, RPZ +rules, and the DoT/DoH certificate for the same public DNS name. Deployment +specific settings intentionally differ: listener container ports/storage paths +must match each deployment, and Compose uses API-key auth with the UI disabled +while Kubernetes uses OIDC. On bare metal the YAML, zone and RPZ files are +bind-mounted; Kubernetes stores config, zones and RPZ in ConfigMaps and TLS in +a Secret. Secrets should be provisioned separately on each system, not copied +into Git. The Compose API key is in its private `.env` file. + +Do not share the `data/` directory or copy its bbolt files while either server +is running. Cache snapshots and query logs are instance-local state. The +managed zones written by the console also live under this directory; edits +made on one instance do not appear on the other automatically. Make operator +zone changes in the source of truth and deploy them to both, or designate one +instance as the writer. Valkey clustering syncs cluster events/list leadership, +not these files or bbolt state. + +The private forwarding target must remain reachable from both the Kubernetes +pods and the bare-metal host. Verify that the bare-metal resolver has outbound +access to its configured public upstreams before making it the cluster's +registry resolver. + +For registry recovery, configure the Kubernetes nodes' OS resolver and the +container runtime's registry DNS path to use this host's **static IP directly** +as a DNS server (with another independent resolver as fallback). Do not rely on +a hostname for the backup resolver, and do not make pulling this Compose image +depend on `registry.k6n.net`: this Compose file builds the image locally from +the repository checkout. A working route from the cluster nodes to the +bare-metal host on UDP/TCP 53 is required; allow it through the host firewall. + +To update the local build after code changes, rebuild and recreate the +container. The running server can hot-reload its YAML on SIGHUP, but Compose +does not automatically send SIGHUP when bind-mounted files change. diff --git a/deploy/compose/compose.yaml b/deploy/compose/compose.yaml new file mode 100644 index 0000000..c987781 --- /dev/null +++ b/deploy/compose/compose.yaml @@ -0,0 +1,39 @@ +services: + fancy-dns: + # Build here so initial startup does not depend on the cluster registry + # whose name this resolver may be needed to resolve. + build: + context: ../.. + dockerfile: Dockerfile + image: fancy-dns:local + container_name: fancy-dns + restart: unless-stopped + read_only: true + cap_drop: [ALL] + security_opt: + - no-new-privileges:true + user: "65532:65532" + environment: + FANCYDNS_API_KEY: ${FANCYDNS_API_KEY:?Set FANCYDNS_API_KEY in deploy/compose/.env} + ports: + - "53:1053/udp" + - "53:1053/tcp" + - "853:1853/tcp" + - "443:8443/tcp" + # Keep the admin UI/API on the host loopback. Put it behind an + # authenticated TLS reverse proxy if remote access is needed. + - "127.0.0.1:8080:8080/tcp" + volumes: + - ./config.yaml:/etc/fancy-dns/config.yaml:ro + - ./zones:/etc/fancy-dns/zones:ro + - ./rpz:/etc/fancy-dns/rpz:ro + - ./tls:/etc/fancy-dns/tls:ro + - ./data:/var/lib/fancy-dns + tmpfs: + - /tmp:mode=1777,size=16m + healthcheck: + test: ["CMD", "/usr/local/bin/fancy-dns", "-version"] + interval: 30s + timeout: 3s + start_period: 10s + retries: 3 diff --git a/deploy/compose/config.yaml b/deploy/compose/config.yaml new file mode 100644 index 0000000..34f23e2 --- /dev/null +++ b/deploy/compose/config.yaml @@ -0,0 +1,114 @@ +# Bare-metal equivalent of deploy/k8s/00-config.yaml. +# Keep this file, zones/, rpz/, and TLS material in sync with the cluster's +# ConfigMaps/Secret as appropriate. The persistent state in ./data is local +# to this host and must not be copied/shared while the server is running. +server: + max_concurrent: 2048 + timeouts: + upstream: 2s + list_fetch: 60s + +dns: + # Container ports stay unprivileged; Compose publishes the conventional + # host ports 53, 853 and 443. + udp: ["0.0.0.0:1053"] + tcp: ["0.0.0.0:1053"] + max_udp_size: 1232 + dot: + enabled: true + listen: ["0.0.0.0:1853"] + cert_file: /etc/fancy-dns/tls/fullchain.pem + key_file: /etc/fancy-dns/tls/privkey.pem + doh: + enabled: true + listen: ["0.0.0.0:8443"] + cert_file: /etc/fancy-dns/tls/fullchain.pem + key_file: /etc/fancy-dns/tls/privkey.pem + base_path: /dns-query + +upstream: + forward_zones: + - zone: local + servers: + - addr: 10.10.0.1:53 + proto: udp + policy: parallel + max_attempts: 3 + healthcheck_interval: 30s + servers: + - addr: 9.9.9.9:53 + proto: udp + - addr: 1.1.1.1:853 + proto: tls + server_name: cloudflare-dns.com + - addr: 9.9.9.9:853 + proto: tls + server_name: dns.quad9.net + +memory: + limit: 1GiB + reserve: 256MiB + cache_max_bytes: 384MiB + +cache: + enabled: true + size: 500000 + max_ttl: 1h + serve_stale: 30m + persist: + enabled: true + path: /var/lib/fancy-dns/cache.db + interval: 30s + +blocklist: + enabled: true + default_action: nxdomain + managed_zones: /var/lib/fancy-dns/managed-zones + local_zones: ["/etc/fancy-dns/zones/*.zone"] + lists: + - name: edge-router-x-address-overrides + format: rpz + path: /etc/fancy-dns/rpz/address-overrides.rpz + enabled: true + refresh: 0s + - name: haas + format: plain + url: https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts + path: /var/lib/fancy-dns/lists/hosts + enabled: true + refresh: 24h + categories: [ads, tracking] + +acl: + default_policy: allow + groups: [] + +qlog: + enabled: true + path: /var/lib/fancy-dns/queries.db + max_entries: 200000 + sample_rate: 1.0 + log_blocked: true + log_cached: false + +api: + enabled: true + listen: ["0.0.0.0:8080"] + base_path: /api + # Backup resolver mode keeps the console disabled. Set true when an operator + # wants the embedded console; it remains reachable only via host loopback. + ui: false + allow_config_write: false + auth: + mode: apikey + api_keys: ["${FANCYDNS_API_KEY}"] + trust_loopback: true + +metrics: + enabled: true + path: /metrics + protect: true + +log: + level: info + format: json diff --git a/deploy/compose/rpz/address-overrides.rpz b/deploy/compose/rpz/address-overrides.rpz new file mode 100644 index 0000000..72b7267 --- /dev/null +++ b/deploy/compose/rpz/address-overrides.rpz @@ -0,0 +1,6 @@ +$TTL 60 +tornberg.me A 10.10.3.10 +knatofs.se A 10.10.3.10 +k6n.net A 10.10.3.10 +slask.local A 10.10.3.10 +iamu.dev A 10.10.3.10 diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index 58f1026..e013b68 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -207,10 +207,13 @@ api: read_only_api_keys: [] trust_loopback: true session_ttl: 12h - # For mode: oidc, configure a registered public PKCE client and the API - # audience included in its access tokens. Users need the admin role to write. + # For mode: oidc, register a public PKCE client with this callback. + # Go exchanges codes and keeps tokens in an HttpOnly cookie session; + # no token-endpoint CORS setting is needed. The audience must be included + # in access tokens. Users need the admin role to write. oidc_issuer: https://users.k6n.net oidc_audience: https://fancy-dns.example.net + oidc_redirect_uri: https://fancy-dns.example.net/auth/oidc/callback oidc_client_id: fancy-dns oidc_write_roles: [admin] rate_limit: diff --git a/deploy/k8s/00-config.yaml b/deploy/k8s/00-config.yaml index 9649e2d..2adabbe 100644 --- a/deploy/k8s/00-config.yaml +++ b/deploy/k8s/00-config.yaml @@ -119,6 +119,7 @@ data: api_keys: ["${FANCYDNS_API_KEY}"] oidc_issuer: https://users.k6n.net oidc_audience: https://dns.k6n.net + oidc_redirect_uri: https://dns.k6n.net/auth/oidc/callback oidc_client_id: fancy-dns oidc_write_roles: [admin] trust_loopback: false diff --git a/internal/api/api.go b/internal/api/api.go index f6c1928..2b701ee 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -151,8 +151,9 @@ type Server struct { } type session struct { - principal Principal - expires time.Time + principal Principal + expires time.Time + accessToken string } type limitState struct { @@ -220,6 +221,8 @@ func (s *Server) routes(cfg *config.Config) *http.ServeMux { }))) } mux.Handle("POST "+base+"auth/login", s.limited(s.handleLogin)) + mux.Handle("GET "+base+"auth/oidc/start", s.limited(s.handleOIDCStart)) + mux.Handle("POST "+base+"auth/oidc/callback", s.limited(s.handleOIDCCallback)) mux.Handle("GET "+base+"auth/config", s.public(s.handleAuthConfig)) mux.Handle("POST "+base+"auth/logout", s.authed(s.handleLogout, false)) @@ -390,6 +393,12 @@ func (s *Server) principal(r *http.Request) (Principal, error) { } return Principal{}, fmt.Errorf("unsupported auth scheme %q", scheme) } + if cookie, err := r.Cookie(browserSessionCookie); err == nil { + if !s.cookieRequestAllowed(r) { + return Principal{}, errors.New("cross-origin session request") + } + return s.auth.session(cookie.Value) + } if s.cfgTrustLoopback(r) { return Principal{Mode: "loopback", Name: "loopback", Loopback: true}, nil } diff --git a/internal/api/auth.go b/internal/api/auth.go index 7404580..c2804a4 100644 --- a/internal/api/auth.go +++ b/internal/api/auth.go @@ -41,28 +41,31 @@ type authenticator struct { // sessionTTL is how long an issued token stays valid. sessionTTL time.Duration // trustLoopback is informational; the caller checks it. - mu sync.RWMutex - sessions map[string]session - oidcIssuer string - oidcAudience string - oidcClientID string - oidcWriteRoles map[string]bool - oidcInitMu sync.Mutex - oidcVerifier *oidc.IDTokenVerifier + mu sync.RWMutex + sessions map[string]session + oidcPending map[string]oidcLogin + oidcRedirectURI string + oidcIssuer string + oidcAudience string + oidcClientID string + oidcWriteRoles map[string]bool + oidcInitMu sync.Mutex + oidcVerifier *oidc.IDTokenVerifier } func newAuthenticator(cfg config.Auth) *authenticator { a := &authenticator{ - mode: cfg.Mode, - readOnly: make(map[string]string, len(cfg.ReadOnlyAPIKeys)), - readWrite: make(map[string]string, len(cfg.APIKeys)), - username: cfg.Username, - sessionTTL: cfg.SessionTTL.D(), - sessions: make(map[string]session), - oidcIssuer: cfg.OIDCIssuer, - oidcAudience: cfg.OIDCAudience, - oidcClientID: cfg.OIDCClientID, - oidcWriteRoles: make(map[string]bool), + mode: cfg.Mode, + readOnly: make(map[string]string, len(cfg.ReadOnlyAPIKeys)), + readWrite: make(map[string]string, len(cfg.APIKeys)), + username: cfg.Username, + sessionTTL: cfg.SessionTTL.D(), + sessions: make(map[string]session), + oidcRedirectURI: cfg.OIDCRedirectURI, + oidcIssuer: cfg.OIDCIssuer, + oidcAudience: cfg.OIDCAudience, + oidcClientID: cfg.OIDCClientID, + oidcWriteRoles: make(map[string]bool), } for _, role := range cfg.OIDCWriteRoles { a.oidcWriteRoles[role] = true @@ -237,6 +240,9 @@ func (a *authenticator) session(token string) (Principal, error) { a.mu.Unlock() return Principal{}, errors.New("session expired") } + if s.accessToken != "" { + return a.oidcPrincipal(s.accessToken) + } return s.principal, nil } @@ -330,6 +336,10 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { // handleLogout drops the caller's session. func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + if c, err := r.Cookie(browserSessionCookie); err == nil { + s.auth.revoke(c.Value) + } + s.setBrowserCookie(w, browserSessionCookie, "", -1) if h := r.Header.Get("Authorization"); h != "" { if scheme, value, ok := cut(h, " "); ok && scheme == "Bearer" { s.auth.revoke(value) diff --git a/internal/api/oidc_login.go b/internal/api/oidc_login.go new file mode 100644 index 0000000..bb08dd3 --- /dev/null +++ b/internal/api/oidc_login.go @@ -0,0 +1,193 @@ +package api + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "encoding/json" + "errors" + "net/http" + "net/url" + "strings" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" +) + +const browserSessionCookie = "__Host-fancydns-session" +const loginCookie = "__Host-fancydns-login" + +type oidcLogin struct { + verifier string + next string + expires time.Time +} + +// The registered callback is configuration, never an untrusted Host header. +func (s *Server) browserRedirect() (*url.URL, error) { + raw := s.auth.oidcRedirectURI + if raw == "" { + u, err := url.Parse(s.auth.oidcAudience) + if err != nil || u.Scheme != "https" || u.Host == "" { + return nil, errors.New("configure api.auth.oidc_redirect_uri with the HTTPS console callback URL") + } + raw = u.Scheme + "://" + u.Host + "/auth/oidc/callback" + } + u, err := url.Parse(raw) + if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" { + return nil, errors.New("OIDC callback must be an absolute HTTPS URL without query or fragment") + } + return u, nil +} + +func (s *Server) cookieRequestAllowed(r *http.Request) bool { + if r.Method == http.MethodGet || r.Method == http.MethodHead { + return true + } + u, err := s.browserRedirect() + return err == nil && r.Header.Get("Origin") == u.Scheme+"://"+u.Host +} + +func (s *Server) setBrowserCookie(w http.ResponseWriter, name, value string, maxAge int) { + http.SetCookie(w, &http.Cookie{Name: name, Value: value, Path: "/", Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: maxAge}) +} + +func (s *Server) browserOAuth(ctx context.Context) (*oauth2.Config, error) { + if s.auth.mode != "oidc" { + return nil, errors.New("OIDC login is not enabled") + } + redirect, err := s.browserRedirect() + if err != nil { + return nil, err + } + provider, err := oidc.NewProvider(ctx, s.auth.oidcIssuer) + if err != nil { + return nil, err + } + endpoint := provider.Endpoint() + // Discovery is trusted only over TLS; never send codes to a downgraded endpoint. + for _, raw := range []string{endpoint.AuthURL, endpoint.TokenURL} { + u, err := url.Parse(raw) + if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil { + return nil, errors.New("OIDC endpoints must use HTTPS") + } + } + endpoint.AuthStyle = oauth2.AuthStyleInParams + return &oauth2.Config{ClientID: s.auth.oidcClientID, RedirectURL: redirect.String(), Endpoint: endpoint, Scopes: []string{"openid", "profile", "email"}}, nil +} + +func safeLoginNext(next string) string { + if !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") || strings.ContainsAny(next, "\\\r\n") || strings.HasPrefix(next, "/auth/") { + return "/" + } + return next +} + +func (s *Server) handleOIDCStart(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + cfg, err := s.browserOAuth(ctx) + if err != nil { + writeError(w, http.StatusServiceUnavailable, "could not start identity provider login") + return + } + stateBytes := make([]byte, 32) + if _, err := rand.Read(stateBytes); err != nil { + writeError(w, 500, "could not create login state") + return + } + state := base64.RawURLEncoding.EncodeToString(stateBytes) + verifier := oauth2.GenerateVerifier() + now := time.Now() + s.auth.mu.Lock() + if s.auth.oidcPending == nil { + s.auth.oidcPending = make(map[string]oidcLogin) + } + for key, login := range s.auth.oidcPending { + if now.After(login.expires) { + delete(s.auth.oidcPending, key) + } + } + if len(s.auth.oidcPending) >= 1024 { + s.auth.mu.Unlock() + writeError(w, 429, "too many pending logins") + return + } + s.auth.oidcPending[state] = oidcLogin{verifier: verifier, next: safeLoginNext(r.URL.Query().Get("next")), expires: now.Add(5 * time.Minute)} + s.auth.mu.Unlock() + s.setBrowserCookie(w, loginCookie, state, 300) + http.Redirect(w, r, cfg.AuthCodeURL(state, oauth2.S256ChallengeOption(verifier)), http.StatusFound) +} + +func (s *Server) handleOIDCCallback(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + if !s.cookieRequestAllowed(r) { + writeError(w, 403, "invalid login origin") + return + } + var in struct { + Code string `json:"code"` + State string `json:"state"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8192)).Decode(&in); err != nil || in.Code == "" || in.State == "" { + writeError(w, 400, "code and state are required") + return + } + cookie, err := r.Cookie(loginCookie) + if err != nil || subtle.ConstantTimeCompare([]byte(cookie.Value), []byte(in.State)) != 1 { + writeError(w, 400, "login state check failed; start sign in again") + return + } + s.auth.mu.Lock() + login, ok := s.auth.oidcPending[in.State] + delete(s.auth.oidcPending, in.State) + s.auth.mu.Unlock() + s.setBrowserCookie(w, loginCookie, "", -1) + if !ok || time.Now().After(login.expires) { + writeError(w, 400, "login expired; start sign in again") + return + } + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + cfg, err := s.browserOAuth(ctx) + if err != nil { + writeError(w, 502, "could not discover identity provider") + return + } + token, err := cfg.Exchange(ctx, in.Code, oauth2.VerifierOption(login.verifier)) + if err != nil { + writeError(w, 401, "identity provider rejected login; start sign in again") + return + } + p, err := s.auth.oidcPrincipal(token.AccessToken) + if err != nil { + writeError(w, 401, "identity provider returned an invalid access token") + return + } + // Bound the session by both the configured local TTL and the signed JWT expiry. + parts := strings.Split(token.AccessToken, ".") + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + var claims struct { + Exp int64 `json:"exp"` + } + if err != nil || json.Unmarshal(payload, &claims) != nil || claims.Exp <= time.Now().Unix() { + writeError(w, 401, "access token has expired") + return + } + local, expires := s.auth.issue(p) + if local == "" { + writeError(w, 500, "could not create session") + return + } + if jwtExpiry := time.Unix(claims.Exp, 0); jwtExpiry.Before(expires) { + expires = jwtExpiry + } + s.auth.mu.Lock() + s.auth.sessions[local] = session{principal: p, expires: expires, accessToken: token.AccessToken} + s.auth.mu.Unlock() + s.setBrowserCookie(w, browserSessionCookie, local, int(time.Until(expires).Seconds())) + writeJSON(w, 200, map[string]string{"next": login.next}) +} diff --git a/internal/api/oidc_login_test.go b/internal/api/oidc_login_test.go new file mode 100644 index 0000000..c3ef9a9 --- /dev/null +++ b/internal/api/oidc_login_test.go @@ -0,0 +1,149 @@ +package api + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "math/big" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "git.k8s-git-k6n.net/mats/fancy-dns/internal/config" +) + +func TestBrowserOIDCSession(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + var issuer, challenge string + exchanges := 0 + mux := http.NewServeMux() + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"issuer": issuer, "jwks_uri": issuer + "/keys", "authorization_endpoint": issuer + "/authorize", "token_endpoint": issuer + "/token"}) + }) + mux.HandleFunc("/keys", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{map[string]string{"kty": "RSA", "use": "sig", "alg": "RS256", "kid": "test-key", "n": base64.RawURLEncoding.EncodeToString(key.N.Bytes()), "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(key.E)).Bytes())}}}) + }) + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + exchanges++ + _ = r.ParseForm() + digest := sha256.Sum256([]byte(r.Form.Get("code_verifier"))) + if r.Form.Get("code") != "valid-code" || base64.RawURLEncoding.EncodeToString(digest[:]) != challenge || r.Form.Get("redirect_uri") != "https://dns.example/auth/oidc/callback" { + t.Error("incorrect code, PKCE verifier or redirect") + http.Error(w, "invalid grant", 400) + return + } + w.Header().Set("Content-Type", "application/json") + token := signedOIDCToken(t, key, map[string]any{"iss": issuer, "aud": "https://dns.example", "sub": "admin", "token_use": "access", "roles": []string{"admin"}, "exp": time.Now().Add(2 * time.Minute).Unix()}) + _ = json.NewEncoder(w).Encode(map[string]any{"access_token": token, "token_type": "Bearer", "expires_in": 120}) + }) + provider := httptest.NewTLSServer(mux) + defer provider.Close() + issuer = provider.URL + original := http.DefaultTransport + http.DefaultTransport = provider.Client().Transport + defer func() { http.DefaultTransport = original }() + h := newHarness(t, func(c *config.Config) { + c.API.Auth.Mode = "oidc" + c.API.Auth.OIDCIssuer = issuer + c.API.Auth.OIDCAudience = "https://dns.example" + c.API.Auth.OIDCClientID = "dns" + }) + start := httptest.NewRecorder() + h.srv.Handler().ServeHTTP(start, httptest.NewRequest("GET", "https://dns.example/api/auth/oidc/start?next=%2Fzones", nil)) + if start.Code != 302 { + t.Fatalf("start: %d %s", start.Code, start.Body.String()) + } + target, err := url.Parse(start.Header().Get("Location")) + if err != nil { + t.Fatal(err) + } + challenge = target.Query().Get("code_challenge") + if challenge == "" || target.Query().Get("code_challenge_method") != "S256" { + t.Fatal("missing PKCE") + } + binding := start.Result().Cookies()[0] + callback := func(origin, state string, cookie *http.Cookie) *httptest.ResponseRecorder { + b, _ := json.Marshal(map[string]string{"code": "valid-code", "state": state}) + r := httptest.NewRequest("POST", "https://dns.example/api/auth/oidc/callback", strings.NewReader(string(b))) + r.Header.Set("Origin", origin) + r.Header.Set("Content-Type", "application/json") + if cookie != nil { + r.AddCookie(cookie) + } + w := httptest.NewRecorder() + h.srv.Handler().ServeHTTP(w, r) + return w + } + state := target.Query().Get("state") + for _, tc := range []struct { + origin, state string + cookie *http.Cookie + }{{"https://evil.example", state, binding}, {"https://dns.example", "wrong", binding}, {"https://dns.example", state, nil}} { + if w := callback(tc.origin, tc.state, tc.cookie); w.Code == 200 { + t.Fatal("accepted unbound or cross-origin callback") + } + } + if exchanges != 0 { + t.Fatal("invalid callback reached token endpoint") + } + w := callback("https://dns.example", state, binding) + if w.Code != 200 { + t.Fatalf("callback: %d %s", w.Code, w.Body.String()) + } + if strings.Contains(w.Body.String(), "access_token") || !strings.Contains(w.Body.String(), "/zones") { + t.Fatal("callback leaked token or lost destination") + } + var sessionCookie *http.Cookie + for _, c := range w.Result().Cookies() { + if c.Name == browserSessionCookie { + sessionCookie = c + } + } + if sessionCookie == nil || !sessionCookie.Secure || !sessionCookie.HttpOnly || sessionCookie.SameSite != http.SameSiteLaxMode || sessionCookie.MaxAge > 120 { + t.Fatalf("unsafe session cookie: %+v", sessionCookie) + } + if w := callback("https://dns.example", state, binding); w.Code == 200 || exchanges != 1 { + t.Fatal("authorization code replay accepted") + } + request := func(method, path, origin string) *httptest.ResponseRecorder { + r := httptest.NewRequest(method, "https://dns.example"+path, nil) + r.AddCookie(sessionCookie) + if origin != "" { + r.Header.Set("Origin", origin) + } + w := httptest.NewRecorder() + h.srv.Handler().ServeHTTP(w, r) + return w + } + if w := request("GET", "/api/whoami", ""); w.Code != 200 { + t.Fatalf("session refused: %d %s", w.Code, w.Body.String()) + } + if w := request("POST", "/api/cache/flush", "https://evil.example"); w.Code == 200 { + t.Fatal("cross-origin cookie mutation accepted") + } + if w := request("POST", "/api/auth/logout", "https://dns.example"); w.Code != 200 { + t.Fatal("logout failed") + } + if w := request("GET", "/api/whoami", ""); w.Code != 401 { + t.Fatal("revoked session still works") + } +} + +func TestSafeLoginNext(t *testing.T) { + for _, next := range []string{"//evil.example", "/\\evil.example", "/auth/login", "/\r\nLocation: evil", "https://evil.example"} { + if safeLoginNext(next) != "/" { + t.Fatalf("unsafe redirect %q", next) + } + } + if safeLoginNext("/zones?zone=example.net") != "/zones?zone=example.net" { + t.Fatal("lost local destination") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 8462565..0312042 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -499,10 +499,11 @@ type Auth struct { // Mode is "none", "apikey", "basic" or "oidc". Mode string `yaml:"mode"` // OIDC settings validate access tokens issued by an external provider. - OIDCIssuer string `yaml:"oidc_issuer"` - OIDCAudience string `yaml:"oidc_audience"` - OIDCClientID string `yaml:"oidc_client_id"` - OIDCWriteRoles []string `yaml:"oidc_write_roles"` + OIDCIssuer string `yaml:"oidc_issuer"` + OIDCAudience string `yaml:"oidc_audience"` + OIDCRedirectURI string `yaml:"oidc_redirect_uri"` + OIDCClientID string `yaml:"oidc_client_id"` + OIDCWriteRoles []string `yaml:"oidc_write_roles"` // APIKeys grant full access. APIKeys []string `yaml:"api_keys"` // ReadOnlyAPIKeys may only call GET endpoints. @@ -1231,6 +1232,12 @@ func (c *Config) Validate() error { } } } + if raw := c.API.Auth.OIDCRedirectURI; raw != "" { + u, err := url.Parse(raw) + if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" { + add("api.auth.oidc_redirect_uri must be an absolute HTTPS callback URL without query or fragment") + } + } if c.API.Auth.SessionTTL <= 0 { add("api.auth.session_ttl must be > 0") } diff --git a/internal/web/dist/index.html b/internal/web/dist/index.html index 2cfa418..d0f4ce9 100644 --- a/internal/web/dist/index.html +++ b/internal/web/dist/index.html @@ -5,7 +5,7 @@ web - + diff --git a/web/src/pages/Login.tsx b/web/src/pages/Login.tsx index be90f9e..038befd 100644 --- a/web/src/pages/Login.tsx +++ b/web/src/pages/Login.tsx @@ -1,18 +1,11 @@ -/** - * Login — the only page that renders outside AppShell. - * - * The daemon may be running with `auth.mode: none`, `apikey` or `basic`, so the - * form is built from what `/api/whoami` reports rather than hard-coded. A 401 - * from that probe is expected (that is why the visitor is here) and is not - * shown as an error. - */ +/** Sign-in uses the node's public auth settings. OIDC tokens stay on the server. */ import { useEffect, useRef, useState } from 'react' import type { FormEvent } from 'react' -import { Globe, KeyRound, LogIn, ShieldCheck, Terminal } from 'lucide-react' +import { Globe, KeyRound, LogIn, ShieldCheck } from 'lucide-react' import { useSearchParams } from 'react-router-dom' -import { ApiError, login, setToken } from '../lib/api' +import { ApiError, login, clearToken } from '../lib/api' import { Button, Card, CardBody, Input } from '../components/ui' import { Spinner } from '../components/ui' @@ -31,7 +24,6 @@ export default function Login() { const callbackStarted = useRef(false) const [mode, setMode] = useState(null) - const [oidc, setOIDC] = useState<{ issuer: string; client_id: string } | null>(null) const [apiKey, setApiKey] = useState('') const [username, setUsername] = useState('') const [password, setPassword] = useState('') @@ -48,13 +40,13 @@ export default function Login() { .then((cfg) => { if (!live) return setMode(cfg.mode ?? 'apikey') - if (cfg.issuer && cfg.client_id) setOIDC({ issuer: cfg.issuer, client_id: cfg.client_id }) + if (cfg.mode === 'none') window.location.replace(safeNext(next)) }) - .catch(() => { if (live) setMode('apikey') }) + .catch(() => { if (live) setError('Could not load sign-in settings. Reload to try again.') }) return () => { live = false } - }, []) + }, [next]) useEffect(() => { const providerError = params.get('error') @@ -63,78 +55,37 @@ export default function Login() { return } const code = params.get('code') - if (!code || !oidc || mode !== 'oidc') return - if (callbackStarted.current) return + if (!code || mode !== 'oidc' || callbackStarted.current) return callbackStarted.current = true - let pendingLogin: { state: string; verifier: string; next: string } | null - try { - pendingLogin = JSON.parse(sessionStorage.getItem('fancydns.oidc.pending') ?? 'null') - } catch { pendingLogin = null } - if (!pendingLogin?.state || pendingLogin.state !== params.get('state') || !pendingLogin.verifier) { - setError('OIDC login state check failed. Start sign in again.') - return - } - sessionStorage.removeItem('fancydns.oidc.pending') setPending(true) - void (async () => { - try { - const discoveryRes = await fetch(`${oidc.issuer.replace(/\/$/, '')}/.well-known/openid-configuration`) - if (!discoveryRes.ok) throw new Error('could not discover the identity provider') - const discovery = (await discoveryRes.json()) as { token_endpoint?: string } - if (!discovery.token_endpoint) throw new Error('identity provider has no token endpoint') - const body = new URLSearchParams({ - grant_type: 'authorization_code', - client_id: oidc.client_id, - code, - redirect_uri: `${window.location.origin}/auth/oidc/callback`, - code_verifier: pendingLogin.verifier, - }) - const tokenRes = await fetch(discovery.token_endpoint, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body, - }) - const tokenBody = (await tokenRes.json()) as { access_token?: string; error_description?: string } - if (!tokenRes.ok || !tokenBody.access_token) throw new Error(tokenBody.error_description ?? 'code exchange failed') - setToken(tokenBody.access_token) - window.location.assign(safeNext(pendingLogin.next)) - } catch (err) { - setError(err instanceof Error ? err.message : 'OIDC sign in failed') - } finally { setPending(false) } - })() - }, [mode, oidc, params]) - - const startOIDC = async (): Promise => { - if (!oidc) return - setPending(true) - setError(null) - try { - const discoveryRes = await fetch(`${oidc.issuer.replace(/\/$/, '')}/.well-known/openid-configuration`) - if (!discoveryRes.ok) throw new Error('could not discover the identity provider') - const discovery = (await discoveryRes.json()) as { authorization_endpoint?: string } - if (!discovery.authorization_endpoint) throw new Error('identity provider has no authorization endpoint') - const random = (bytes: number): string => { - const data = crypto.getRandomValues(new Uint8Array(bytes)) - return btoa(String.fromCharCode(...data)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') - } - const verifier = random(32) - const challenge = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)) - const codeChallenge = btoa(String.fromCharCode(...new Uint8Array(challenge))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') - const state = random(24) - const redirectURI = `${window.location.origin}/auth/oidc/callback` - sessionStorage.setItem('fancydns.oidc.pending', JSON.stringify({ state, verifier, next })) - const authorize = new URL(discovery.authorization_endpoint) - authorize.search = new URLSearchParams({ - response_type: 'code', client_id: oidc.client_id, redirect_uri: redirectURI, - scope: 'openid profile email', state, code_challenge: codeChallenge, code_challenge_method: 'S256', - }).toString() - window.location.assign(authorize.toString()) - } catch (err) { - setError(err instanceof Error ? err.message : 'could not start OIDC sign in') + void fetch('/api/auth/oidc/callback', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code, state: params.get('state') }), + }).then(async (res) => { + const body = await res.json() as { next?: string; error?: string } + if (!res.ok) throw new Error(body.error ?? 'Sign in failed') + clearToken() + window.location.replace(safeNext(body.next ?? '/')) + }).catch((err: unknown) => { + window.history.replaceState(null, '', '/auth/login') + setError(err instanceof Error ? err.message : 'Sign in failed') setPending(false) - } + }) + }, [mode, params]) + + const startOIDC = (): void => { + setPending(true) + window.location.assign(`/api/auth/oidc/start?next=${encodeURIComponent(safeNext(next))}`) } + useEffect(() => { + if (mode === 'oidc' && !params.has('code') && !params.has('error') && !params.has('signed_out') && !callbackStarted.current) { + callbackStarted.current = true + window.location.replace(`/api/auth/oidc/start?next=${encodeURIComponent(safeNext(next))}`) + } + }, [mode, next, params]) + const onSubmit = async (event: FormEvent): Promise => { event.preventDefault() if (pending) return @@ -184,12 +135,12 @@ export default function Login() { ? 'Checking the authentication mode…' : passwordMode ? 'This node uses username and password.' - : mode === 'oidc' ? 'Sign in with your identity provider.' : 'This node uses an API key.' + : mode === 'oidc' ? 'Continue with your k6n account.' : 'This node uses an API key.' } >
void onSubmit(event)}> - {mode === 'oidc' ? null : passwordMode ? ( + {mode === null || mode === 'oidc' ? null : passwordMode ? ( <> - {pending ? 'Signing in…' : mode === 'oidc' ? 'Continue with OIDC' : 'Sign in'} + {pending ? 'Signing in…' : mode === 'oidc' ? 'Continue to sign in' : 'Sign in'}
-

-

- {pending && (