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.

Once a user has logged in, hand them a token with access_token_response (RFC 6749, section 5.1):

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)
let assert Ok(token) = gaseto.sign(claims, secret_key)
gaseto_http.access_token_response(token, lifetime)
// -> 200 OK, with the token in a JSON body:
// {"access_token":"v4.public.eyJ...","token_type":"Bearer","expires_in":900}

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

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")

To read the token, for example its subject, use verify_bearer, or keep the token require_verified_bearer passes on.

For a browser, the token can live in a cookie instead: see set_token_cookie and require_verified_cookie.

Types

Why a bearer token could not be used.

pub type BearerError {
  MissingToken
  MalformedAuthorization
  InvalidToken(error: error.TokenValidationError)
}

Constructors

  • MissingToken

    The request has no Authorization header with the Bearer scheme. A request that uses another scheme, such as Basic, counts as having no token.

  • MalformedAuthorization

    The Authorization header uses the Bearer scheme, but does not hold one token in the form RFC 6750 requires (RFC 6750, section 2.1), or the request has more than one Authorization header (RFC 9110, section 5.3).

  • InvalidToken(error: error.TokenValidationError)

    Gaseto refused the token.

Why a token cookie could not be used.

pub type CookieError {
  MissingCookie
  RepeatedCookie
  InvalidCookieToken(error: error.TokenValidationError)
}

Constructors

  • MissingCookie

    The request has no cookie with the given name, or it is empty, as after expire_token_cookie.

  • RepeatedCookie

    The request has more than one cookie with the given name. A page on another subdomain can add a second one, so neither is trusted. A name starting with __Host- prevents this (OWASP, Cookie Name Prefixes).

  • InvalidCookieToken(error: error.TokenValidationError)

    Gaseto refused the token.

Values

pub fn access_token_response(
  token: String,
  expires_in: duration.Duration,
) -> response.Response(String)

A response that hands a new token to the client, for example after a login, in the form OAuth 2.0 uses (RFC 6749, section 5.1). The client then sends it back as a bearer token. The body is JSON:

{"access_token":"v4.public...","token_type":"Bearer","expires_in":900}

expires_in is how long the token lasts. Pass the same duration the claims were given with gaseto.expires_in, or one hour if they kept the default. It is sent in whole seconds, rounded up, and never below 0. The response is marked so that caches do not store the token, as the RFC requires.

let assert Ok(token) = gaseto.sign(claims, secret_key)
gaseto_http.access_token_response(token, duration.minutes(15))
// With Wisp:
gaseto_http.access_token_response(token, duration.minutes(15))
|> response.map(wisp.Text)
pub fn bearer_token(
  request: request.Request(body),
) -> Result(String, BearerError)

The token from an Authorization: Bearer <token> header (RFC 6750, section 2.1). The scheme name is not case sensitive.

Most code wants verify_bearer or decrypt_bearer instead, which also check the token.

pub fn challenge(
  error: BearerError,
  realm realm: String,
) -> String

The WWW-Authenticate header that tells the client how to authenticate and what went wrong (RFC 6750, section 3). It starts with Bearer, the scheme the client should use, and the realm, a name for what is protected, such as your API. An error code follows as error="...", except for a request without a token, as the RFC asks. The reason a token was refused is not included, so it is not shared with the client.

gaseto_http.challenge(MissingToken, realm: "api.example.com")
// -> "Bearer realm=\"api.example.com\""
pub fn cookie_token(
  request: request.Request(body),
  named name: String,
) -> Result(String, CookieError)

The token from the cookie with this name.

Most code wants verify_cookie or decrypt_cookie instead, which also check the token.

pub fn decrypt_bearer(
  request: request.Request(body),
  key key: gaseto.LocalKey,
  rules rules: gaseto.Rules,
) -> Result(gaseto.Token, BearerError)

Reads the bearer token and decrypts it as a local token. See gaseto.decrypt.

pub fn decrypt_cookie(
  request: request.Request(body),
  named name: String,
  key key: gaseto.LocalKey,
  rules rules: gaseto.Rules,
) -> Result(gaseto.Token, CookieError)

Reads the token from the cookie and decrypts it as a local token. See gaseto.decrypt.

