a73x

internal/oidcprovider/provider.go

Ref:   Size: 10.8 KiB   History

package oidcprovider

import (
	"crypto/rand"
	"crypto/rsa"
	"crypto/sha256"
	"crypto/subtle"
	"crypto/x509"
	_ "embed"
	"encoding/base64"
	"encoding/json"
	"encoding/pem"
	"errors"
	"html/template"
	"net/http"
	"net/url"
	"os"
	"sync"
	"time"
)

//go:embed login.html
var loginHTML string

var loginTmpl = template.Must(template.New("login").Parse(loginHTML))

var errBadKey = errors.New("oidcprovider: malformed signing key PEM")

// Config is the provider's static wiring: where users and the signing key live,
// and the set of statically-registered clients (no dynamic registration).
type Config struct {
	UsersFile  string
	SigningKey string   // path; RSA-2048 PEM, generated if absent (0600)
	Clients    []Client // static registration
}

// Client is one registered relying party: its id and exact redirect URL. The
// JSON tags are the eitri-oidc config wire contract (spec §2.1: {"id",
// "redirect_url"}); without them the snake_case redirect_url would silently
// decode to empty and every login would 400 on the exact-match client check.
type Client struct {
	ID          string `json:"id"`
	RedirectURL string `json:"redirect_url"`
}

// authCode is a minted, single-use authorization code held in memory.
type authCode struct {
	sub         string
	email       string
	clientID    string
	redirectURL string
	challenge   string // PKCE S256 code_challenge
	expires     time.Time
}

// Provider is a minimal OIDC issuer. Zero refresh tokens, zero userinfo.
type Provider struct {
	cfg    Config
	key    *rsa.PrivateKey
	kid    string // stable hash of the public key
	issuer string

	now func() time.Time // injectable clock (tests)

	mu    sync.Mutex
	codes map[string]authCode
}

// New loads or generates the RSA-2048 signing key (PEM at cfg.SigningKey, 0600)
// and returns a provider. SetIssuer must be called before Handler serves — the
// issuer must equal the URL clients reach it at for discovery to verify.
func New(cfg Config) (*Provider, error) {
	key, err := loadOrGenerateKey(cfg.SigningKey)
	if err != nil {
		return nil, err
	}
	return &Provider{
		cfg:   cfg,
		key:   key,
		kid:   keyID(&key.PublicKey),
		now:   time.Now,
		codes: make(map[string]authCode),
	}, nil
}

// SetIssuer sets the canonical issuer URL advertised in discovery and stamped
// into id_tokens. It must equal the URL browsers and eitri-server reach.
// Not synchronized: call once, before the handler starts serving.
func (p *Provider) SetIssuer(u string) { p.issuer = u }

// Handler returns the mux with the four endpoints at its root; the binary
// decides where to mount it.
func (p *Provider) Handler() http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("/.well-known/openid-configuration", p.handleDiscovery)
	mux.HandleFunc("/authorize", p.handleAuthorize)
	mux.HandleFunc("/token", p.handleToken)
	mux.HandleFunc("/jwks.json", p.handleJWKS)
	return mux
}

func (p *Provider) handleDiscovery(w http.ResponseWriter, r *http.Request) {
	writeJSON(w, map[string]any{
		"issuer":                                p.issuer,
		"authorization_endpoint":                p.issuer + "/authorize",
		"token_endpoint":                        p.issuer + "/token",
		"jwks_uri":                              p.issuer + "/jwks.json",
		"response_types_supported":              []string{"code"},
		"grant_types_supported":                 []string{"authorization_code"},
		"code_challenge_methods_supported":      []string{"S256"},
		"id_token_signing_alg_values_supported": []string{"RS256"},
		"scopes_supported":                      []string{"openid", "email"},
		"subject_types_supported":               []string{"public"}, // go-oidc requires this field
	})
}

