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

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

Examples and reference

✨ Search Document