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:
| Check | Rule | Setter |
|---|---|---|
| size | at most 8 KiB | |
alg | in the configured allowlist; none and HMAC are never accepted | with_algorithms |
typ | at+jwt (or application/at+jwt) | allow_any_token_type |
| signature | a key of the issuer’s JWKS; the token’s kid selects it | |
iss | exactly the configured issuer | |
aud | exactly [audience] | with_audience_policy |
exp | required, strictly in the future | |
nbf, iat | not later than now plus the clock tolerance; iat required | config.with_clock_tolerance |
sub | required | |
| scopes | every required scope present | with_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
-
ExactAudienceaudis exactly the configured audience: a token issued for several resource servers is refused, so one of them cannot replay it at another. The default. -
AudienceIncludedaudcontains 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
-
Rejected401 with
error="invalid_token". -
WrongAudience401 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’sIssuedForAnotherResource); answer it asRejectedwhere it cannot. -
Forbidden403 with
error="insufficient_scope". -
Unavailable503: 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
-
TokenTooLargeEmpty, or longer than
max_token_bytes. -
TokenMalformedNot a compact JWS (an encrypted token is refused too).
-
TokenTypeInvalidThe header
typis notat+jwt. -
UnsignedTokenalg: none. -
AlgorithmNotAllowedalgis outside the allowlist (including any HMAC algorithm). -
UnknownSigningKeyNo key of the issuer verifies this
kid, even after refreshing keys. -
BadSignature -
IssuerMismatch -
AudienceMismatch -
TokenExpired -
TokenNotYetValidnbforiatis 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.
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 expires_at(claims: AccessClaims) -> timestamp.Timestamp
pub fn issued_at(claims: AccessClaims) -> timestamp.Timestamp
pub fn issuer(claims: AccessClaims) -> String
pub fn jwt_id(claims: AccessClaims) -> option.Option(String)
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