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
}