// Package traefikoidc — bearer-token (M2M) authentication path.
//
// Disabled by default. When enabled via Config.EnableBearerAuth, requests
// presenting "Authorization: Bearer <jwt>" are validated against the
// configured OIDC provider (signature, issuer, audience, exp, replay-Get)
// and the request is forwarded downstream without creating a cookie session.
//
// Design rules (kept here in code as the single source of truth):
//   - Access tokens only. ID tokens are rejected via detectTokenType.
//   - Audience is mandatory (enforced at startup in main.go).
//   - alg + kid pinned BEFORE JWKS fetch to deny amplification probes.
//   - iat upper-age cap bounds clock-skew / forever-token abuse.
//   - Multi-audience tokens require matching azp.
//   - Per-IP 401 throttle returns 429 + Retry-After after a threshold.
//   - JTI Set is suppressed (skipReplayMarking) but JTI Get stays — revoked
//     tokens (RevokeToken adds to blacklist) are still rejected.
//   - Identifier is read from BearerIdentifierClaim (default "sub"), never
//     from UserIdentifierClaim, to avoid the unverified-email spoofing path.
//   - Identifier is sanitized: length cap, control chars, bidi-override,
//     delimiter chars (, ; =) rejected.
//   - On excluded URLs the Authorization header is stripped before forwarding.
//
// See docs/superpowers/specs/2026-05-18-bearer-token-auth-design.md and
// docs/BEARER_AUTH.md for the full threat model.
package traefikoidc

import (
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"net"
	"net/http"
	"strings"
	"sync"
	"time"
	"unicode"
)

const bearerPrefix = "Bearer "

// bearerAlgAllowlist is the set of JWS algorithms accepted on the bearer
// path. Asymmetric-only — HS* would allow public-key-as-HMAC-secret attacks
// if any operator ever rotates a key into the symmetric branch by mistake;
// "none" is obvious. Matches the allowlist enforced inside jwt.Verify but is
// checked here BEFORE the JWKS fetch so attacker noise can't amplify.
var bearerAlgAllowlist = map[string]struct{}{
	"RS256": {}, "RS384": {}, "RS512": {},
	"PS256": {}, "PS384": {}, "PS512": {},
	"ES256": {}, "ES384": {}, "ES512": {},
}

// bearerKidMaxLen caps the JOSE kid header length to keep memory and cache-key
// usage bounded against attacker-controlled values.
const bearerKidMaxLen = 256

// validKidChar is the allowlist for kid header characters. Letters, digits,
// dot, underscore, hyphen, equals. Intentionally narrow; real-world kid
// values are short URL-safe-base64-ish identifiers.
func validKidChar(r rune) bool {
	if r >= 'a' && r <= 'z' {
		return true
	}
	if r >= 'A' && r <= 'Z' {
		return true
	}
	if r >= '0' && r <= '9' {
		return true
	}
	switch r {
	case '.', '_', '-', '=':
		return true
	}
	return false
}

// bearerError categorizes failure modes for the response builder. Categories
// map 1:1 to the table in docs/superpowers/specs/2026-05-18-bearer-token-auth-design.md
// §9 so behavior is auditable from spec to code.
type bearerErrorKind int

const (
	bearerErrInvalidRequest bearerErrorKind = iota
	bearerErrInvalidToken
	bearerErrTokenInactive
	bearerErrInvalidIdentifier
	bearerErrForbidden
	bearerErrThrottled
	bearerErrIntrospectionUnavailable
)

type bearerError struct {
	kind   bearerErrorKind
	reason string
}

func (e *bearerError) Error() string { return e.reason }

func newBearerError(kind bearerErrorKind, reason string) *bearerError {
	return &bearerError{kind: kind, reason: reason}
}

// joseHeader is the minimal subset of the JWS protected header we inspect
// BEFORE running the full verification pipeline. Lifted out so the alg+kid
// pin can run without paying for parseJWT's full claim decode.
type joseHeader struct {
	Alg string `json:"alg"`
	Kid string `json:"kid"`
	Typ string `json:"typ"`
}