// handleAuthorize serves the login form (GET) and processes credentials (POST).
func (p *Provider) handleAuthorize(w http.ResponseWriter, r *http.Request) {
	q := r.URL.Query()
	clientID := q.Get("client_id")
	redirectURI := q.Get("redirect_uri")

	// Validate client_id + redirect_uri against static config BEFORE rendering
	// anything — an attacker must not be able to bounce a code to an arbitrary
	// URL, so a mismatch is a 400, never a redirect.
	client, ok := p.clientFor(clientID, redirectURI)
	if !ok {
		http.Error(w, "unknown client_id or redirect_uri", http.StatusBadRequest)
		return
	}
	if q.Get("response_type") != "code" {
		http.Error(w, "response_type must be code", http.StatusBadRequest)
		return
	}
	challenge := q.Get("code_challenge")
	if q.Get("code_challenge_method") != "S256" || challenge == "" {
		http.Error(w, "code_challenge_method must be S256 with a challenge", http.StatusBadRequest)
		return
	}

	if r.Method == http.MethodGet {
		p.renderLogin(w, r.URL.RawQuery, "")
		return
	}

	// POST: authenticate the submitted credentials. A login form never needs
	// more than a few KB; cap the body well below ParseForm's default.
	r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
	user, ok := Authenticate(p.cfg.UsersFile, r.FormValue("email"), r.FormValue("password"))
	if !ok {
		// Fixed delay on failure — a crude brute-force brake; real rate limiting
		// is out of scope (spec §2.1). Re-render the form with an error (200).
		time.Sleep(1 * time.Second)
		p.renderLogin(w, r.URL.RawQuery, "Invalid email or password.")
		return
	}

	code := random16() + random16()
	p.mu.Lock()
	p.codes[code] = authCode{
		sub:         user.Sub,
		email:       user.Email,
		clientID:    client.ID,
		redirectURL: client.RedirectURL,
		challenge:   challenge,
		expires:     p.now().Add(2 * time.Minute),
	}
	p.mu.Unlock()

	redirect, _ := url.Parse(client.RedirectURL)
	rq := redirect.Query()
	rq.Set("code", code)
	rq.Set("state", q.Get("state")) // passed through verbatim
	redirect.RawQuery = rq.Encode()
	http.Redirect(w, r, redirect.String(), http.StatusFound)
}

// renderLogin renders the embedded form. Action posts back to /authorize with
// the original raw query so a headless client POSTs to the URL it just fetched
// (spec §2.1); rawQuery is trusted here (it came off the wire and we only echo
// it into a same-origin action).
func (p *Provider) renderLogin(w http.ResponseWriter, rawQuery, errMsg string) {
	action := "/authorize"
	if rawQuery != "" {
		action += "?" + rawQuery
	}
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	// html/template escapes both the action attribute and the error text.
	_ = loginTmpl.Execute(w, map[string]any{
		"Action": template.URL(action),
		"Error":  errMsg,
	})
}

// handleToken exchanges a single-use code for an id_token (authorization-code
// grant only, PKCE verified).
func (p *Provider) handleToken(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "POST required", http.StatusMethodNotAllowed)
		return
	}
	if r.FormValue("grant_type") != "authorization_code" {
		http.Error(w, "unsupported grant_type", http.StatusBadRequest)
		return
	}
	code := r.FormValue("code")
	clientID := r.FormValue("client_id")
	if clientID == "" {
		// RFC 6749 §2.3.1: clients may authenticate with HTTP Basic instead of
		// form params (x/oauth2's AuthStyleAutoDetect probes Basic first).
		clientID = basicClientID(r)
	}

	// Validate the caller BEFORE consuming the code: a wrong-client request
	// must not burn it, or a legitimate RP's Basic-vs-params autodetect retry
	// would find its own code already gone. Once the client checks out, the
	// code is consumed — every later outcome (expiry, PKCE failure, success)
	// is a real exchange attempt and single-use must hold.
	p.mu.Lock()
	ac, ok := p.codes[code]
	consumed := ok && clientID == ac.clientID && r.FormValue("redirect_uri") == ac.redirectURL
	if consumed {
		delete(p.codes, code)
	}
	p.mu.Unlock()
	if !ok {
		http.Error(w, "invalid code", http.StatusBadRequest)
		return
	}
	if !consumed {
		http.Error(w, "client_id or redirect_uri mismatch", http.StatusBadRequest)
		return
	}
	if p.now().After(ac.expires) {
		http.Error(w, "expired code", http.StatusBadRequest)
		return
	}
	// Verify PKCE: S256(code_verifier) must equal the stored challenge.
	sum := sha256.Sum256([]byte(r.FormValue("code_verifier")))
	got := base64.RawURLEncoding.EncodeToString(sum[:])
	if subtle.ConstantTimeCompare([]byte(got), []byte(ac.challenge)) != 1 {
		http.Error(w, "PKCE verification failed", http.StatusBadRequest)
		return
	}

	idToken, err := p.signIDToken(ac.sub, ac.email, ac.clientID, p.now())
	if err != nil {
		http.Error(w, "signing failed", http.StatusInternalServerError)
		return
	}
	writeJSON(w, map[string]any{
		"access_token": random16() + random16(),
		"token_type":   "Bearer",
		"id_token":     idToken,
		"expires_in":   300,
	})
}