pub fn error_response(
  error: BearerError,
  realm realm: String,
  body body: body,
) -> response.Response(body)

A response for the error, with its error_status and challenge.

gaseto_http.error_response(error, realm: "api.example.com", body: "")
// With Wisp:
gaseto_http.error_response(error, realm: "api.example.com", body: wisp.Text(""))
pub fn error_status(error: BearerError) -> Int

The HTTP status for the error: 400 for a malformed header, and 401 otherwise (RFC 6750, section 3.1).

pub fn expire_token_cookie(
  response: response.Response(body),
  named name: String,
) -> response.Response(body)

Tells the browser to delete the token cookie, for example when the user logs out. A copy kept elsewhere still works until the token expires.

pub fn require_decrypted_bearer(
  request: request.Request(request_body),
  key key: gaseto.LocalKey,
  rules rules: gaseto.Rules,
  realm realm: String,
  error_body error_body: response_body,
  next next: fn(gaseto.Token) -> response.Response(response_body),
) -> response.Response(response_body)

Like require_verified_bearer, for a local token.

pub fn require_decrypted_cookie(
  request: request.Request(request_body),
  named name: String,
  key key: gaseto.LocalKey,
  rules rules: gaseto.Rules,
  otherwise otherwise: fn(CookieError) -> response.Response(
    response_body,
  ),
  next next: fn(gaseto.Token) -> response.Response(response_body),
) -> response.Response(response_body)

Like require_verified_cookie, for a local token.

pub fn require_verified_bearer(
  request: request.Request(request_body),
  key key: gaseto.PublicKey,
  rules rules: gaseto.Rules,
  realm realm: String,
  error_body error_body: response_body,
  next next: fn(gaseto.Token) -> response.Response(response_body),
) -> response.Response(response_body)

Runs next with the token when the bearer token verifies, and answers with error_response otherwise. It is made for use, so the rest of a handler only runs for a valid token:

pub fn handle(request: Request(body)) -> Response(String) {
  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")
}
pub fn require_verified_cookie(
  request: request.Request(request_body),
  named name: String,
  key key: gaseto.PublicKey,
  rules rules: gaseto.Rules,
  otherwise otherwise: fn(CookieError) -> response.Response(
    response_body,
  ),
  next next: fn(gaseto.Token) -> response.Response(response_body),
) -> response.Response(response_body)

Runs next with the token when the cookie’s token verifies, and calls otherwise with the error when it does not. A browser page often redirects to a login page there, so the response is left to you. It is made for use:

use _token <- gaseto_http.require_verified_cookie(
  request,
  named: "__Host-token",
  key: public_key,
  rules:,
  otherwise: fn(_error) { redirect_to_login() },
)
pub fn set_token_cookie(
  response: response.Response(body),
  named name: String,
  token token: String,
  max_age max_age: duration.Duration,
) -> response.Response(body)

Stores the token in a cookie for this long. The cookie is HttpOnly, so scripts cannot read it, Secure, so it is only sent over HTTPS, SameSite=Lax, and set for the whole site with Path=/ and no Domain. See the OWASP Session Management Cheat Sheet for what these mean. They also allow the __Host- name prefix, which OWASP recommends. OWASP allows SameSite=Lax and prefers Strict. Lax is used so that users who follow a link from another site arrive logged in.

The token is written as it is, so pass one Gaseto made and read it back with verify_cookie or decrypt_cookie. Wisp’s own cookie functions encode values differently. A lifetime under a second is rounded up to one second, and a negative one counts as 0, which deletes the cookie.

response.new(200)
|> gaseto_http.set_token_cookie(
  named: "__Host-token",
  token:,
  max_age: duration.hours(1),
)
pub fn verify_bearer(
  request: request.Request(body),
  key key: gaseto.PublicKey,
  rules rules: gaseto.Rules,
) -> Result(gaseto.Token, BearerError)

Reads the bearer token and verifies it as a public token. See gaseto.verify.

pub fn verify_cookie(
  request: request.Request(body),
  named name: String,
  key key: gaseto.PublicKey,
  rules rules: gaseto.Rules,
) -> Result(gaseto.Token, CookieError)

Reads the token from the cookie and verifies it as a public token. See gaseto.verify.

✨ Search Document