package auth import ( "context" "log/slog" "net/http" "slices" "strings" "sync" "time" "github.com/coreos/go-oidc/v3/oidc" ) // BearerOIDC verifies `Authorization: Bearer ` 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 + `"}`)) }