Skip to main content

README.md

# Keyfob

QR **device-handoff login** for Phoenix LiveView — the "scan to sign in"
pattern you know from WhatsApp Web and Telegram.

A browser shows a QR on the login page. You point your phone's camera at it,
approve on the phone (where you're already signed in), and the browser logs
in. No password typed on the new device.

```
Desktop (login page)                    Phone (already signed in)
────────────────────                    ─────────────────────────
"Log in with QR code"
  → QR appears, waits          ── scan ──►  confirm screen:
                                            "Sign in Chrome · macOS · 1.2.3.4?"
                                            [Deny]           [Approve]
  signed in ✔  ◄─── push ───────────────    approves
```

Keyfob owns the **rendezvous** — the expiring, single-use meeting point
between the two devices. It is **auth-system-agnostic**: it never writes a
session or touches your users table. When a request is approved and consumed,
your app issues the session with its own machinery. That boundary is the whole
design — the same way a viewer library shouldn't own persistence, an
auth-handoff library shouldn't own your session.

## Why not just… do it yourself?

The easy 80% (mint a token, render a QR, PubSub a message) is a snippet. The
20% that's easy to get *wrong* is what Keyfob writes once and tests hard:

- **The secret split.** The token inside the QR is on a screen — semi-public.
  It must never become the credential. Keyfob mints a *separate* one-time login
  token at approval and delivers it only to the waiting LiveView.
- **No auto-approve.** The confirm screen is mandatory — it's the defense
  against QR-jacking (see Security).
- **Single-use under races**, short expiries, token rotation without dropping
  the waiting socket, approve-after-expire / double-scan / replay handling.

## Install

```elixir
def deps do
  [{:keyfob, "~> 0.1"}]
end
```

Configure PubSub (you already have one) and add the store to your tree:

```elixir
config :keyfob, pubsub: MyApp.PubSub

# application.ex
children = [
  Keyfob.Store.ETS,
  # ...
]
```

## Integrate (3 touch points)

### 1. Login page (desktop)

```elixir
def mount(_params, _session, socket) do
  socket =
    if connected?(socket) do
      Keyfob.Live.init_panel(socket,
        confirm_url: &url(~p"/login/qr/#{&1}"),
        meta: device_meta(socket),         # %{browser:, os:, ip:}
        on_approved: &complete/2
      )
    else
      assign(socket, :keyfob, nil)
    end

  {:ok, socket}
end

def handle_info({:keyfob, _t, _} = msg, socket),
  do: Keyfob.Live.handle_message(msg, socket, on_approved: &complete/2)

def handle_event("keyfob_refresh", _p, socket),
  do: {:noreply, Keyfob.Live.refresh(socket)}

# The login token can't be turned into a cookie from the LiveView, so hand
# it to a plain controller action that CAN set the session.
defp complete(socket, login_token),
  do: {:noreply, push_navigate(socket, to: ~p"/login/qr/complete/#{login_token}")}
```

```heex
<Keyfob.Components.panel :if={@keyfob} qr_svg={@keyfob.qr_svg} state={@keyfob.state} />
```

### 2. Confirm page (phone, authenticated route)

```elixir
def mount(%{"token" => token}, _session, socket) do
  case Keyfob.peek(token) do
    {:ok, %{state: :pending, meta: meta}} ->
      {:ok, assign(socket, token: token, meta: meta, state: :pending)}
    _ ->
      {:ok, assign(socket, state: :expired)}
  end
end

def handle_event("keyfob_approve", _p, socket) do
  # current_user comes from YOUR auth — Keyfob just needs a stable ref.
  :ok = Keyfob.approve(socket.assigns.token, socket.assigns.current_user.id)
  {:noreply, assign(socket, :state, :approved)}
end

def handle_event("keyfob_deny", _p, socket) do
  Keyfob.deny(socket.assigns.token)
  {:noreply, assign(socket, :state, :denied)}
end
```

```heex
<Keyfob.Components.confirm_screen meta={@meta} state={@state} />
```

### 3. Completion action (controller — issues the session)

```elixir
def complete(conn, %{"token" => login_token}) do
  case Keyfob.consume(login_token) do
    {:ok, user_id} ->
      user = Accounts.get_user!(user_id)
      MyAppWeb.UserAuth.log_in_user(conn, user)   # YOUR session logic
    {:error, _} ->
      conn |> put_flash(:error, "Sign-in link expired.") |> redirect(to: ~p"/login")
  end
end
```

That's it — `identify the approver`, then `mint your own session`. Keyfob does
the middle.

## Phone-side scanning

The QR encodes a plain URL, so **any phone's native camera** opens it in the
browser where the user is signed in — zero scanner code, works day one. An
in-dashboard "link a device" scanner (a JS camera lib) is an optional nicety
you add later; Keyfob doesn't need it.

## Security

Read this part.

- **QR-jacking is the threat.** An attacker opens the login page, gets *their*
  QR, and socially engineers a victim into scanning + approving it — now the
  attacker is signed in as the victim. **The only defense is the human confirm
  screen**, which Keyfob makes mandatory and cannot be turned off. Show the
  device/browser/IP and word it as "you are signing *someone else* in." Do not
  build an auto-approve path.
- **Secret split.** The request token (in the QR) can identify a request but
  never logs anyone in. `consume/2` accepts only the login token minted at
  approval, delivered solely over PubSub to the waiting process.
- **Expiry + single use.** Requests default to a 2-minute TTL, login tokens to
  60 seconds; both are single-use, stored SHA-256-hashed, and rotate — refresh
  the QR every ~60s (`Keyfob.Live.refresh/1`) so a shoulder-surfed code is
  short-lived.
- **Rate-limit** `create_request/1` per IP where you already throttle logins,
  and **audit** via the telemetry events below (`[:keyfob, :request, :approved]`
  etc.).
- Consider requiring re-auth (or step-up) on the phone before it can approve,
  and only offering QR login to accounts that opt in.

Keyfob is a mechanism, not a policy engine — these are your calls, but the
defaults are chosen to fail safe.

## Clustering

PubSub already crosses nodes. Only request *storage* is node-local in the
default `Keyfob.Store.ETS`. For multi-node, implement the `Keyfob.Store`
behaviour over shared storage (your DB, Redis) and set
`config :keyfob, store: MyApp.KeyfobStore`.

## Telemetry

`[:keyfob, :request, :created | :approved | :denied]` and
`[:keyfob, :login, :consumed]`, each with `%{meta: ...}` (and `%{user_ref: ...}`
on approve/consume).

## License

MIT.