Keyfob (Keyfob v0.1.0)

Copy Markdown View Source

QR device-handoff login for Phoenix LiveView.

A browser that wants in shows a QR code; an already-authenticated phone scans it and approves; the waiting browser signs in. Keyfob owns the rendezvous — request lifecycle, expiry, single-use enforcement, the secret split, and the PubSub signaling. It never touches your session, users table, or cookies: your app issues the credential when Keyfob hands it an approved, consumed request.

The flow

  1. The login page calls Keyfob.Live.init_panel/2 — a pending request is minted and a QR encoding your confirm URL (with the request token) is rendered by Keyfob.Components.panel/1. The LiveView waits.
  2. The phone (logged in) opens the confirm URL. Your confirm page shows Keyfob.Components.confirm_screen/1 and, on the user's explicit Approve, calls Keyfob.approve/3 with your user's identifier.
  3. Approval mints a separate one-time login token delivered over PubSub only to the waiting LiveView, which navigates to your completion endpoint.
  4. Your endpoint calls Keyfob.consume/2{:ok, user_ref}, exactly once — and logs that user in with your own machinery.

The secret split

The request token travels inside a QR on a screen, so it is treated as semi-public: it can identify a request but never log anyone in. The login token minted at approval is the credential; it exists only in the PubSub message to the waiting process and dies on first consume/2.

Configuration

config :keyfob,
  pubsub: MyApp.PubSub,          # required (or pass pubsub: per call)
  store: Keyfob.Store.ETS        # default

Add the default store to your supervision tree:

children = [
  Keyfob.Store.ETS,
  ...
]

The ETS store is single-node. For clustered deployments implement Keyfob.Store over a shared store (database, Redis) — PubSub already crosses nodes, only the request storage needs to.

Security posture

  • Tokens are 256-bit random, stored hashed (SHA-256).
  • Requests expire (default 2 minutes) and are single-use.
  • Approval requires an explicit user_ref — there is no auto-approve, by design: the confirm screen is the defense against QR-jacking (an attacker showing a victim the attacker's login QR).
  • The login token expires fast (default 60 seconds) and consume/2 deletes it atomically.

Rate-limiting request creation (per IP) is left to the host — do it where you already rate-limit login attempts. Telemetry events are emitted for every transition (see below) so approvals are auditable.

Telemetry

  • [:keyfob, :request, :created]
  • [:keyfob, :request, :approved]
  • [:keyfob, :request, :denied]
  • [:keyfob, :login, :consumed]

Each with metadata %{meta: request_meta} (plus %{user_ref: ...} on approve/consume).

Summary

Functions

Approves a pending request on behalf of user_ref (your user's stable identifier — an id, a uuid; Keyfob treats it as opaque).

Exchanges a login token for the approved user_ref — exactly once.

Mints a pending login request.

Denies a pending request — broadcasts {:keyfob, token, :denied} to the waiting LiveView and deletes the request. Returns :ok regardless of whether the request still existed (deny is idempotent).

Looks a request up by its token without changing it — for rendering the confirm screen. Returns {:ok, %{state: state, meta: meta, expires_at_ms: ms}} or {:error, :not_found | :expired}.

Subscribes the calling process to a request's topic.

The PubSub topic for a request token. Subscribe before rendering the QR.

Unsubscribes the calling process from a request's topic.

Types

token()

@type token() :: String.t()

user_ref()

@type user_ref() :: term()

Functions

approve(token, user_ref, opts \\ [])

@spec approve(token(), user_ref(), keyword()) ::
  :ok | {:error, :not_found | :expired | :not_pending | :invalid_user_ref}

Approves a pending request on behalf of user_ref (your user's stable identifier — an id, a uuid; Keyfob treats it as opaque).

Mints the one-time login token, binds it to the request, and broadcasts {:keyfob, token, {:approved, login_token}} on the request's topic — received only by the waiting LiveView.

Options: :login_ttl_ms (default 60000), plus :store / :pubsub overrides.

Returns :ok, or {:error, :not_found | :expired | :not_pending}. user_ref must not be nil.

consume(login_token, opts \\ [])

@spec consume(
  token(),
  keyword()
) :: {:ok, user_ref()} | {:error, :not_found | :expired}

Exchanges a login token for the approved user_ref — exactly once.

Call this from the completion endpoint the waiting LiveView navigates to. On success both the login token and the underlying request are deleted, so a replayed URL gets {:error, :not_found}.

create_request(opts \\ [])

@spec create_request(keyword()) ::
  {:ok, %{token: token(), expires_in_ms: pos_integer()}}

Mints a pending login request.

Options:

  • :meta — map describing the requesting device (browser, os, ip); shown verbatim on the confirm screen. Default %{}.
  • :ttl_ms — request lifetime. Default 120000.
  • :store / :pubsub — override configuration.

Returns {:ok, %{token: token, expires_in_ms: ttl}}. The token is the only handle the caller keeps; everything at rest is hashed.

deny(token, opts \\ [])

@spec deny(
  token(),
  keyword()
) :: :ok

Denies a pending request — broadcasts {:keyfob, token, :denied} to the waiting LiveView and deletes the request. Returns :ok regardless of whether the request still existed (deny is idempotent).

peek(token, opts \\ [])

@spec peek(
  token(),
  keyword()
) :: {:ok, map()} | {:error, :not_found | :expired}

Looks a request up by its token without changing it — for rendering the confirm screen. Returns {:ok, %{state: state, meta: meta, expires_at_ms: ms}} or {:error, :not_found | :expired}.

subscribe(token, opts \\ [])

@spec subscribe(
  token(),
  keyword()
) :: :ok | {:error, term()}

Subscribes the calling process to a request's topic.

topic(token)

@spec topic(token()) :: String.t()

The PubSub topic for a request token. Subscribe before rendering the QR.

unsubscribe(token, opts \\ [])

@spec unsubscribe(
  token(),
  keyword()
) :: :ok

Unsubscribes the calling process from a request's topic.