package auth import ( "context" "errors" "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 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 + `"}`)) }