// parseBearerJOSEHeader decodes the first JWT segment for early alg/kid pinning.
// Does not touch the payload or signature — those are the verifier's job.
// Returns nil on success; *bearerError on rejection so the handler can map
// directly to a status code. The decoded header itself is not surfaced because
// callers don't need it (verifyTokenWithOpts re-parses internally).
func parseBearerJOSEHeader(token string) *bearerError {
	dot := strings.IndexByte(token, '.')
	if dot <= 0 {
		return newBearerError(bearerErrInvalidToken, "malformed JWT: no header segment")
	}
	raw, err := base64.RawURLEncoding.DecodeString(token[:dot])
	if err != nil {
		// Some IdPs pad with '='; tolerate by retrying with StdEncoding.
		raw, err = base64.URLEncoding.DecodeString(token[:dot])
		if err != nil {
			return newBearerError(bearerErrInvalidToken, "malformed JWT: header not base64url")
		}
	}
	var hdr joseHeader
	if err := json.Unmarshal(raw, &hdr); err != nil {
		return newBearerError(bearerErrInvalidToken, "malformed JWT: header not JSON")
	}
	if _, ok := bearerAlgAllowlist[hdr.Alg]; !ok {
		return newBearerError(bearerErrInvalidToken, fmt.Sprintf("disallowed alg %q on bearer path", hdr.Alg))
	}
	if hdr.Kid == "" {
		return newBearerError(bearerErrInvalidToken, "missing kid header")
	}
	if len(hdr.Kid) > bearerKidMaxLen {
		return newBearerError(bearerErrInvalidToken, "kid header exceeds max length")
	}
	for _, r := range hdr.Kid {
		if !validKidChar(r) {
			return newBearerError(bearerErrInvalidToken, "kid header contains disallowed characters")
		}
	}
	return nil
}

// headerClaimRuneReason reports why a rune is unsafe to inject into a request
// header value, or "" if the rune is acceptable. Shared core of the bearer-path
// identifier sanitizer and the cookie-path header claim sanitizer: rejects
// control chars (CRLF/header injection), Unicode bidi-override runes (RTL
// spoofing of admin UI / SIEM), and the delimiters , ; = (a comma in a group
// name would inject extra entries into a comma-joined header).
func headerClaimRuneReason(r rune) string {
	if reason := headerInjectionRuneReason(r); reason != "" {
		return reason
	}
	// The , ; = delimiters are only unsafe for values placed into delimited or
	// list contexts (a comma-joined header, or an identifier downstreams may
	// split). They are valid in arbitrary single header values, so this stricter
	// check is used for the cookie-path identifier and the group/role list, NOT
	// for free-form templated header output (see headerValueReason).
	if r == ',' || r == ';' || r == '=' {
		return "delimiter character"
	}
	return ""
}

// headerInjectionRuneReason reports why a rune is unsafe in ANY HTTP header
// value, or "" if acceptable. Rejects control characters (CR/LF header
// injection) and Unicode bidi-override runes (RTL spoofing of admin UIs/SIEMs).
// Unlike headerClaimRuneReason it does NOT reject , ; = which are legitimate in
// free-form header values (e.g. an opaque "Authorization: Bearer <token>").
func headerInjectionRuneReason(r rune) string {
	if unicode.IsControl(r) {
		return "control character"
	}
	if (r >= 0x202A && r <= 0x202E) || (r >= 0x2066 && r <= 0x2069) {
		return "bidi-override character"
	}
	return ""
}

// headerValueReason reports why value is unsafe to forward as a free-form HTTP
// header value, or "" if acceptable. It rejects values over maxLen (maxLen<=0
// disables the check) and values containing control or bidi-override runes, but
// permits , ; = (valid in header values). Empty is allowed. The reason string
// never includes the value, so it is safe to log.
func headerValueReason(value string, maxLen int) string {
	if maxLen > 0 && len(value) > maxLen {
		return "exceeds max length"
	}
	for _, r := range value {
		if reason := headerInjectionRuneReason(r); reason != "" {
			return reason
		}
	}
	return ""
}

// headerClaimValueReason reports why value is unsafe to inject into a
// downstream request header, or "" if it is acceptable. It rejects empty
// values, values exceeding maxLen (maxLen<=0 disables the length check), and
// values containing any rune rejected by headerClaimRuneReason. The reason
// string is safe to log (it never includes the value itself).
func headerClaimValueReason(value string, maxLen int) string {
	if value == "" {
		return "empty value"
	}
	if maxLen > 0 && len(value) > maxLen {
		return "exceeds max length"
	}
	for _, r := range value {
		if reason := headerClaimRuneReason(r); reason != "" {
			return reason
		}
	}
	return ""
}