// handleJWKS publishes the public key as a one-key JWK set (RFC 7517).
func (p *Provider) handleJWKS(w http.ResponseWriter, r *http.Request) {
	pub := p.key.PublicKey
	n := base64.RawURLEncoding.EncodeToString(pub.N.Bytes())
	e := base64.RawURLEncoding.EncodeToString(bigEndianExp(pub.E))
	writeJSON(w, map[string]any{
		"keys": []map[string]any{{
			"kty": "RSA",
			"alg": "RS256",
			"use": "sig",
			"kid": p.kid,
			"n":   n,
			"e":   e,
		}},
	})
}

// basicClientID extracts the client id from HTTP Basic credentials, in which
// RFC 6749 §2.3.1 says both halves are form-urlencoded. Public clients send an
// empty secret; only the username matters here.
func basicClientID(r *http.Request) string {
	user, _, ok := r.BasicAuth()
	if !ok {
		return ""
	}
	id, err := url.QueryUnescape(user)
	if err != nil {
		return ""
	}
	return id
}

func (p *Provider) clientFor(id, redirectURI string) (Client, bool) {
	for _, c := range p.cfg.Clients {
		if c.ID == id && c.RedirectURL == redirectURI {
			return c, true
		}
	}
	return Client{}, false
}

func writeJSON(w http.ResponseWriter, v any) {
	w.Header().Set("Content-Type", "application/json")
	_ = json.NewEncoder(w).Encode(v)
}

// bigEndianExp encodes an RSA public exponent as minimal big-endian bytes.
func bigEndianExp(e int) []byte {
	b := []byte{byte(e >> 16), byte(e >> 8), byte(e)}
	for len(b) > 1 && b[0] == 0 {
		b = b[1:]
	}
	return b
}

// keyID is a stable identifier for the public key: base64url of a SHA-256 over
// its PKIX DER. Same key in, same kid out, across restarts.
func keyID(pub *rsa.PublicKey) string {
	der, _ := x509.MarshalPKIXPublicKey(pub)
	sum := sha256.Sum256(der)
	return base64.RawURLEncoding.EncodeToString(sum[:])
}

// loadOrGenerateKey reads a PKCS#1 PEM key from path, or generates and persists
// an RSA-2048 one (0600) if the file is absent.
func loadOrGenerateKey(path string) (*rsa.PrivateKey, error) {
	b, err := os.ReadFile(path)
	if err == nil {
		block, _ := pem.Decode(b)
		if block == nil {
			return nil, errBadKey
		}
		return x509.ParsePKCS1PrivateKey(block.Bytes)
	}
	if !os.IsNotExist(err) {
		return nil, err
	}
	key, err := rsa.GenerateKey(rand.Reader, 2048)
	if err != nil {
		return nil, err
	}
	pemBytes := pem.EncodeToMemory(&pem.Block{
		Type:  "RSA PRIVATE KEY",
		Bytes: x509.MarshalPKCS1PrivateKey(key),
	})
	if err := os.WriteFile(path, pemBytes, 0o600); err != nil {
		return nil, err
	}
	return key, nil
}