Bier.JWT (bier v0.1.0)

Copy Markdown View Source

Minimal JWT verification for the auth pipeline.

PostgREST authenticates a request by verifying the Authorization: Bearer <token> JWT against the configured jwt-secret, then switching the database role to the token's role claim (falling back to the anonymous role). This module performs only the verification half — it never mints tokens (the conformance cases carry hardcoded tokens) — and maps each failure to the PostgREST error the cases expect:

  • no secret configured but a token is presented -> :no_secret (PGRST300, 500)
  • empty bearer token -> :empty (PGRST301, 401)
  • not exactly 3 dot-separated parts -> {:parts, n} (PGRST301, 401)
  • unreadable token structure -> :bad_crypto (PGRST301, 401)
  • unsecured token (alg: none) -> {:bad_algorithm, d} (PGRST301, 401)
  • no key verified the signature -> :jwt_invalid (PGRST301, 401)
  • payload is not a JSON object -> :claims_parse_failed (PGRST303)
  • exp more than 30s in the past -> :expired (PGRST303, 401)
  • nbf more than 30s in the future -> :not_yet_valid (PGRST303, 401)
  • iat more than 30s in the future -> :issued_at_future (PGRST303, 401)
  • non-numeric exp/nbf/iat -> {:claim_not_number, claim} (PGRST303)
  • aud not a string / array of strings -> :aud_not_string (PGRST303)
  • audience mismatch (when jwt-aud configured) -> :not_in_audience (PGRST303)

The decode-stage taxonomy

PostgREST hands the token to Haskell jose-jwt's JWT.decode, whose only three failures it re-labels are KeyError, BadAlgorithm and BadCrypto (Auth/Jwt.hs jwtDecodeError), each rendered as a distinct PGRST301 body. The three arise as follows, and this module reproduces the same split:

  • parseJwt (Jose/Internal/Parser.hs) collapses every structural failure to BadCryptofirst (const BadCrypto) $ parseOnly jwt bs. It base64url-decodes the header, JSON-decodes it into a recognized header (alg: none is the unsecured header; anything else needs a string alg), then base64url-decodes the payload and the signature. Any of those failing is :bad_crypto here, details: null in the response.
  • an unsecured header reaches decode, which — PostgREST passes no expected encoding — throws BadAlgorithm "JWT is unsecured but expected 'alg' was not 'none'". That jose message is surfaced verbatim as the response details, so it is carried in the error term.
  • otherwise the key set is filtered by what can verify the header's alg and each candidate is tried; nothing verifying is KeyError. Bier holds a single configured key, so its "wrong secret", "wrong key type" and "algorithm mismatch" outcomes are one bucket (:jwt_invalid), rendered with jose's "None of the keys was able to decode the JWT" details.

Signatures are verified through :jose. The configured secret selects the key: a JWK (a JSON object with kty, or a JWK Set) verifies asymmetric algorithms (RS/ES/PS*/EdDSA); any other secret is an HMAC oct key (HS256/384/512). Routing on the secret — not just the token's alg — keeps a public JWK from ever being used as an HMAC key (an algorithm-confusion attempt is rejected). alg: none never reaches that step: an unsecured token is rejected while the token is still being parsed, so it can never authenticate.

Returns {:ok, %{role: role | nil, claims: map, claims_json: raw_json}} where claims_json is the exact decoded payload JSON segment (re-encoded canonically) used to populate request.jwt.claims.

Verification is split into public pieces so Bier.Auth can interpose a cache: precheck/2 is the nil/empty/no-secret gate, then decode_and_verify/2 (cacheable: signature + payload decode) and validate_claims/3 (per-request: temporal + audience checks + role extraction). Bier.JwtCache caches only the expensive decode_and_verify/2 results; verify/4 recomposes all three for direct (uncached) use.

Summary

Functions

The cacheable half of verification (PostgREST parseAndDecodeClaims): splits the token, verifies the signature against secret, and decodes the payload. Returns the claims plus the canonically re-encoded payload JSON used for request.jwt.claims. Assumes a non-empty token and a present secret — callers keep the :empty/:no_secret pre-checks. Bier.JwtCache caches exactly this function's successful result, keyed by the token.

Pre-checks a bearer token ahead of the decode step: nil (no header) -> {:ok, :anonymous}, a blank token -> {:error, :empty}, no secret configured -> {:error, :no_secret}, otherwise {:ok, trimmed_token}.

The per-request half (PostgREST validateClaims + role extraction): temporal (exp/nbf/iat) and audience checks, then the role claim. Runs on every request — cache hit or not — so a cached token still starts failing once its exp passes.

Verify the bearer token from the Authorization header.

Functions

decode_and_verify(token, secret)

@spec decode_and_verify(String.t(), String.t()) ::
  {:ok, map(), String.t()} | {:error, atom() | {atom(), term()}}

The cacheable half of verification (PostgREST parseAndDecodeClaims): splits the token, verifies the signature against secret, and decodes the payload. Returns the claims plus the canonically re-encoded payload JSON used for request.jwt.claims. Assumes a non-empty token and a present secret — callers keep the :empty/:no_secret pre-checks. Bier.JwtCache caches exactly this function's successful result, keyed by the token.

precheck(token, secret)

@spec precheck(String.t() | nil, String.t() | nil) ::
  {:ok, :anonymous} | {:ok, String.t()} | {:error, :empty | :no_secret}

Pre-checks a bearer token ahead of the decode step: nil (no header) -> {:ok, :anonymous}, a blank token -> {:error, :empty}, no secret configured -> {:error, :no_secret}, otherwise {:ok, trimmed_token}.

Shared by verify/4 and Bier.Auth, which interposes Bier.JwtCache between this check and decode_and_verify/2 rather than calling verify_token/4 directly — keeping the check in one place means the two callers can't drift apart on it.

validate_claims(claims, aud, role_claim_path)

@spec validate_claims(map(), String.t() | nil, Bier.JWT.RoleClaim.path()) ::
  {:ok, String.t() | nil} | {:error, atom() | {atom(), term()}}

The per-request half (PostgREST validateClaims + role extraction): temporal (exp/nbf/iat) and audience checks, then the role claim. Runs on every request — cache hit or not — so a cached token still starts failing once its exp passes.

verify(token, secret, aud, role_claim_path \\ [{:name, :dot, "role"}])

@spec verify(
  String.t() | nil,
  String.t() | nil,
  String.t() | nil,
  Bier.JWT.RoleClaim.path()
) ::
  {:ok, :anonymous}
  | {:ok, %{role: String.t() | nil, claims: map(), claims_json: String.t()}}
  | {:error, atom() | {atom(), term()}}

Verify the bearer token from the Authorization header.

  • nil token (no header) -> {:ok, :anonymous}
  • a present token, no secret -> {:error, :no_secret}
  • a present, valid token -> {:ok, %{role:, claims:, claims_json:}}
  • a present, invalid token -> {:error, reason}

role_claim_path is the parsed jwt-role-claim-key JSON Path (Bier.JWT.RoleClaim) locating the role inside the claims; it defaults to PostgREST's $.role.