// sanitizeHeaderClaimValue validates a claim-derived value before it is
// injected into a downstream request header. It trims surrounding whitespace
// and fails closed (ok=false) on empty values, values exceeding maxLen
// (maxLen<=0 disables the length check), or values containing any rune rejected
// by headerClaimRuneReason. Used by the cookie/session path, which — unlike the
// bearer path — does not otherwise sanitize the principal identifier or the
// group/role strings joined into X-User-Groups / X-User-Roles.
func sanitizeHeaderClaimValue(raw string, maxLen int) (string, bool) {
	value := strings.TrimSpace(raw)
	if headerClaimValueReason(value, maxLen) != "" {
		return "", false
	}
	return value, true
}

// sanitizeBearerIdentifier validates and trims a principal identifier before
// it is injected into request headers. Layered defense: net/http will reject
// CRLF on the wire too, but rejecting early gives clearer error logs and
// prevents bidi-override / delimiter chars that pass net/http's narrower
// checks but confuse downstream parsers and admin UIs.
func sanitizeBearerIdentifier(raw string, maxLen int) (string, *bearerError) {
	identifier := strings.TrimSpace(raw)
	if identifier == "" {
		return "", newBearerError(bearerErrInvalidIdentifier, "identifier claim empty")
	}
	if maxLen > 0 && len(identifier) > maxLen {
		return "", newBearerError(bearerErrInvalidIdentifier, "identifier exceeds max length")
	}
	for _, r := range identifier {
		if reason := headerClaimRuneReason(r); reason != "" {
			return "", newBearerError(bearerErrInvalidIdentifier, "identifier contains "+reason)
		}
	}
	return identifier, nil
}

// resolveBearerIdentifier picks the principal identifier from claims using
// the configured BearerIdentifierClaim (default "sub"). Decoupled from
// userIdentifierClaim (cookie path) to avoid the unverified-email spoofing
// vector documented in the spec §13.
func resolveBearerIdentifier(claims map[string]interface{}, claimName string) (string, *bearerError) {
	if claimName == "" {
		claimName = "sub"
	}
	raw, ok := claims[claimName]
	if !ok {
		return "", newBearerError(bearerErrInvalidIdentifier, fmt.Sprintf("missing claim %q", claimName))
	}
	str, ok := raw.(string)
	if !ok {
		return "", newBearerError(bearerErrInvalidIdentifier, fmt.Sprintf("claim %q not a string", claimName))
	}
	return str, nil
}

// enforceMultiAudienceAzp implements the spec hardening: when aud is a
// multi-element array, require an azp claim equal to clientID. Single-string
// aud is unaffected (existing verifyAudience handles it).
func enforceMultiAudienceAzp(claims map[string]interface{}, clientID string) *bearerError {
	audRaw, ok := claims["aud"]
	if !ok {
		return nil // verifyToken already rejects missing aud
	}
	arr, ok := audRaw.([]interface{})
	if !ok {
		return nil // single-string aud
	}
	if len(arr) <= 1 {
		return nil
	}
	azpRaw, ok := claims["azp"]
	if !ok {
		return newBearerError(bearerErrInvalidToken, "multi-audience token missing azp")
	}
	azp, ok := azpRaw.(string)
	if !ok || azp == "" {
		return newBearerError(bearerErrInvalidToken, "multi-audience token has empty/non-string azp")
	}
	if azp != clientID {
		return newBearerError(bearerErrInvalidToken, "multi-audience token azp does not match clientID")
	}
	return nil
}

// enforceIatAge implements the spec MaxTokenAgeSeconds bound on iat. Bounds
// clock-manipulation / forever-token abuse without rejecting tokens with a
// normal iat just because the issuer's clock skews a few seconds.
func enforceIatAge(claims map[string]interface{}, maxAge time.Duration) *bearerError {
	if maxAge <= 0 {
		return nil
	}
	iatRaw, ok := claims["iat"].(float64)
	if !ok {
		// jwt.Verify already requires iat; this branch shouldn't be reached.
		return newBearerError(bearerErrInvalidToken, "missing iat claim")
	}
	iat := time.Unix(int64(iatRaw), 0)
	if time.Since(iat) > maxAge {
		return newBearerError(bearerErrInvalidToken, "token iat outside age bound")
	}
	return nil
}

