Skip to content

fix(hooks): never turn a halted before hook into a success - #455

Open
bmalum wants to merge 1 commit into
joken-elixir:mainfrom
bmalum:fix/hook-halt-non-error
Open

bmalum wants to merge 1 commit into
joken-elixir:mainfrom
bmalum:fix/hook-halt-non-error

Conversation

@bmalum

@bmalum bmalum commented Oct 25, 2026

Copy link
Copy Markdown

A before hook that halts with anything other than an {:error, _} tuple currently makes the operation succeed. run_before_hook/3 returns the raw halt payload, then wraps everything that isn't {:error, _} as {:ok, payload}. Joken.verify/3 matches {:ok, {token, signer}} in a with without else, so {:ok, :kid_not_found} is returned as-is and Signer.verify/2 never runs:

defmodule MyToken do
  use Joken.Config
  def before_verify(_opts, _input), do: {:halt, :kid_not_found}
end

MyToken.verify("not.a.token")
#=> {:ok, :kid_not_found}

Code that checks {:ok, _} from verify/2 (channel joins, socket connects, anywhere the claims aren't needed) treats that as a verified token. @type halt_tuple :: {:halt, tuple} allows the shape, so this isn't a misuse. validate/4, generate_claims/3 and encode_and_sign/3 have the same with. run_after_hook/4 has the inverse problem: {:halt, {:error, {:missing, "aud"}}} is destructured by the when is_tuple(input) clause and comes out as the bare atom :error.

The accumulators are now tagged. A before hook halt that isn't {:error, _} becomes {:error, :wrong_hook_return}, same as the other wrong return shapes; an after hook halt is returned verbatim. Before hook specs tightened to {:halt, {:error, term}}, documented in the moduledoc, CHANGELOG entry, three regression tests. mix test, mix format --check-formatted, mix credo --strict, mix dialyzer all clean.

Prepared with AI assistance (Claude); I reproduced the bypass and verified the tests fail on main.

A before hook returning {:halt, reason} where reason is not an {:error, _}
tuple was wrapped as {:ok, reason} by Joken.Hooks.run_before_hook/3.
Joken.verify/3 (and validate/4, generate_claims/3, encode_and_sign/3) then
returned that {:ok, _} value verbatim without running signature verification
or claim validation, because the `with` had no else branch. Callers matching
on {:ok, _} would treat a hook "deny" as a verified token.

Tag the reduce_while accumulator so run_before_hook/3 only ever returns
{:ok, input} or {:error, reason}; a halt with a non-error payload is now
{:error, :wrong_hook_return}, consistent with the documented contract and
with how other wrong return shapes are already handled.

run_after_hook/4 had the inverse problem: a halt with {:error, tuple} was
destructured by the untagged `{result, input} when is_tuple(input)` clause
and surfaced as the bare atom :error. The accumulator is now explicitly
tagged and the halt payload is returned verbatim.

Tighten the before hook callback specs to {:halt, {:error, term}}, document
the behaviour and add regression tests.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant