Hellhound
OIDC login, OAuth 2.0 tokens and JWT access-token validation for Gleam on Erlang/OTP. Hellhound manages provider discovery, signing keys and token custody; your application owns its accounts, session cookies and permission checks.
This package is pre-production. Production use requires the release conditions and independent security review of Hellhound-owned code. Hellhound is not certified.
Capabilities
- OIDC authorization-code login with S256 PKCE and query or form-post callbacks.
- Checked identity and application-decoded claims, keyed by issuer and subject.
- Opaque user sessions, near-expiry refresh and shared concurrent refreshes.
- Logout with separate local-custody, revocation and provider-redirect outcomes.
- Confidential service clients for client credentials, userinfo and introspection.
- Local RFC 9068 JWT access-token validation with audience and scope policy.
- Supervised discovery and signing-key caches with restart-stable client handles.
- Optional sealed durable custody through a conditional-record store, including the separate PostgreSQL adapter.
When to use Hellhound
Use it to sign users into a Gleam web application through an existing identity provider, obtain OAuth tokens for a service, or protect an API with JWT access tokens. It does not supply an authorization server or your application’s browser session and authorization policy.
Installation
Requires Gleam 1.18 or later and the Erlang target. Add Hellhound from Hex:
gleam add hellhound
Commit gleam.toml and manifest.toml to retain the resolved versions. The
reference application includes the HTTP dependencies and route
integration used below.
The optional PostgreSQL adapter
installs from a Git subdirectory.
Sign a user in, then call an API
Register the callback address with your issuer and load the client secret from trusted configuration. This excerpt defines startup, login/callback handlers, API authorization and logout:
import gleam/http/request.{type Request}
import gleam/http/response.{type Response}
import gleam/result
import hellhound
import hellhound/config
pub fn start(secret: String) -> Result(hellhound.Client, hellhound.StartError) {
let config =
config.new(
issuer: "https://login.example.com",
client_id: "app",
redirect_uri: "https://app.example.com/auth/callback",
authentication: config.ClientSecretBasic(config.secret(secret)),
)
|> config.with_scopes(["email", "profile"])
// Validates; starts nothing.
use client <- result.try(hellhound.new(config))
// Discovery, keys, processes.
use Nil <- result.map(hellhound.start(client))
client
}
/// GET /login: Hellhound sets the browser-binding cookie and the redirect.
pub fn login(client: hellhound.Client, req: Request(a)) -> Response(String) {
case hellhound.begin_login(client, req, hellhound.default_login()) {
Ok(redirect) -> hellhound.login_response(response.new(303), redirect)
Error(error) ->
response.new(503)
|> response.set_body(hellhound.describe_login_error(error))
}
}
/// GET (or POST, for form-post) /auth/callback, with the body as a String.
pub fn callback(
client: hellhound.Client,
req: Request(String),
) -> Result(String, hellhound.Action) {
case hellhound.complete_login(client, req) {
// Keep the reference in the application's session.
Ok(session) -> Ok(hellhound.session_reference(session))
// Reauthenticate, RejectRequest, Recover, ...
Error(error) -> Error(hellhound.login_error_action(error))
}
}
/// Any later request: a current token, refreshed within 30 s of expiry;
/// concurrent requests share one refresh.
pub fn call_api(
client: hellhound.Client,
reference: String,
api_request: Request(a),
) -> Result(Request(a), hellhound.SessionError) {
use session <- result.try(hellhound.restore_session(client, reference))
use access <- result.map(hellhound.access_token(client, session))
hellhound.authorize(api_request, access.token)
}
/// POST /logout: custody removed, refresh token revoked (RFC 7009).
pub fn logout(
client: hellhound.Client,
session: hellhound.Session,
) -> Response(String) {
case hellhound.logout(client, session, hellhound.default_logout()) {
Ok(hellhound.LoggedOut(
provider_logout: hellhound.RedirectToProvider(redirect),
..,
)) -> hellhound.logout_response(response.new(303), redirect)
_ -> response.new(303) |> response.set_header("location", "/")
}
}
The complete module compiles in the reference consumer. Its common-path test exercises the equivalent login, token and logout flow against a local test provider. The application stores the session reference in its protected browser session.
How it works
flowchart LR
App["Application routes and policy"] --> Client["Hellhound client handle"]
Supervisor["Application supervisor"] --> Runtime["Hellhound provider and cache processes"]
Client --> Runtime
Runtime --> Issuer["OIDC / OAuth issuer"]
Runtime --> Store["Memory or sealed record store"]
Config describes the client role. hellhound.new allocates stable names;
hellhound.start starts a manually owned client, while hellhound.supervised installs
it under your supervisor. Background discovery can return ProviderNotReady
until metadata and keys arrive. A Session is an opaque custody reference, not
an application session or permission grant.
Use the supervision example
for a long-lived service. Stop supervised clients through their parent; manually
started clients need hellhound.stop after the application stops admitting work.
Resource servers
Use the separate resource-server role to check JWT access tokens against cached issuer keys. This example maps verification outcomes to HTTP status codes:
pub fn start(
issuer: String,
) -> Result(#(hellhound.Client, resource.Validator), hellhound.StartError) {
let assert Ok(client) = hellhound.new(config.resource_server(issuer:))
use Nil <- result.map(hellhound.start(client))
#(client, resource.new(client, audience: "https://api.example.com"))
}
/// The HTTP status a failed token earns: 401, 403 or 503. A token issued for
/// another resource is answered as a rejected one here.
pub fn status(validator: resource.Validator, bearer: String) -> Int {
case resource.verify(validator, bearer) {
Ok(_claims) -> 200
Error(error) ->
case resource.error_kind(error) {
resource.Rejected | resource.WrongAudience -> 401
resource.Forbidden -> 403
resource.Unavailable -> 503
}
}
}
Local validation does not observe immediate token
revocation. A suitably authenticated service client can instead use
hellhound.introspect to ask the provider for current token state. Resource-server
configuration alone supplies no introspection credentials.
Operational notes
- Use
(issuer, subject)as identity, not email. Protect application session cookies and apply CSRF/origin and permission checks at your routes. - Memory storage loses sessions on restart. Durable custody needs a protected 32-byte sealing key, conditional writes and rollback protection; the application owns migrations, pool lifetime, capacity and backup policy.
- Do not blindly resend a possibly consumed authorization code or refresh token.
Branch on
login_error_actionorsession_error_action; keep errors carryingRecoverevidence forrecover_custodyorrecover_refresh. - Provider traffic uses verified HTTPS and public destinations by default. Login checks issuer, audience, nonce and callback matching. Register every allowed callback address with the provider.
hellhound.stopand the login deadline do not prove that every worker or external effect has finished. Keep borrowed resources alive for the required completion boundary.
Examples and reference
- Usage modules: supervision, durable stores, callback addresses, resource servers and a local HTTPS test provider.
- Relay integration: JWT and introspection verifiers.
- PostgreSQL storage: optional adapter; the core does not depend on it.
- Design: protocol scope, defaults and unresolved requirements.
- Oracle and measurements: retained evidence with its revisions and limits, not a release certification or current performance promise.
- Development commands: local checks, provider suites and browser tests.