// hashIdentifierForLog returns a short SHA-256 prefix safe for info-level
// logs. Full identifier is only emitted at debug. Satisfies the audit
// requirement (trace which principal was rejected) without leaking PII.
func hashIdentifierForLog(identifier string) string {
	if identifier == "" {
		return "(none)"
	}
	sum := sha256.Sum256([]byte(identifier))
	return hex.EncodeToString(sum[:4]) // 8 hex chars
}

// --- Per-IP failure throttle ---

// bearerFailureTracker records consecutive bearer-auth 401s per source IP and
// parks repeat offenders in a 429 penalty box. Limits offline-guessing-style
// attacks and protects the shared rate-limiter / JWKS endpoint from being
// burned by a single source.
type bearerFailureTracker struct {
	mu      sync.Mutex
	entries map[string]*bearerFailureEntry
	// Configuration snapshot. Captured at construction so a hot reconfigure
	// doesn't race with the per-request paths.
	threshold int
	window    time.Duration
	penalty   time.Duration
}

type bearerFailureEntry struct {
	firstFailureAt time.Time
	penaltyUntil   time.Time
	count          int
}

func newBearerFailureTracker(threshold int, window, penalty time.Duration) *bearerFailureTracker {
	if threshold <= 0 {
		threshold = 20
	}
	if window <= 0 {
		window = 60 * time.Second
	}
	if penalty <= 0 {
		penalty = 60 * time.Second
	}
	return &bearerFailureTracker{
		entries:   make(map[string]*bearerFailureEntry),
		threshold: threshold,
		window:    window,
		penalty:   penalty,
	}
}

// blocked reports whether the source IP is currently in the penalty box.
// Returns (true, retryAfter) when blocked; (false, 0) when allowed.
func (b *bearerFailureTracker) blocked(ip string) (bool, time.Duration) {
	if b == nil || ip == "" {
		return false, 0
	}
	b.mu.Lock()
	defer b.mu.Unlock()
	e, ok := b.entries[ip]
	if !ok {
		return false, 0
	}
	now := time.Now()
	if !e.penaltyUntil.IsZero() && now.Before(e.penaltyUntil) {
		return true, time.Until(e.penaltyUntil)
	}
	return false, 0
}

// recordFailure increments the failure counter for the given IP and trips
// the penalty box once threshold-within-window is exceeded.
func (b *bearerFailureTracker) recordFailure(ip string) {
	if b == nil || ip == "" {
		return
	}
	b.mu.Lock()
	defer b.mu.Unlock()
	now := time.Now()
	e, ok := b.entries[ip]
	if !ok || now.Sub(e.firstFailureAt) > b.window {
		e = &bearerFailureEntry{firstFailureAt: now}
		b.entries[ip] = e
	}
	e.count++
	if e.count >= b.threshold {
		e.penaltyUntil = now.Add(b.penalty)
	}
}

// recordSuccess clears the failure counter for the given IP after a
// successful bearer auth.
func (b *bearerFailureTracker) recordSuccess(ip string) {
	if b == nil || ip == "" {
		return
	}
	b.mu.Lock()
	defer b.mu.Unlock()
	e, ok := b.entries[ip]
	if !ok {
		return
	}
	// Preserve an active penalty so a single success cannot wipe an in-effect
	// lockout; only reset the counter when no penalty is active or it has expired.
	now := time.Now()
	if e.penaltyUntil.IsZero() || now.After(e.penaltyUntil) {
		e.count = 0
		e.firstFailureAt = now
	}
}

// clientIPForBearer returns the source IP used to key the failure tracker.
// Trusts only the request's transport-level RemoteAddr; X-Forwarded-For is
// intentionally ignored to avoid attacker-controlled key spoofing. Behind a
// trusted reverse proxy where every request shares one IP, the throttle is
// still useful (caps attacker churn through that proxy) — operators wanting
// per-real-client throttling must terminate at this middleware.
func clientIPForBearer(req *http.Request) string {
	if req == nil {
		return ""
	}
	host, _, err := net.SplitHostPort(req.RemoteAddr)
	if err != nil {
		return req.RemoteAddr
	}
	return host
}

// --- Bearer auth entrypoint ---

