hellhound/resource

Local validation of JWT access tokens (RFC 9068), for resource servers.

A Validator checks a bearer token against the issuer’s published keys, using the started Hellhound client’s key cache (an unknown kid refreshes the keys once, throttled), so validation sends no request per token:

let assert Ok(client) = hellhound.new(config.resource_server(issuer:))
let assert Ok(Nil) = hellhound.start(client)
let validator = resource.new(client, audience: "https://api.example.com")

case resource.verify(validator, bearer_token) {
  Ok(claims) -> resource.subject(claims)
  Error(error) ->
    case resource.error_kind(error) {
      resource.Rejected -> todo    // 401, error="invalid_token"
      resource.WrongAudience -> todo // 401, a valid token for another resource
      resource.Forbidden -> todo   // 403, error="insufficient_scope"
      resource.Unavailable -> todo // 503
    }
}

Every check fails closed:

CheckRuleSetter
sizeat most 8 KiB
algin the configured allowlist; none and HMAC are never acceptedwith_algorithms
typat+jwt (or application/at+jwt)allow_any_token_type
signaturea key of the issuer’s JWKS; the token’s kid selects it
issexactly the configured issuer
audexactly [audience]with_audience_policy
exprequired, strictly in the future
nbf, iatnot later than now plus the clock tolerance; iat requiredconfig.with_clock_tolerance
subrequired
scopesevery required scope presentwith_required_scopes

Local validation cannot see revocation: a JWT access token stays valid until exp even after the provider revoked it. A resource server that must refuse a revoked token at once uses introspection (hellhound.introspect, the second recipe below); otherwise keep access tokens short-lived. hellhound/testing.revoke_access_token shows both sides in a test.

With Relay

Relay’s verifier takes a function from its BearerToken and the request’s correlation to an attestation. verifier builds the validation half; its on_error maps a wrong-audience token to Relay’s IssuedForAnotherResource. Relay’s admit checks the endpoint scopes itself, so leave with_required_scopes unset. The recipe below, and the introspection variant for opaque tokens or immediate revocation, is compiled against Relay by scripts/relay-recipe on every gate run:

import gleam/option.{type Option, None, Some}
import relay/authorization.{type Attestation, type Verifier}
import hellhound
import hellhound/resource

pub type Principal {
  Principal(subject: String, client_id: Option(String), scopes: List(String))
}

/// Local RFC 9068 validation: no provider request per token. A token for
/// another resource gets Relay's "issued for another resource" challenge.
pub fn jwt_verifier(validator: resource.Validator) -> Verifier(Principal) {
  let verify =
    resource.verifier(
      validator,
      authorization.token_value,
      attest,
      on_error: refuse,
    )
  use token, _correlation <- authorization.verifier("hellhound-jwt")
  verify(token)
}

fn refuse(kind: resource.ErrorKind) -> authorization.VerificationError {
  case kind {
    resource.Rejected | resource.Forbidden -> authorization.BearerRejected
    resource.WrongAudience -> authorization.IssuedForAnotherResource
    resource.Unavailable -> authorization.VerifierUnavailable
  }
}

fn attest(claims: resource.AccessClaims) -> Attestation(Principal) {
  let scopes = resource.scopes(claims)
  authorization.attestation(
    Principal(resource.subject(claims), resource.client_id(claims), scopes),
    resource.audiences(claims),
    scopes,
  )
}

/// RFC 7662 introspection: one provider request per token, so a revoked
/// token is refused at once. The request's correlation tags the provider
/// call, so it joins the MCP request in telemetry.
pub fn introspection_verifier(client: hellhound.Client) -> Verifier(Principal) {
  use token, correlation <- authorization.verifier("hellhound-introspection")
  let client = hellhound.with_correlation(client, correlation)
  case hellhound.introspect(client, authorization.token_value(token)) {
    Ok(hellhound.ActiveToken(hellhound.TokenInfo(subject: Some(subject), ..) as info)) ->
      Ok(authorization.attestation(
        Principal(subject, info.client_id, info.scopes),
        info.audiences,
        info.scopes,
      ))
    Ok(hellhound.ActiveToken(hellhound.TokenInfo(subject: None, ..))) ->
      Error(authorization.VerifierUnmapped)
    Ok(hellhound.InactiveToken) | Error(hellhound.IntrospectionTokenTooLarge) ->
      Error(authorization.BearerRejected)
    Error(hellhound.IntrospectionNotSupported)
    | Error(hellhound.IntrospectionFailed(_)) ->
      Error(authorization.VerifierUnavailable)
  }
}

Types

Verified access-token claims. Built only by verify.

pub opaque type AccessClaims

How aud must name this resource server.

pub type AudiencePolicy {
  ExactAudience
  AudienceIncluded
}

Constructors

  • ExactAudience

    aud is exactly the configured audience: a token issued for several resource servers is refused, so one of them cannot replay it at another. The default.

  • AudienceIncluded

    aud contains the configured audience among others.

How a resource server answers a token error (RFC 6750 §3.1).

