rework
build / build-push (push) Successful in 1m8s

This commit is contained in:
mats committed 2026-10-16 11:13:13 +02:00
1 parent 7fa48bcdf9
commit a3d1614dca
15 files changed
+704 -123

No files matched your search

+11 -2
View File
@@ -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
}
+28 -18
View File
@@ -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)
+193
View File
@@ -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})
}
+149
View File
@@ -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")
}
}