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

+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})
}