gaseto_http

Send, read and check PASETO tokens over HTTP, with Gaseto. It works with any server built on gleam_http, such as Wisp and Mist.

Installation

gaseto_http works alongside Gaseto, which provides the keys, claims and rules, so add both:

gleam add gaseto gaseto_http

Usage

Logging in a user

Once the user has logged in, sign a token and hand it to them with access_token_response. It answers in the form OAuth 2.0 uses (RFC 6749, section 5.1):

import gaseto
import gaseto_http
import gleam/http/response.{type Response}
import gleam/time/duration

/// Call this once the user's password has been checked. The client gets:
///
///   HTTP/1.1 200 OK
///   content-type: application/json
///   cache-control: no-store
///   pragma: no-cache
///
///   {"access_token":"v4.public.eyJ...","token_type":"Bearer","expires_in":900}
///
pub fn log_in(
  user_identifier: String,
  secret_key: gaseto.SecretKey,
) -> Response(String) {
  let lifetime = duration.minutes(15)
  let claims =
    gaseto.new_claims()
    |> gaseto.with_issuer("login.example.com")
    |> gaseto.with_audience("api.example.com")
    |> gaseto.with_subject(user_identifier)
    |> gaseto.expires_in(lifetime)
  case gaseto.sign(claims, secret_key) {
    Ok(token) -> gaseto_http.access_token_response(token, lifetime)
    Error(_) -> response.new(500) |> response.set_body("")
  }
}

Checking a request

The client sends the token back on later requests, as a bearer token (RFC 6750): Authorization: Bearer <token>. Check it with require_verified_bearer. The rest of the handler only runs for a valid token:

import gaseto
import gaseto_http
import gleam/http/request.{type Request}
import gleam/http/response.{type Response}

pub fn handle(
  request: Request(body),
  public_key: gaseto.PublicKey,
) -> Response(String) {
  // Only tokens from our login service, made for this API
  let rules =
    gaseto.new_rules()
    |> gaseto.issued_by("login.example.com")
    |> gaseto.for_audience("api.example.com")
  use _token <- gaseto_http.require_verified_bearer(
    request,
    key: public_key,
    rules:,
    realm: "api.example.com",
    error_body: "",
  )
  response.new(200) |> response.set_body("Hello, user")
}

A refused request gets the status and WWW-Authenticate header RFC 6750 describes. The reason a token was refused is not sent to the client. To read the token yourself, for example its subject, use verify_bearer, or keep the token that require_verified_bearer passes on.

Cookies

For a browser, the token can live in a cookie instead. set_token_cookie stores it, verify_cookie or decrypt_cookie read it back, and expire_token_cookie deletes it on logout:

response.new(200)
|> gaseto_http.set_token_cookie(
  named: "__Host-token",
  token:,
  max_age: duration.hours(1),
)

Checking it works the same way, except that you choose the response for a refused request, since a browser page usually redirects to a login page:

use _token <- gaseto_http.require_verified_cookie(
  request,
  named: "__Host-token",
  key: public_key,
  rules:,
  otherwise: fn(_error) { redirect_to_login() },
)

The cookie is HttpOnly, Secure and SameSite=Lax. See the OWASP Session Management Cheat Sheet for what these mean, and the OWASP Cross-Site Request Forgery Prevention Cheat Sheet for how to protect requests that change something.

Further documentation can be found at https://hexdocs.pm/gaseto_http.

Development

gleam test  # Run the tests