hellhound/testing

Test support: a scripted OpenID provider with its own test PKI, an in-memory record store, and a conformance check for store adapters.

The provider serves discovery, keys, a scripted login page at /authorize, token (authorization code with PKCE S256, rotating refresh tokens, client credentials), userinfo, introspection, revocation and end-session over HTTPS on loopback. Its certificate chains to a root generated at start; config and trusting trust that root and allow loopback explicitly, so no production default changes. ID and access tokens are signed ES256 with gose.

Over HTTP

A test browser follows the application’s login redirect to the provider (trusting trust_anchor_pem). /authorize signs in the user that with_login or set_login names (test-user by default, or the request’s login_hint) and answers 303 to the callback with code, state and iss (a self-posting form for form-post). The application needs no test hook:

let assert Ok(provider) =
  testing.start_provider(
    testing.provider_options() |> testing.with_login(testing.SignIn("ada")),
  )
// The app runs `begin_login` and `login_response` in its /login route;
// the browser follows /login -> provider /authorize -> app /callback.
testing.set_login(provider, testing.Refuse("access_denied"))  // next login is cancelled

In process

A unit test that holds the LoginRedirect skips HTTP:

let assert Ok(provider) = testing.start_provider(testing.provider_options())
let assert Ok(client) =
  hellhound.new(testing.config(provider, "https://app.test/callback"))
let assert Ok(Nil) = hellhound.start(client)

let assert Ok(redirect) = hellhound.begin_login(client, browser, hellhound.default_login())
let assert Ok(callback) = testing.authorize(provider, redirect, subject: "ada")
let assert Ok(session) = hellhound.complete_login(client, callback)

Access tokens

set_access_token_audiences changes the aud of issued access tokens after start, so an application can name its own address once it listens. revoke_access_token revokes one token for introspection and userinfo; a local JWT validator (hellhound/resource) keeps accepting it until it expires, as RFC 9068 tokens carry no revocation channel.

The provider mints tokens with keys it generated; nothing trusts them unless a configuration names the provider’s issuer and root.

Types

Typed standard email claims. None omits that claim entirely. These are provider assertions, not permission or email-format validation.

pub type EmailClaims {
  EmailClaims(
    email: option.Option(String),
    verified: option.Option(Bool),
  )
}

Constructors

A forged token, for testing that a resource server refuses it.

pub type Forgery {
  UnsignedToken
  HmacWithPublicKey
  UnknownKey
}

Constructors

  • UnsignedToken

    alg: none and an empty signature.

  • HmacWithPublicKey

    HS256 keyed with the provider’s public key (algorithm confusion).

  • UnknownKey

    Signed by a key the provider never published, with an unknown kid.

What the provider’s /authorize endpoint does with a browser that arrives there: the provider’s login page, scripted.

pub type LoginDecision {
  SignIn(subject: String)
  Refuse(error: String)
}

Constructors

  • SignIn(subject: String)

    Sign subject in and redirect back to the client with a code. When the authorization request carries login_hint (Hellhound sends LoginOptions.login_hint), that subject signs in instead, so one provider serves several users at once.

  • Refuse(error: String)

    Redirect back with this OAuth error, as when the user cancels ("access_denied") or prompt=none finds no session ("login_required").

pub opaque type Provider

How the provider behaves. Build from provider_options().

pub opaque type ProviderOptions

Requests the provider has answered, by kind.

pub type RequestCounts {
  RequestCounts(
    discovery: Int,
    authorizations: Int,
    keys: Int,
    code_grants: Int,
    refresh_grants: Int,
    rejected_refresh_grants: Int,
    client_credentials_grants: Int,
    userinfo: Int,
    introspections: Int,
    revocations: Int,
    end_sessions: Int,
    active_refresh_tokens: Int,
  )
}

Constructors

  • RequestCounts(
      discovery: Int,
      authorizations: Int,
      keys: Int,
      code_grants: Int,
      refresh_grants: Int,
      rejected_refresh_grants: Int,
      client_credentials_grants: Int,
      userinfo: Int,
      introspections: Int,
      revocations: Int,
      end_sessions: Int,
      active_refresh_tokens: Int,
    )

    Arguments

    authorizations

    Authorization requests, over HTTP or through authorize, whether they signed someone in or not.

    active_refresh_tokens

    Refresh tokens that are neither rotated nor revoked.

