a73x

internal/server/api/auth.go

Ref:   Size: 10.7 KiB   History

package api

import (
	"context"
	"crypto/rand"
	"encoding/hex"
	"errors"
	"log/slog"
	"net/http"
	"strings"
	"sync"
	"time"

	oidc "github.com/coreos/go-oidc/v3/oidc"
	"golang.org/x/oauth2"

	"github.com/a73x/eitri/internal/server/store"
)

// OIDCConfig mirrors config.OIDC into the api package so this package need not
// import internal/server/config — matching how AdminToken and the advertise
// addresses are passed as plain Config fields. main.go copies the config.OIDC
// block into this shape.
type OIDCConfig struct {
	Issuer            string
	ClientID          string
	ClientSecret      string // external confidential clients only; empty for a PKCE public client
	PublicURL         string
	AllowedDomains    []string // optional signup gate (case-insensitive suffix after '@')
	AllowedIdentities []string // optional signup gate (case-insensitive full email)
}

// Cookie names and lifetimes for the sign-in flow.
const (
	sessionCookie  = "eitri_session"
	stateCookie    = "eitri_oauth_state"
	verifierCookie = "eitri_oauth_verifier"
	oauthTempTTL   = 10 * time.Minute
	sessionTTL     = 30 * 24 * time.Hour
)

// errSignupNotAllowed is the sentinel resolveTenant returns when the signup gate
// turns an identity away — the handler maps it to a 403 with no tenant created.
var errSignupNotAllowed = errors.New("signup not allowed")

// authFlow owns /auth/login, /auth/callback, /auth/logout: the OIDC relying-party
// sign-in. These are browser redirect endpoints, deliberately OUTSIDE the JSON
// route table (spec §3) and NOT behind the API auth middleware — they are how a
// session is established in the first place.
//
// eitri-server is a pure relying party: it holds no passwords and no identity-
// signing key. The issuer is sometimes the bundled eitri-oidc next door and
// sometimes an external IdP; go-oidc treats them identically.
type authFlow struct {
	st  *store.Store
	cfg OIDCConfig

	// OIDC discovery is LAZY: the provider is fetched on the first request and
	// cached. This lets eitri-server start before eitri-oidc — the systemd
	// ordering does not force the issuer to be reachable at boot. Config *shape*
	// is validated at boot (cmd/eitri-server); issuer *reachability* is fail-soft
	// here, so a discovery failure re-tries on the next request rather than
	// wedging a started server.
	mu       sync.Mutex
	provider *oidc.Provider
	verifier *oidc.IDTokenVerifier
	oauth    oauth2.Config
}

// ensureProvider performs OIDC discovery against cfg.Issuer once and caches the
// verifier + oauth2 config. Safe under concurrent callers; on failure it leaves
// the fields nil so a later request retries (the issuer may not be up yet).
func (af *authFlow) ensureProvider(ctx context.Context) error {
	af.mu.Lock()
	defer af.mu.Unlock()
	if af.provider != nil {
		return nil
	}
	if af.cfg.Issuer == "" {
		return errors.New("oidc issuer not configured")
	}
	prov, err := oidc.NewProvider(ctx, af.cfg.Issuer)
	if err != nil {
		return err
	}
	af.provider = prov
	af.verifier = prov.Verifier(&oidc.Config{ClientID: af.cfg.ClientID})
	endpoint := prov.Endpoint()
	if af.cfg.ClientSecret == "" {
		// Public PKCE client: there is no client authentication, so the token
		// request must carry client_id in the body. Pin AuthStyleInParams to stop
		// oauth2's autodetect from first probing HTTP Basic — the bundled
		// eitri-oidc answers that probe by consuming the single-use code before
		// rejecting the empty secret, so the autodetect retry would then fail with
		// "invalid code". A confidential client (secret set) leaves autodetect on,
		// since external IdPs vary in the client-auth style they accept.
		endpoint.AuthStyle = oauth2.AuthStyleInParams
	}
	af.oauth = oauth2.Config{
		ClientID:     af.cfg.ClientID,
		ClientSecret: af.cfg.ClientSecret,
		Endpoint:     endpoint,
		RedirectURL:  strings.TrimRight(af.cfg.PublicURL, "/") + "/auth/callback",
		Scopes:       []string{oidc.ScopeOpenID, "email"},
	}
	return nil
}

