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
}