pub type TestError {
  ProviderStartFailed
  AuthorizationRefused(reason: String)
}

Constructors

  • ProviderStartFailed

    The provider’s PKI or listener did not start.

  • AuthorizationRefused(reason: String)

    authorize was given a URL the provider does not accept (another client, no S256 challenge, or not this provider’s endpoint).

An access token to mint with issue_access_token. Build from access_token(subject).

pub opaque type TokenSpec

Values

pub fn access_token(subject: String) -> TokenSpec

Defaults: audience the client id, no scopes, five minutes, typ at+jwt.

pub fn authorize(
  provider: Provider,
  redirect: hellhound.LoginRedirect,
  subject subject: String,
) -> Result(request.Request(String), TestError)

Play the browser and the provider’s login page in process: accept the authorization request in redirect for subject and return the callback request Hellhound expects (the query, or a form post, with the binding cookie). subject overrides the provider’s LoginDecision.

pub fn browser_request(
  redirect: hellhound.LoginRedirect,
) -> request.Request(String)

A request from the same browser (it carries the binding cookie of an earlier login), for hellhound.begin_login.

pub fn check_store(
  store: store.Store,
) -> Result(Nil, List(String))

Check a store adapter against the hellhound/store contract with records under keys prefixed hellhound-conformance:: insert only when absent, version and deadline conditions, refusal preservation, concurrent admission, reads of the latest write, and delete_expired deleting exactly the expired records. Returns every violation found. Run it against an empty test table. Adapters must also verify clock sampling after their own queue/lock waits and uncertain commits.

pub fn client_id(provider: Provider) -> String
pub fn config(
  provider: Provider,
  redirect_uri: String,
) -> config.Config

A relying-party configuration for this provider: its issuer, client and secret (client_secret_basic), its root, and loopback destinations.

pub fn forged(spec: TokenSpec, forgery: Forgery) -> TokenSpec
pub fn issue_access_token(
  provider: Provider,
  spec: TokenSpec,
) -> String

Mint a JWT access token (RFC 9068) signed with the provider’s current key, or forged as spec says. The provider’s introspection endpoint knows it too.

pub fn issuer(provider: Provider) -> String
pub fn memory_store() -> store.Store

A fresh in-memory record store linked to the caller: a reference implementation of the hellhound/store contract, for tests and for wrapping with delays or failures.

pub fn provider_options() -> ProviderOptions

Defaults: client hellhound-test with a random secret, five-minute access tokens whose audience is the client id, no refresh delay, scopes openid email profile, and /authorize signing in test-user.

pub fn requests(provider: Provider) -> RequestCounts
pub fn revoke_access_token(
  provider: Provider,
  token: String,
) -> Nil

Revoke one access token, as RFC 7009 revocation or an administrator would. Introspection then answers active: false and userinfo invalid_token.

A resource server that validates JWTs locally (hellhound/resource) does not see this: RFC 9068 tokens carry no revocation channel, so they stay valid until exp. A test that needs a revoked token refused at once uses introspection (hellhound.introspect, or resource’s introspection recipe); with local validation, use short lifetimes (with_access_token_ttl).

pub fn revoke_refresh_tokens(
  provider: Provider,
  subject: String,
) -> Nil

Revoke every refresh token issued to subject, as an administrator ending the grant would. The next refresh answers invalid_grant.

pub fn rotate_signing_key(provider: Provider) -> Nil

Replace the signing key with a new one (new kid). The key set lists the new key first and keeps the previous one.

pub fn service_config(provider: Provider) -> config.Config

A service-client configuration for this provider (client credentials, introspection).

pub fn set_access_token_audiences(
  provider: Provider,
  audiences: List(String),
) -> Nil

aud of access tokens issued from now on (empty: the client id). Tokens issued earlier keep theirs.

pub fn set_clock_skew(
  provider: Provider,
  skew: duration.Duration,
) -> Nil

