128 lines
3.8 KiB
Go
128 lines
3.8 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/coreos/go-oidc/v3/oidc"
|
|
)
|
|
|
|
// BearerOIDC verifies `Authorization: Bearer <jwt>` against an OIDC issuer's
|
|
// JWKS. Discovery is lazy so startup never depends on the issuer being
|
|
// reachable; a failed discovery is not retried until the backoff elapses.
|
|
type BearerOIDC struct {
|
|
issuer string
|
|
audiences []string
|
|
timeout time.Duration
|
|
backoff time.Duration
|
|
|
|
mu sync.Mutex
|
|
verifier *oidc.IDTokenVerifier
|
|
discovering bool
|
|
failedAt time.Time
|
|
}
|
|
|
|
var errIssuerUnavailable = errors.New("oidc issuer unavailable")
|
|
|
|
// NewBearerOIDC returns nil when issuer is empty, meaning auth is disabled.
|
|
func NewBearerOIDC(issuer string, audiences []string) *BearerOIDC {
|
|
if issuer == "" {
|
|
return nil
|
|
}
|
|
return &BearerOIDC{issuer: issuer, audiences: audiences, timeout: 10 * time.Second, backoff: 15 * time.Second}
|
|
}
|
|
|
|
// getVerifier runs at most one discovery at a time without holding the lock
|
|
// across network I/O; callers arriving mid-discovery or within the backoff fail fast.
|
|
func (b *BearerOIDC) getVerifier() (*oidc.IDTokenVerifier, error) {
|
|
b.mu.Lock()
|
|
if b.verifier != nil || b.discovering || time.Since(b.failedAt) < b.backoff {
|
|
v := b.verifier
|
|
b.mu.Unlock()
|
|
if v == nil {
|
|
return nil, errIssuerUnavailable
|
|
}
|
|
return v, nil
|
|
}
|
|
b.discovering = true
|
|
b.mu.Unlock()
|
|
|
|
// The provider keeps this context and client for JWKS refreshes, so the
|
|
// deadline lives on the client rather than the context.
|
|
client := &http.Client{Timeout: b.timeout}
|
|
p, err := oidc.NewProvider(oidc.ClientContext(context.Background(), client), b.issuer)
|
|
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
b.discovering = false
|
|
if err != nil {
|
|
b.failedAt = time.Now()
|
|
slog.Error("oidc discovery failed", "issuer", b.issuer, "error", err)
|
|
return nil, err
|
|
}
|
|
b.verifier = p.Verifier(&oidc.Config{SkipClientIDCheck: true})
|
|
return b.verifier, nil
|
|
}
|
|
|
|
// Require gates every request through next on a valid token. A nil receiver
|
|
// passes everything through.
|
|
func (b *BearerOIDC) Require(next http.Handler) http.Handler {
|
|
if b == nil {
|
|
return next
|
|
}
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
scheme, raw, _ := strings.Cut(strings.TrimSpace(r.Header.Get("Authorization")), " ")
|
|
raw = strings.TrimSpace(raw)
|
|
if !strings.EqualFold(scheme, "Bearer") || raw == "" {
|
|
unauthorized(w, "missing bearer token")
|
|
return
|
|
}
|
|
v, err := b.getVerifier()
|
|
if err != nil {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
_, _ = w.Write([]byte(`{"error":"token issuer unavailable"}`))
|
|
return
|
|
}
|
|
tok, err := v.Verify(r.Context(), raw)
|
|
if err != nil {
|
|
slog.Info("bearer token rejected", "error", err)
|
|
unauthorized(w, "invalid token")
|
|
return
|
|
}
|
|
if len(b.audiences) > 0 && !slices.ContainsFunc(tok.Audience, func(a string) bool { return slices.Contains(b.audiences, a) }) {
|
|
slog.Info("bearer token rejected", "error", "audience not allowed", "aud", tok.Audience)
|
|
unauthorized(w, "invalid token")
|
|
return
|
|
}
|
|
slog.Info("bearer token accepted", "sub", tok.Subject, "method", r.Method, "path", r.URL.Path)
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// RequireWrites gates only mutating methods.
|
|
func (b *BearerOIDC) RequireWrites(next http.Handler) http.Handler {
|
|
gated := b.Require(next)
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
|
next.ServeHTTP(w, r)
|
|
default:
|
|
gated.ServeHTTP(w, r)
|
|
}
|
|
})
|
|
}
|
|
|
|
func unauthorized(w http.ResponseWriter, msg string) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("WWW-Authenticate", "Bearer")
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
_, _ = w.Write([]byte(`{"error":"` + msg + `"}`))
|
|
}
|