// secure reports whether cookies should carry the Secure flag: any real
// deployment sets an https public_url; plain http is tolerated only for the
// single-box quickstart, where the browser and server share localhost.
func (af *authFlow) secure() bool { return strings.HasPrefix(af.cfg.PublicURL, "https://") }

func (af *authFlow) handleLogin(w http.ResponseWriter, r *http.Request) {
	if err := af.ensureProvider(r.Context()); err != nil {
		slog.Warn("oidc discovery failed", "err", err)
		http.Error(w, "sign-in temporarily unavailable", http.StatusServiceUnavailable)
		return
	}
	state, err := newState(requestOrigin(r))
	if err != nil {
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	verifier := oauth2.GenerateVerifier()
	af.setTempCookie(w, stateCookie, state)
	af.setTempCookie(w, verifierCookie, verifier)
	http.Redirect(w, r, af.oauth.AuthCodeURL(state, oauth2.S256ChallengeOption(verifier)), http.StatusFound)
}

func (af *authFlow) handleCallback(w http.ResponseWriter, r *http.Request) {
	if err := af.ensureProvider(r.Context()); err != nil {
		slog.Warn("oidc discovery failed", "err", err)
		http.Error(w, "sign-in temporarily unavailable", http.StatusServiceUnavailable)
		return
	}
	// Opportunistic session-table hygiene: a cheap DELETE on the sign-in path
	// keeps the table bounded without a background goroutine (expiry is already
	// enforced on read, so this is pure hygiene).
	if _, err := af.st.ReapSessions(); err != nil {
		slog.Warn("reap sessions failed", "err", err)
	}

	// State must round-trip through the cookie set at /auth/login (CSRF guard):
	// a missing or mismatched value is rejected before any token exchange. The
	// guard is unchanged; what it rejects now says which way it failed.
	pkceVerifier, rej := af.diagnoseCallback(r)
	if rej != nil {
		rej.log()
		http.Error(w, rej.message(), http.StatusBadRequest)
		return
	}

	tok, err := af.oauth.Exchange(r.Context(), r.URL.Query().Get("code"), oauth2.VerifierOption(pkceVerifier))
	if err != nil {
		slog.Warn("oauth code exchange failed", "err", err)
		http.Error(w, "sign-in failed", http.StatusBadGateway)
		return
	}
	rawID, ok := tok.Extra("id_token").(string)
	if !ok || rawID == "" {
		http.Error(w, "identity provider returned no id_token", http.StatusBadGateway)
		return
	}
	idToken, err := af.verifier.Verify(r.Context(), rawID)
	if err != nil {
		slog.Warn("id_token verification failed", "err", err)
		http.Error(w, "sign-in failed", http.StatusForbidden)
		return
	}
	var claims struct {
		Email         string `json:"email"`
		EmailVerified bool   `json:"email_verified"`
	}
	if err := idToken.Claims(&claims); err != nil {
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	if claims.Email == "" {
		// The spec's claims contract requires an email; an issuer that omits it
		// cannot be used for sign-in (identity display + allowlist matching).
		http.Error(w, "identity provider returned no email", http.StatusForbidden)
		return
	}
	if !claims.EmailVerified {
		// The claims contract requires a VERIFIED email: the address is the
		// identity key for JIT tenants and the allowlist match, so an issuer
		// that hasn't verified it (claim false or absent) cannot vouch for it.
		http.Error(w, "identity provider has not verified this email", http.StatusForbidden)
		return
	}

	tenant, err := af.resolveTenant(idToken.Issuer, idToken.Subject, claims.Email)
	if err != nil {
		if errors.Is(err, errSignupNotAllowed) {
			http.Error(w, "not authorized for this server", http.StatusForbidden)
			return
		}
		slog.Error("resolve tenant failed", "err", err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}

	sid, err := af.st.CreateSession(tenant, sessionTTL)
	if err != nil {
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	http.SetCookie(w, &http.Cookie{
		Name:     sessionCookie,
		Value:    sid,
		Path:     "/",
		HttpOnly: true,
		SameSite: http.SameSiteLaxMode,
		Secure:   af.secure(),
		MaxAge:   int(sessionTTL.Seconds()),
	})
	af.clearCookie(w, stateCookie)
	af.clearCookie(w, verifierCookie)
	http.Redirect(w, r, "/", http.StatusFound)
}

// resolveTenant maps a verified (issuer, subject, email) to a tenant id, in the
// exact order the spec mandates.
func (af *authFlow) resolveTenant(issuer, subject, email string) (string, error) {
	// 1. An existing binding wins — a returning user — and bypasses the signup
	//    gate (the identity was admitted once already).
	if tn, ok, err := af.st.TenantByIdentity(issuer, subject); err != nil {
		return "", err
	} else if ok {
		return tn.ID, nil
	}
	// 2. Signup gate: when either allowlist is non-empty the identity must match
	//    one of them, or it is turned away with NO tenant created.
	if !af.signupAllowed(email) {
		return "", errSignupNotAllowed
	}
	// 3. JIT: a new identity gets a fresh tenant with a handle derived from email.
	tn, err := af.st.CreateTenantForIdentity(issuer, subject, email)
	if err != nil {
		return "", err
	}
	return tn.ID, nil
}

// signupAllowed applies the optional signup gate. Both lists empty ⇒ open
// signup. An exact identity match (case-insensitive full email) or a domain
// match (case-insensitive, the part after '@') admits the identity.
func (af *authFlow) signupAllowed(email string) bool {
	if len(af.cfg.AllowedDomains) == 0 && len(af.cfg.AllowedIdentities) == 0 {
		return true
	}
	for _, id := range af.cfg.AllowedIdentities {
		if strings.EqualFold(email, id) {
			return true
		}
	}
	domain := ""
	if i := strings.LastIndexByte(email, '@'); i >= 0 {
		domain = email[i+1:]
	}
	for _, d := range af.cfg.AllowedDomains {
		if domain != "" && strings.EqualFold(domain, d) {
			return true
		}
	}
	return false
}

// handleLogout deletes the session row and clears the cookie. The SPA calls this
// via fetch and handles navigation itself, so a bodyless 204 is cleaner than
// forcing a redirect on a fetch caller.
func (af *authFlow) handleLogout(w http.ResponseWriter, r *http.Request) {
	if ck, err := r.Cookie(sessionCookie); err == nil && ck.Value != "" {
		if err := af.st.DeleteSession(ck.Value); err != nil {
			slog.Warn("delete session failed", "err", err)
		}
	}
	af.clearCookie(w, sessionCookie)
	w.WriteHeader(http.StatusNoContent)
}

func (af *authFlow) setTempCookie(w http.ResponseWriter, name, value string) {
	http.SetCookie(w, &http.Cookie{
		Name:     name,
		Value:    value,
		Path:     "/",
		HttpOnly: true,
		SameSite: http.SameSiteLaxMode,
		Secure:   af.secure(),
		MaxAge:   int(oauthTempTTL.Seconds()),
	})
}

func (af *authFlow) clearCookie(w http.ResponseWriter, name string) {
	http.SetCookie(w, &http.Cookie{
		Name:     name,
		Value:    "",
		Path:     "/",
		HttpOnly: true,
		SameSite: http.SameSiteLaxMode,
		Secure:   af.secure(),
		MaxAge:   -1,
	})
}

func randomHex(n int) (string, error) {
	b := make([]byte, n)
	if _, err := rand.Read(b); err != nil {
		return "", err
	}
	return hex.EncodeToString(b), nil
}