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
-
EmailClaims( email: option.Option(String), verified: option.Option(Bool), )
A forged token, for testing that a resource server refuses it.
pub type Forgery {
UnsignedToken
HmacWithPublicKey
UnknownKey
}
Constructors
-
UnsignedTokenalg: noneand an empty signature. -
HmacWithPublicKeyHS256 keyed with the provider’s public key (algorithm confusion).
-
UnknownKeySigned 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
subjectin and redirect back to the client with a code. When the authorization request carrieslogin_hint(Hellhound sendsLoginOptions.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") orprompt=nonefinds no session ("login_required").
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
-
ProviderStartFailedThe provider’s PKI or listener did not start.
-
AuthorizationRefused(reason: String)authorizewas given a URL the provider does not accept (another client, no S256 challenge, or not this provider’s endpoint).
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 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 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 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_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_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_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.