106 lines
3.1 KiB
Go
106 lines
3.1 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"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 and retried per request so startup never depends on
|
|
// the issuer being reachable.
|
|
type BearerOIDC struct {
|
|
issuer string
|
|
audiences []string
|
|
|
|
mu sync.Mutex
|
|
verifier *oidc.IDTokenVerifier
|
|
}
|
|
|
|
// 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}
|
|
}
|
|
|
|
func (b *BearerOIDC) getVerifier(ctx context.Context) (*oidc.IDTokenVerifier, error) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
if b.verifier != nil {
|
|
return b.verifier, nil
|
|
}
|
|
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
defer cancel()
|
|
// The provider keeps this context for JWKS refreshes; it must outlive the request.
|
|
p, err := oidc.NewProvider(oidc.ClientContext(context.WithoutCancel(ctx), http.DefaultClient), b.issuer)
|
|
if err != nil {
|
|
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) {
|
|
raw, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
|
|
if !ok || raw == "" {
|
|
unauthorized(w, "missing bearer token")
|
|
return
|
|
}
|
|
v, err := b.getVerifier(r.Context())
|
|
if err != nil {
|
|
slog.Error("oidc discovery failed", "issuer", b.issuer, "error", err)
|
|
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 + `"}`))
|
|
}
|