pub type ErrorKind {
  Rejected
  WrongAudience
  Forbidden
  Unavailable
}

Constructors

  • Rejected

    401 with error="invalid_token".

  • WrongAudience

    401 with error="invalid_token", for a token that passed every other check but names another resource (AudienceMismatch). Distinct so a framework can say so (Relay’s IssuedForAnotherResource); answer it as Rejected where it cannot.

  • Forbidden

    403 with error="insufficient_scope".

  • Unavailable

    503: the keys are unavailable; nothing about the token was decided.

Why a token was not accepted. May gain variants; branch on error_kind.

pub type TokenError {
  TokenTooLarge
  TokenMalformed
  TokenTypeInvalid
  UnsignedToken
  AlgorithmNotAllowed
  UnknownSigningKey
  BadSignature
  IssuerMismatch
  AudienceMismatch
  TokenExpired
  TokenNotYetValid
  MissingClaim(String)
  InsufficientScope(missing: List(String))
  KeysUnavailable(hellhound.ProviderFailure)
}

Constructors

  • TokenTooLarge

    Empty, or longer than max_token_bytes.

  • TokenMalformed

    Not a compact JWS (an encrypted token is refused too).

  • TokenTypeInvalid

    The header typ is not at+jwt.

  • UnsignedToken

    alg: none.

  • AlgorithmNotAllowed

    alg is outside the allowlist (including any HMAC algorithm).

  • UnknownSigningKey

    No key of the issuer verifies this kid, even after refreshing keys.

  • BadSignature
  • IssuerMismatch
  • AudienceMismatch
  • TokenExpired
  • TokenNotYetValid

    nbf or iat is in the future beyond the clock tolerance.

  • MissingClaim(String)
  • InsufficientScope(missing: List(String))

    The token is valid but lacks these required scopes.

  • KeysUnavailable(hellhound.ProviderFailure)

    The issuer’s keys are not loaded or could not be fetched.

A validator for one audience. Build it with new and the setters.

pub opaque type Validator

Values

pub fn allow_any_token_type(validator: Validator) -> Validator

Accept tokens whose header typ is not at+jwt, for providers that do not set it (RFC 9068 §4 requires the check). Prefer a provider setting over this.

pub fn audiences(claims: AccessClaims) -> List(String)
pub fn client_id(claims: AccessClaims) -> option.Option(String)

client_id (RFC 9068), or azp when the provider uses that instead.

pub fn decode_claims(
  claims: AccessClaims,
  decoder: decode.Decoder(a),
) -> Result(a, List(decode.DecodeError))

Decode the verified claims into a caller-owned type.

pub fn describe_error(error: TokenError) -> String
pub fn error_kind(error: TokenError) -> ErrorKind
pub fn issuer(claims: AccessClaims) -> String
pub fn jwt_id(claims: AccessClaims) -> option.Option(String)
pub const max_token_bytes: Int

The largest token verify accepts.

pub fn new(
  client: hellhound.Client,
  audience audience: String,
) -> Validator

A validator for tokens issued by the client’s issuer for audience. The algorithm allowlist starts from the client’s configured signing algorithms (default RS256, PS256, ES256, EdDSA).

pub fn scopes(claims: AccessClaims) -> List(String)
pub fn subject(claims: AccessClaims) -> String
pub fn verifier(
  validator: Validator,
  token_value: fn(token) -> String,
  accept: fn(AccessClaims) -> a,
  on_error on_error: fn(ErrorKind) -> e,
) -> fn(token) -> Result(a, e)

A verification function for a framework that hands its own token type to a verifier (such as Relay’s authorization.verifier): token_value reads the raw token, accept builds the framework’s result from the claims, and on_error maps the answer for a refused token to the framework’s error. Matching on ErrorKind is exhaustive, so a 503 cannot be swapped with a 401 by position, and a framework that has a variant for a token issued for another resource (WrongAudience) or for a missing scope (Forbidden) can use it:

fn refuse(kind: resource.ErrorKind) -> authorization.VerificationError {
  case kind {
    resource.Rejected | resource.Forbidden -> authorization.BearerRejected
    resource.WrongAudience -> authorization.IssuedForAnotherResource
    resource.Unavailable -> authorization.VerifierUnavailable
  }
}

Relay has no 403 variant for a verifier, so leave with_required_scopes unset and let its admit check scopes (403).

pub fn verify(
  validator: Validator,
  token: String,
) -> Result(AccessClaims, TokenError)

Verify a bearer token (the value after Bearer ).

pub fn with_algorithms(
  validator: Validator,
  algorithms: List(config.SigningAlgorithm),
) -> Validator

The signing algorithms accepted. none and HMAC algorithms are not representable, so a token cannot switch a public key into a shared secret.

pub fn with_audience_policy(
  validator: Validator,
  policy: AudiencePolicy,
) -> Validator
pub fn with_required_scopes(
  validator: Validator,
  scopes: List(String),
) -> Validator

Scopes every accepted token must carry (scope, space-separated, or a scp list).

✨ Search Document