Make the provider’s clock run ahead (positive) or behind: issued tokens carry iat (and auth_time) shifted by skew.

pub fn set_email_claims(
  provider: Provider,
  subject: String,
  claims: EmailClaims,
) -> Nil

Change one subject’s assertions for future ID tokens and userinfo reads. Previously issued tokens and verified identities remain unchanged.

pub fn set_granted_scopes(
  provider: Provider,
  subject: String,
  scopes: List(String),
) -> Nil

What with_granted_scopes does, for logins from now on. An empty list grants no scope at all.

pub fn set_login(
  provider: Provider,
  decision: LoginDecision,
) -> Nil

What /authorize does from now on.

pub fn start_provider(
  options: ProviderOptions,
) -> Result(Provider, TestError)

Start a provider on a free loopback port. It is linked to the caller, so it ends with the test that started it; stop_provider ends it earlier.

pub fn stop_provider(provider: Provider) -> Nil
pub fn trust_anchor_der(provider: Provider) -> BitArray

The provider’s root certificate in DER, the form HTTP Gun’s Anchors takes, for a test client that calls the provider or the application over HTTPS without Hellhound.

pub fn trust_anchor_pem(provider: Provider) -> String

PEM text of the provider’s root certificate.

pub fn trusting(
  config: config.Config,
  provider: Provider,
) -> config.Config

Trust this provider’s root and allow loopback destinations, on any configuration (for example config.resource_server).

pub fn with_access_token_audiences(
  options: ProviderOptions,
  audiences: List(String),
) -> ProviderOptions

aud of access tokens issued by the token endpoint. Default: the client id. set_access_token_audiences changes it after start, for an audience that names an address known only once the application listens.

pub fn with_access_token_ttl(
  options: ProviderOptions,
  ttl: duration.Duration,
) -> ProviderOptions

Lifetime of access tokens issued by the token endpoint.

pub fn with_audiences(
  spec: TokenSpec,
  audiences: List(String),
) -> TokenSpec
pub fn with_client(
  options: ProviderOptions,
  client_id client_id: String,
  secret secret: String,
) -> ProviderOptions

The one registered client. It authenticates with client_secret_basic or client_secret_post.

pub fn with_email_claims(
  options: ProviderOptions,
  subject: String,
  claims: EmailClaims,
) -> ProviderOptions

Override only email claims for one subject in newly issued ID tokens and userinfo. Without an override, the provider retains subject-derived email, verified in ID tokens and no verification claim in userinfo. Reserved protocol claims, identity constructors and production trust are unchanged.

pub fn with_granted_scopes(
  options: ProviderOptions,
  subject: String,
  scopes: List(String),
) -> ProviderOptions

The scopes a login of subject is granted, in place of the scopes the client requested. The access token’s scope claim, the token response and introspection all carry exactly this list, so a test can sign in a user who lacks a scope:

testing.provider_options()
|> testing.with_granted_scopes("ada", ["openid", "approve:refund"])
|> testing.with_granted_scopes("mallory", ["openid"])

The list is granted whether or not the client requested it, and openid is not added: list it if the test needs it. A subject without an entry gets the scopes the client requested. The subject is the one that signs in, so it also applies to a login_hint.

pub fn with_issuer(spec: TokenSpec, issuer: String) -> TokenSpec

Claim another issuer.

pub fn with_login(
  options: ProviderOptions,
  decision: LoginDecision,
) -> ProviderOptions

What /authorize does; set_login changes it after start.

pub fn with_refresh_delay(
  options: ProviderOptions,
  delay: duration.Duration,
) -> ProviderOptions

Delay before answering a refresh grant, to widen refresh races.

pub fn with_scopes(
  spec: TokenSpec,
  scopes: List(String),
) -> TokenSpec
pub fn with_token_type(
  spec: TokenSpec,
  token_type: option.Option(String),
) -> TokenSpec

The header typ; None omits it.

pub fn with_ttl(
  spec: TokenSpec,
  ttl: duration.Duration,
) -> TokenSpec

Lifetime from now; a negative value mints an expired token.

✨ Search Document