// detectBearerToken returns (token, true) when the request carries a usable
// Authorization: Bearer header. Case-insensitive on the scheme. Returns
// ("", false) for any other shape.
func detectBearerToken(req *http.Request) (string, bool) {
	if req == nil {
		return "", false
	}
	h := req.Header.Get("Authorization")
	if len(h) < len(bearerPrefix) {
		return "", false
	}
	if !strings.EqualFold(h[:len(bearerPrefix)], bearerPrefix) {
		return "", false
	}
	token := strings.TrimSpace(h[len(bearerPrefix):])
	if token == "" {
		return "", false
	}
	return token, true
}

// hasSessionCookie reports whether the request carries any cookie matching
// the session prefix. Used to implement the cookie-wins-by-default
// precedence rule when both bearer and cookie are present.
func (t *TraefikOidc) hasSessionCookie(req *http.Request) bool {
	if t.sessionManager == nil {
		return false
	}
	prefix := t.sessionManager.GetCookiePrefix()
	if prefix == "" {
		return false
	}
	for _, c := range req.Cookies() {
		if strings.HasPrefix(c.Name, prefix) {
			return true
		}
	}
	return false
}

// writeBearerError writes the canonical 401/403/429/503 response per spec §9.
// Body is always generic; reason is logged at debug only. The
// WWW-Authenticate hint is gated by config (default on, RFC 6750 compliant).
func (t *TraefikOidc) writeBearerError(rw http.ResponseWriter, req *http.Request, err *bearerError) {
	var (
		status     int
		errCode    string
		body       string
		retryAfter time.Duration
	)
	switch err.kind {
	case bearerErrInvalidRequest:
		status = http.StatusUnauthorized
		errCode = "invalid_request"
		body = "Unauthorized"
	case bearerErrInvalidToken, bearerErrTokenInactive, bearerErrInvalidIdentifier:
		status = http.StatusUnauthorized
		errCode = "invalid_token"
		body = "Unauthorized"
	case bearerErrForbidden:
		status = http.StatusForbidden
		body = "Access denied"
	case bearerErrThrottled:
		status = http.StatusTooManyRequests
		body = "Too Many Requests"
		retryAfter = t.bearerFailurePenalty
	case bearerErrIntrospectionUnavailable:
		status = http.StatusServiceUnavailable
		body = "Service Unavailable"
	default:
		status = http.StatusUnauthorized
		body = "Unauthorized"
	}

	if t.bearerEmitWWWAuthenticate && errCode != "" {
		rw.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer error=%q`, errCode))
	}
	if retryAfter > 0 {
		rw.Header().Set("Retry-After", fmt.Sprintf("%d", int(retryAfter.Seconds())))
	}
	rw.Header().Set("Content-Type", "text/plain; charset=utf-8")
	rw.WriteHeader(status)
	_, _ = rw.Write([]byte(body)) // Safe to ignore: best-effort error body write

	if t.logger != nil {
		t.logger.Debugf("bearer auth rejected: status=%d category=%v reason=%q path=%s",
			status, err.kind, err.reason, req.URL.Path)
	}
}

// handleBearerRequest is the entry point invoked by ServeHTTP when the
// EnableBearerAuth flag is set, the request carries an Authorization: Bearer
// header, and the (configurable) cookie-precedence rule allows the bearer
// path to run.
func (t *TraefikOidc) handleBearerRequest(rw http.ResponseWriter, req *http.Request) {
	ip := clientIPForBearer(req)

	if blocked, retryAfter := t.bearerFailureTracker.blocked(ip); blocked {
		throttled := newBearerError(bearerErrThrottled, "ip in penalty box")
		// Preserve the actual retry-after even if it diverged from the
		// configured default (clock-skew, partial-window expiry).
		if retryAfter > 0 {
			rw.Header().Set("Retry-After", fmt.Sprintf("%d", int(retryAfter.Seconds())))
		}
		t.writeBearerError(rw, req, throttled)
		return
	}

	token, ok := detectBearerToken(req)
	if !ok {
		t.bearerFailureTracker.recordFailure(ip)
		t.writeBearerError(rw, req, newBearerError(bearerErrInvalidRequest, "missing or empty bearer token"))
		return
	}
	if len(token) > AccessTokenConfig.MaxLength {
		t.bearerFailureTracker.recordFailure(ip)
		t.writeBearerError(rw, req, newBearerError(bearerErrInvalidToken, "token exceeds max length"))
		return
	}
	if strings.Count(token, ".") != 2 {
		t.bearerFailureTracker.recordFailure(ip)
		t.writeBearerError(rw, req, newBearerError(bearerErrInvalidToken, "token is not a 3-segment JWT"))
		return
	}

	if bErr := parseBearerJOSEHeader(token); bErr != nil {
		t.bearerFailureTracker.recordFailure(ip)
		t.writeBearerError(rw, req, bErr)
		return
	}

	p, bErr := t.buildPrincipalFromBearerToken(token)
	if bErr != nil {
		t.bearerFailureTracker.recordFailure(ip)
		t.writeBearerError(rw, req, bErr)
		return
	}

	t.bearerFailureTracker.recordSuccess(ip)
	if t.logger != nil {
		t.logger.Debugf("bearer auth success: identifier_hash=%s path=%s",
			hashIdentifierForLog(p.Identifier), req.URL.Path)
	}
	t.forwardAuthorized(rw, req, p)
}

// buildPrincipalFromBearerToken runs the full bearer verification pipeline
// described in spec §7.3 and returns a principal ready for forwardAuthorized.
// Returns a typed *bearerError on failure so the caller can map to status.
func (t *TraefikOidc) buildPrincipalFromBearerToken(token string) (*principal, *bearerError) {
	if err := t.verifyTokenWithOpts(token, verifyOpts{skipReplayMarking: true}); err != nil {
		return nil, newBearerError(bearerErrInvalidToken, "token verification failed: "+err.Error())
	}

	parsed, err := parseJWT(token)
	if err != nil {
		return nil, newBearerError(bearerErrInvalidToken, "post-verify parseJWT failed: "+err.Error())
	}
	claims := parsed.Claims

	// Token-type guard. Reuse the well-tested classifier which already
	// checks nonce / typ=at+jwt / token_use / scope / aud-vs-clientID.
	if t.detectTokenType(parsed, token) {
		return nil, newBearerError(bearerErrInvalidToken, "ID tokens are not accepted on the bearer path")
	}
	// Belt-and-braces explicit rejection (cheap, catches edge cases not
	// covered by detectTokenType's heuristic).
	if nonce, ok := claims["nonce"].(string); ok && nonce != "" {
		return nil, newBearerError(bearerErrInvalidToken, "nonce claim present (ID-token shape)")
	}
	if tu, ok := claims["token_use"].(string); ok && tu == "id" {
		return nil, newBearerError(bearerErrInvalidToken, "token_use=id rejected")
	}

	if bErr := enforceMultiAudienceAzp(claims, t.clientID); bErr != nil {
		return nil, bErr
	}
	if bErr := enforceIatAge(claims, t.maxTokenAge); bErr != nil {
		return nil, bErr
	}

	if t.requireTokenIntrospection {
		if bErr := t.introspectOnBearerPath(token); bErr != nil {
			return nil, bErr
		}
	}

	rawIdentifier, bErr := resolveBearerIdentifier(claims, t.bearerIdentifierClaim)
	if bErr != nil {
		return nil, bErr
	}
	identifier, bErr := sanitizeBearerIdentifier(rawIdentifier, t.maxIdentifierLength)
	if bErr != nil {
		return nil, bErr
	}

	subject, _ := claims["sub"].(string)
	clientID, _ := claims["azp"].(string)
	if clientID == "" {
		clientID, _ = claims["client_id"].(string)
	}

	return &principal{
		Source:      sourceBearer,
		Identifier:  identifier,
		Subject:     subject,
		ClientID:    clientID,
		Claims:      claims,
		AccessToken: token,
	}, nil
}

// introspectOnBearerPath calls the existing RFC 7662 introspector when the
// operator demands real-time revocation. Distinguishes "token revoked" (401)
// from "endpoint unavailable" (503) so transient infra failures don't look
// like credential failures.
func (t *TraefikOidc) introspectOnBearerPath(token string) *bearerError {
	resp, err := t.introspectToken(token)
	if err != nil {
		return newBearerError(bearerErrIntrospectionUnavailable, "introspection failed: "+err.Error())
	}
	if !resp.Active {
		return newBearerError(bearerErrTokenInactive, "introspection reports token inactive")
	}
	return nil
}
