# `Keyfob`
[🔗](https://github.com/alexdont/keyfob/blob/v0.1.0/lib/keyfob.ex#L1)

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).

# `token`

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

# `user_ref`

```elixir
@type user_ref() :: term()
```

# `approve`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

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

Subscribes the calling process to a request's topic.

# `topic`

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

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

# `unsubscribe`

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

Unsubscribes the calling process from a request's topic.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
