nebula_token (Elixir)

Elixir implementation of NEBULA — opaque rotating refresh tokens (RFC 9700 model). :crypto + stdlib only, no runtime dependencies.

Requires Elixir ≥ 1.18 and Erlang/OTP ≥ 25. The constant-time comparison the specification mandates ([N-31]) is :crypto.hash_equals/2, added in OTP 25, and it backs both the verifier proof and the sender binding. On an older OTP issue/3 would work while every refresh/3 raised UndefinedFunctionError, so mix.exs refuses to build there rather than letting the defect reach production.

{:nebula_token, "~> 1.0"}

Usage

{:ok, store} = NebulaToken.MemoryStore.start_link() # dev/test only — see "Stores"
engine =
NebulaToken.Engine.new(
peppers: %{"k1" => System.fetch_env!("NEBULA_PEPPER_K1")},
active_kid: "k1",
store_mod: NebulaToken.MemoryStore,
store: store,
reuse_grace_seconds: 0
)
# Login
issued = NebulaToken.Engine.issue(engine, "usr_1", device_id)
# => %NebulaToken.TokenResult{token: ..., user_id: ..., family_id: ..., generation: 0,
# expires_at: <unix seconds>, idle_expires_at: <unix seconds>}
# inspect/1 on it hides the token ([N-14]); issued.token still returns it.
# Being a struct is what makes that redaction possible, and it means the value
# is not Jason-encodable: use Map.from_struct(issued) before json(conn, ...),
# or build the response body field by field.
# Refresh
case NebulaToken.Engine.refresh(engine, presented_token, device_id) do
{:ok, result} ->
# the presented token is now dead — persist result.token
{:ok, result.token}
{:error, %NebulaToken.Failure{code: code} = failure}
when code in [:REUSE_DETECTED, :DEVICE_MISMATCH] ->
# security event: the family is already revoked. failure.user_id and
# failure.family_id identify the session without a second lookup ([N-39]).
alert(failure)
{:error, %NebulaToken.Failure{code: :CONFLICT}} ->
:retry_once # a concurrent refresh won; nothing was rotated
{:error, %NebulaToken.Failure{}} ->
:require_login # keep a catch-all: the code set is open ([N-40])
end
# Logout / incident response
NebulaToken.Engine.revoke_token(engine, token) # authenticated, by token
NebulaToken.Engine.revoke_family(engine, family_id) # administrative
NebulaToken.Engine.revoke_all_for_user(engine, user_id) # administrative

revoke_token/2 proves the verifier before revoking anything ([N-36]): the selector is a public lookup key, so accepting one alone would be an unauthenticated denial of service. It takes no device identifier and performs no sender-binding check. It returns {:ok, %{user_id: ..., family_id: ..., revoked: n}} or {:error, %NebulaToken.Failure{}}; the two administrative functions take a server-side identifier and return the count n directly.

Stores — :store_mod is not optional in production

The engine talks to storage through two options that travel together:

OptionMeaning
:store_modThe module implementing the NebulaToken.Store behaviour. Defaults to NebulaToken.MemoryStore.
:storeThe handle passed back to that module as its first argument — an Agent pid, a Postgrex pool name, an Ecto.Repo, a tuple of those.

Omitting :store_mod does not fail: it silently routes every call to the in-memory store, which loses every session on restart and cannot detect reuse across nodes. Production configurations must set it.

engine =
NebulaToken.Engine.new(
peppers: %{"k1" => pepper},
active_kid: "k1",
store_mod: MyApp.RefreshTokenStore,
store: MyApp.Repo
)

The contract is six callbacks ([N-16]); a ready-made SQL template is in examples/postgrex_store.ex and the schema in docs/STORE.md.

find_by_selector(store, selector) :: {:ok, Record.t() | nil} | {:error, term}
insert(store, record) :: :ok | {:error, term}
mark_rotated(store, selector, from_status, rotated_at, replaced_by) :: {:ok, boolean} | {:error, term}
revoke_if_active(store, selector) :: {:ok, boolean} | {:error, term}
revoke_family(store, family_id) :: {:ok, non_neg_integer} | {:error, term}
revoke_user(store, user_id) :: {:ok, non_neg_integer} | {:error, term}

mark_rotated/5 and revoke_if_active/2 are compare-and-set ([N-17], [N-18]): they must apply their write only if the current status still matches, and report whether it applied. Returning {:ok, true} unconditionally re-opens the race in which two concurrent refreshes each mint a successor and fork the family.

Two failure channels ([N-20])

Invalid configuration and a device identifier that is not valid UTF-8 at issue/3 raise ArgumentError ([N-12]).

Tests

mix deps.get && mix test

The suite is three files and nothing is hand-derived from the prose:

Both vector files are resolved by walking up to the repository root, never copied into the package.

runtime-admits-invalid-unicode-strings is not satisfied here — String.t() on the BEAM is a UTF-8 binary and cannot hold an unpaired surrogate — so device-05 is reported as skipped. The [N-12] requirement behind it is still proven in engine_test.exs, against the WTF-8 byte sequence <<0xED, 0xA0, 0x80>>.

Agent skill:skills/nebula-token-elixir/SKILL.md teaches an AI coding assistant to integrate this package correctly. Install it with the standard agent-skill installer:

npx skills add nebula-token/nebula-token --skill nebula-token-elixir

That lands in the project's .claude/skills/, checked in and shared with your team; add -g for your personal ~/.claude/skills/ instead. In Claude Code it is equally a plugin — /plugin marketplace add nebula-token/nebula-token, then /plugin install nebula-token-elixir@nebula-token. Neither route copies anything: both read this directory in place, through .claude-plugin/marketplace.json.

As a fallback the skill also ships inside the published artefact, in the same installable shape — after mix deps.get the directory is at deps/nebula_token/skills/nebula-token-elixir/, and copying that directory into ~/.claude/skills/ is the whole installation. All ten skills and every install route are in skills/README.md.