Skip to main content

guides/authentication.md

# Authentication

Dialup does not ship a full user database. Instead it provides **Dialup-native HTTP auth**
(`Dialup.Auth`, `Dialup.Auth.Plug`) and a generator that scaffolds Ecto models in your app,
following the token design from Phoenix's `phx_gen_auth`.

## Two session concepts

| Concept | Cookie / ID | Purpose |
|---------|-------------|---------|
| **Connection session** | `dialup_session` | Identifies the live `UserSessionProcess` (one browser tab) |
| **Auth session** | `_dialup_user_token` | Opaque token resolved to `current_user` via your Accounts context |

These are intentionally separate. A visitor can have a live Dialup connection without being
logged in. After login, `current_user` is injected into layout session before `layout.mount/1`
runs.

## Quick start

1. Generate scaffolding:

```bash
mix dialup.gen.auth Accounts User users
```

2. Wire your Application module:

```elixir
use Dialup,
  app_dir: __DIR__ <> "/app",
  auth_accounts: MyApp.Accounts,
  plugs: [{Dialup.Auth.Plug, accounts: MyApp.Accounts}]
```

3. Run migrations and configure `MyApp.Repo`.

4. Use generated pages under `lib/app/log_in/`, `register/`, `settings/`, and
   `reset_password/`.

## HTTP endpoints

All state-changing auth routes require a CSRF token (double-submit cookie `_dialup_csrf`).

| Method | Path | Description |
|--------|------|-------------|
| POST | `/log_in` | Email + password → auth cookie + rotated `dialup_session` |
| POST | `/log_out` | Clears auth cookie + rotates connection session |
| POST | `/register` | Creates user and logs in |
| POST | `/reset_password/request` | Sends reset email (if configured) |
| POST | `/reset_password/confirm` | Sets new password from token |

Login and logout forms are plain HTML `<form method="post">` on auth pages. Auth pages
receive `@csrf_token` on **every** render, including WebSocket-driven remounts after `__init`
(see [CSRF on WebSocket remounts](#csrf-on-websocket-remounts) below).

## Layout session and `current_user`

On WebSocket connect and HTTP GET, Dialup resolves the auth user from cookies and seeds
layout session:

```elixir
# In UserSessionProcess / Router
initial_session = Dialup.Auth.initial_session(user)
# => %{current_user: user} or %{}
```

Your root `layout.mount/1` receives this session map. Pages can read `session[:current_user]`
in `mount/2` and gate redirects:

```elixir
def mount(_params, session) do
  if session[:current_user], do: {:ok, session}, else: {:redirect, "/log_in"}
end
```

## MCP / agent integration

Unauthenticated agent sessions receive **minimal capabilities** via `Dialup.Auth.Grants`:

```elixir
def agent_grant(assigns) do
  Dialup.Auth.Grants.for_user(assigns, capabilities: [:add_item])
end
```

- **Guest** (no `current_user`, with `auth_accounts` configured): `[:read_scene, :issue_browser_url]`
- **Logged in**: full capabilities (or your custom list)
- **No auth configured**: pages keep the legacy full-capability default

Project `current_user` safely in `agent_state/1`:

```elixir
def agent_state(assigns) do
  %{user: Dialup.Auth.public_user(assigns[:current_user])}
end
```

### Agent-first login flow

When an agent starts before a human is logged in:

1. Agent receives a grant with minimal capabilities.
2. Agent calls `issue_browser_url` (or browser handoff) to invite a human.
3. Human opens the link, logs in via `/log_in`.
4. WebSocket reconnects with the auth cookie; `current_user` is restored and grants are
   re-evaluated on the **live** session.
5. **Re-handoff required:** MCP tokens issued while guest carry `auth_fingerprint=guest` and
   cannot gain logged-in capabilities. Call `POST /_dialup/agent-handoff` again after login.

See [Sequence diagrams](#sequence-diagrams) for Mermaid flows.

When the auth cookie is cleared or expires while `dialup_session` remains, the next WebSocket
`__init` / `__reconnect` passes `auth_user: nil` explicitly so the live session downgrades to
guest state and previously issued MCP tokens that exceed guest capabilities are rejected.

`GET /.well-known/dialup-agent?path=...` seeds the same auth cookies as HTTP GET before resolving
mount redirects, so discovery for protected pages matches the authenticated HTML shell.

Guest MCP tokens carry an **`auth_fingerprint`** captured at issuance time. After the human logs
in, the live session upgrades but **old guest tokens do not gain elevated capabilities** — the
agent must obtain a fresh grant via `POST /_dialup/agent-handoff` (or a new agent session).

## Sequence diagrams

The flows below match the shipped implementation (`Dialup.Auth.Plug`, `UserSessionProcess`,
`Dialup.Auth.Grants`). They are the canonical reference for login, agent-first handoff, and
logout sync.

### Human login (browser-first)

A visitor loads an auth page over HTTP, submits credentials, then the WebSocket shell remounts
with `current_user` in layout session.

```mermaid
sequenceDiagram
  participant Browser
  participant Plug as Dialup.Auth.Plug
  participant Accounts
  participant USP as UserSessionProcess

  Browser->>Plug: GET /log_in
  Plug->>Browser: HTML + @csrf_token + Set-Cookie _dialup_csrf

  Browser->>Plug: POST /log_in (email, password, csrf_token)
  Plug->>Plug: valid_csrf?
  Plug->>Accounts: authenticate_user/2
  Accounts-->>Plug: {:ok, user}
  Plug->>Accounts: generate_user_session_token(user)
  Note over Plug,USP: sync_connection_sessions/2 on pre-rotate dialup_session id
  Plug->>USP: broadcast_auth_user(user, auth_session_token)
  Plug->>Plug: rotate dialup_session cookie
  Plug->>USP: rotate_connection_session_id/2
  Plug->>Browser: Set-Cookie _dialup_user_token, dialup_session

  Browser->>USP: WS /ws upgrade (both cookies)
  USP->>Accounts: get_user_by_session_token (ensure_live_auth)
  USP->>USP: mount_session(initial_session with current_user)
  USP->>Browser: authenticated HTML + @csrf_token on remount
```

### Agent-first login (invite human, then re-handoff)

An agent starts on a guest grant, invites a human, the human logs in in the browser, and the
agent requests a **new** grant for elevated capabilities.

```mermaid
sequenceDiagram
  participant Agent
  participant USP as UserSessionProcess
  participant Human as Human browser
  participant Plug as Dialup.Auth.Plug
  participant Accounts

  Agent->>USP: POST /_dialup/agent-session (or handoff)
  USP-->>Agent: guest grant (read_scene, issue_browser_url) + auth_fingerprint=guest

  Agent->>USP: tools/call issue_browser_url
  USP-->>Agent: browserUrl (?_join=token)

  Human->>USP: WS join + finalize-join (guest session)
  Human->>Plug: POST /log_in (credentials + csrf)
  Plug->>Accounts: authenticate + generate_user_session_token
  Plug->>USP: broadcast_auth_user(user, token) + cookie rotation
  USP->>USP: sync_auth_user → remount with current_user

  Note over Agent,USP: Old guest MCP token: auth_fingerprint mismatch or subset check
  Agent->>USP: tools/call with old token (elevated capability)
  USP-->>Agent: grant_expired (-32002)

  Human->>USP: POST /_dialup/agent-handoff (authenticated cookies)
  USP-->>Agent: new grant with logged-in capabilities + auth_fingerprint=authed
```

### Logout and live-session sync

Logout clears the auth cookie, notifies live processes on the **pre-rotate** connection id,
revokes stored session tokens, and rotates `dialup_session`.

```mermaid
sequenceDiagram
  participant Browser
  participant Plug as Dialup.Auth.Plug
  participant Accounts
  participant USP as UserSessionProcess
  participant Agent

  Browser->>Plug: POST /log_out (csrf)
  Plug->>USP: broadcast_auth_user(nil, nil) on pre-rotate dialup_session
  Plug->>Accounts: delete_session_token (cookie + live auth_session_token)
  Plug->>Plug: rotate dialup_session cookie
  Plug->>USP: rotate_connection_session_id/2
  Plug->>Browser: clear _dialup_user_token

  USP->>USP: sync_auth_user → guest remount
  Agent->>USP: tools/call with pre-logout MCP token
  USP-->>Agent: grant_expired (capabilities exceed guest grant)
```

## Security notes

- Auth tokens are **opaque**; only SHA-256 hashes are stored in the database.
- Login and logout **rotate** `dialup_session` (session fixation mitigation).
- Password reset and email confirmation tokens have short TTLs (see
  `Dialup.Auth.SessionToken`).
- Operations marked `confirm: :human` remain unavailable over HTTP MCP regardless of login
  state.
- Login redirects use `Dialup.Auth.safe_redirect_path/1` — only same-origin relative paths
  (`/foo`) are allowed; absolute URLs, protocol-relative URLs (`//host`), and backslashes are
  rejected (defaults to `/`).
- MCP grants are **re-validated at use time**: capabilities and projections must stay within
  what `agent_grant/1` would allow for the current session (`state.auth_user`, not stale
  layout session alone).
- **Login/logout HTTP** notifies live `UserSessionProcess` instances sharing the same
  `dialup_session` via `broadcast_auth_user/2`, so MCP grants downgrade even without a
  WebSocket reconnect in the same tab.

### CSRF on WebSocket remounts

The CSRF cookie (`_dialup_csrf`) is httpOnly, so forms cannot read it from JavaScript. Dialup
therefore injects `@csrf_token` into server-side assigns:

1. **HTTP GET** — `Dialup.Auth.csrf_token/1` sets or reads the cookie and puts the token in
   initial assigns.
2. **WebSocket upgrade** — the same helper runs on `/ws`; the token is stored in
   `UserSessionProcess` and merged into every render via `merge_for_render/1`.

Without step 2, the client `__init` morph would replace the HTTP shell and drop `@csrf_token`,
causing auth POSTs to fail CSRF validation.

## Review findings and remediations

The initial auth foundation PR was reviewed with Bugbot and a security pass. Six findings
(three overlapping pairs) were addressed as follows.

| Review | Severity | Finding | Remediation | Tests |
|--------|----------|---------|-------------|-------|
| Bugbot | High | WS remount drops `@csrf_token`; auth forms fail CSRF after `__init` | Propagate `csrf_token` from `/ws` upgrade through WebSocket → `UserSessionProcess` → `merge_for_render/1` | `test/auth_session_bridge_test.exs` |
| Bugbot | High | `auth_user: nil` not forwarded on reconnect; stale `current_user` after cookie loss | `auth_opts/1` always passes `auth_user:` (including `nil`); `apply_auth_opts/2` uses `Keyword.fetch/2` | `test/auth_session_bridge_test.exs` |
| Bugbot | Medium | Unvalidated `return_to` on login → open redirect | `Dialup.Auth.safe_redirect_path/1` in `Dialup.Auth.Plug` | `test/auth_plug_test.exs`, `test/auth_integration_test.exs` |
| Security | Medium | Open redirect via `return_to` | Same as above | Same as above |
| Security | Medium | WebSocket reconnect retains auth after cookie cleared | Same as Bugbot auth_user propagation; remount on auth change | `test/auth_session_bridge_test.exs` |
| Security | Medium | MCP bearer tokens keep elevated capabilities after logout | `fetch_grant/2` checks grant capabilities ⊆ current `agent_grant/1`; excess → `:grant_expired` | `test/auth_session_bridge_test.exs` |

### Second review (post-remediation)

| Review | Severity | Finding | Remediation | Tests |
|--------|----------|---------|-------------|-------|
| Bugbot | High | Atom/string capability mismatch in `fetch_grant/2` falsely expired guest grants | Normalize capability and projection names to strings before subset checks | `test/auth_session_bridge_test.exs` (guest `read_scene`) |
| Bugbot | Medium | ETS-restored `session[:current_user]` survived when `auth_user` was nil | `auth_identity_stale?/1` forces remount; `merge_for_render/1` syncs `current_user` from `auth_user` | `test/auth_session_bridge_test.exs` (stale session) |
| Security | Medium | Logout in another tab left MCP tokens elevated until WS reconnect | `sync_connection_sessions/2` on login/logout → `broadcast_auth_user/2` → `sync_auth_user/1` | `test/auth_session_bridge_test.exs` (sync without reconnect) |
| Security | Medium | Grant re-validation ignored projections (`:regions` leaked after logout) | `projections_subset?/2` in `fetch_grant/2`; settings template scopes projections per auth state | generator template + grant tests |

### Third review (post-remediation)

These findings were **not** a review oscillation: each round targeted a different layer
(core bridge → grant normalization → session broadcast completion → generator templates).
Round 2 introduced `broadcast_auth_user/2`; round 3 completed it when cookie rotation left
live processes on a stale `session_id`.

| Review | Severity | Finding | Remediation | Tests |
|--------|----------|---------|-------------|-------|
| Bugbot | High | Login/logout broadcast keyed to post-rotate cookie; live processes kept pre-rotate `session_id` | `sync_connection_sessions/2` notifies pre-rotate id, rotates cookie, then `rotate_connection_session_id/2` updates processes (+ ETS rename) | `test/auth_session_bridge_test.exs` (post-login logout sync) |
| Security | Medium | Reset password token reusable until TTL | Generated `Accounts.reset_user_password/2` deletes `reset_password` tokens; new requests delete old reset tokens | generator template |
| Security | Medium | Stolen session token survives re-login | Generated `generate_user_session_token/1` deletes existing `"session"` tokens before insert | generator template |
| Security | Medium | Default Notifier logs reset URL to stdout | Notifier logs email only; guides require Swoosh (or similar) before production | generator template |

### Fourth review (post-remediation)

Still **not** oscillation: rounds 1–3 scoped auth sync to the same browser (`dialup_session`
broadcast + cookie rotation). Round 4 adds **cross-device** re-validation against the accounts
store without undoing those paths.

| Review | Severity | Finding | Remediation | Tests |
|--------|----------|---------|-------------|-------|
| Bugbot | High | Stale `auth_user` after DB session-token invalidation on another device | Store `auth_session_token` on `UserSessionProcess`; `ensure_live_auth/1` re-resolves via `get_user_by_session_token/1` before MCP grant checks | `test/auth_session_bridge_test.exs` (revoked token) |
| Bugbot | Medium | WebSocket upgrade ignored `dialup_current_user` from plugs | `Dialup.Auth.resolve_conn_user/2` shared by HTTP and `/ws` | `test/auth_plug_test.exs` |
| Security | Medium | Cross-device logout/re-login left elevated MCP bearer valid | Same token re-validation in `fetch_grant/2`; HTTP auth sync clears stored token when only `auth_user` is broadcast | same as above |

### Fifth review (post-remediation)

Still **not** oscillation: round 4 added cross-device DB re-validation; round 5 closes the
HTTP login broadcast gap (token propagation) and hardens redirect validation without removing
prior sync paths.

| Review | Severity | Finding | Remediation | Tests |
|--------|----------|---------|-------------|-------|
| Bugbot | High | HTTP login broadcast cleared `auth_session_token`, bypassing cross-device revalidation | `log_in_user/3` passes new token through `sync_connection_sessions/2` → `broadcast_auth_user/3` | `test/auth_session_bridge_test.exs` (login broadcast + revoke) |
| Bugbot | Medium | Percent-encoded `//` bypassed `safe_redirect_path/1` | Decode before `local_path?/1`; reject both raw and decoded unsafe paths | `test/auth_plug_test.exs`, `test/auth_integration_test.exs` |
| Security | Medium | Same login-sync token gap as Bugbot High | Same as above | same as above |
| Security | Medium | CRLF in `return_to` could inject Location headers | Reject `\r`, `\n`, `\0` in `local_path?/1` | `test/auth_plug_test.exs` |

### Sixth review (post-remediation)

Still **not** oscillation: redirect checks were deepened (multi-decode); browser join auth
binding is a separate agent-handoff path, not a revert of login sync or token revalidation.

| Review | Severity | Finding | Remediation | Tests |
|--------|----------|---------|-------------|-------|
| Bugbot | Medium | Double-encoded `%252f` bypassed single `URI.decode/1` | `fully_decode/1` (bounded) before `local_path?/1` | `test/auth_integration_test.exs` |
| Security | Medium | Browser join sent authenticated HTML before joiner auth applied | Pass joiner auth into `browser_join_with_token/5`; `apply_auth_sync/3` before first render | `test/auth_session_bridge_test.exs` |

### Seventh review (post-remediation)

Still **not** oscillation: human WebSocket paths now share the same live auth check as MCP;
bcrypt fallback is scaffold hardening, unrelated to prior session-sync rounds.

| Review | Severity | Finding | Remediation | Tests |
|--------|----------|---------|-------------|-------|
| Bugbot | High | Revoked tokens did not revalidate on WS navigate/event | `ensure_live_auth/1` in `do_navigate/2`, `handle_cast` event/reconnect | `test/auth_session_bridge_test.exs` (navigate after revoke) |
| Security | Medium | Weak SHA-256 password fallback outside `:test` | `Dialup.Auth.Hash.hash_password/1` raises without `bcrypt_elixir` in non-test envs | existing auth tests |

### Eighth review (post-remediation)

Still **not** oscillation: token preservation fixes a regression in round 7's `ensure_live_auth/1`;
grant nil-page and reset-session fixes close adjacent gaps without undoing prior rounds.

| Review | Severity | Finding | Remediation | Tests |
|--------|----------|---------|-------------|-------|
| Bugbot | High | `ensure_live_auth/1` dropped `auth_session_token` on user re-resolve | Preserve token in sync opts; compare users by id | existing bridge tests |
| Bugbot | Medium | `/register` crashed when `register_user/1` optional | Guard with `function_exported?/3` | `test/auth_plug_test.exs` |
| Security | Medium | MCP grant skipped revalidation when `current_page == nil` | Deny when `auth_accounts` configured | `test/auth_session_bridge_test.exs` |
| Security | Medium | Password reset left session tokens valid | Delete `"session"` tokens in generated `reset_user_password/2` | generator template |

### Ninth review (post-remediation)

Still **not** oscillation: strict token requirement tightens round 4 revalidation; redirect and
optional-callback guards extend prior hardening without reverting login sync or MCP checks.

| Review | Severity | Finding | Remediation | Tests |
|--------|----------|---------|-------------|-------|
| Bugbot | Medium | `/reset_password/confirm` crashed without optional callbacks | `function_exported?/3` guards | plug error path |
| Bugbot | Medium | Nil `auth_session_token` skipped DB revalidation | Require token when `auth_accounts` configured | `authed_opts/1` in bridge tests |
| Security | Medium | Encoded `..` in `return_to` | Reject `..` path segments after decode | `test/auth_integration_test.exs` |

### Tenth review (post-remediation)

| Review | Severity | Finding | Remediation | Tests |
|--------|----------|---------|-------------|-------|
| Bugbot | Medium | `__init` skipped `ensure_live_auth/1` | Run after `apply_auth_opts` in init cast | bridge tests |
| Bugbot | Medium | Missing login params raised in accounts | Return `:invalid_credentials` when fields absent | `test/auth_plug_test.exs` |

### Eleventh review (post-remediation)

Still **not** oscillation: plug param guards and redirect sanitization extend round 5/10;
`auth_user_current?/2` deepens round 8 token preservation without removing strict token checks.

| Review | Severity | Finding | Remediation | Tests |
|--------|----------|---------|-------------|-------|
| Bugbot | Medium | Reset routes crashed on missing email/token params | Binary guards before Accounts callbacks | `test/auth_plug_test.exs` |
| Bugbot | Medium | `require_authenticated_user/2` skipped `safe_redirect_path/1` | Sanitize `login_path` header | `test/auth_integration_test.exs` |
| Security | Medium | DB role changes not reflected when user id unchanged | Compare `public_user/1` in `auth_user_current?/2` | `test/auth_session_bridge_test.exs` |

### Twelfth review (post-remediation)

Still **not** oscillation: navigate remount aligns with reconnect (round 6/10);
grant `auth_fingerprint` binds MCP tokens to issuance-time identity without removing subset checks.

| Review | Severity | Finding | Remediation | Tests |
|--------|----------|---------|-------------|-------|
| Bugbot | High | Navigate skipped stale session remount when auth already nil | Use `mount_session/3` when `auth_identity_stale?/1` | `test/auth_session_bridge_test.exs` |
| Security | Medium | Guest MCP token read authenticated state after auth upgrade | Store `auth_fingerprint` on grants; reject mismatch in `fetch_grant/2` | `test/auth_session_bridge_test.exs` |

### Thirteenth review (post-remediation)

Still **not** oscillation: mount redirect support completes the gen.auth settings template contract
without changing the template's `{:redirect, "/log_in"}` guard pattern.

| Review | Severity | Finding | Remediation | Tests |
|--------|----------|---------|-------------|-------|
| Bugbot | High | `mount/2` `{:redirect, path}` unsupported; settings page 500 for guests | Handle mount redirect in `Router` + `UserSessionProcess` + HTTP GET | `test/mount_redirect_test.exs` |

### Fourteenth review (Bugbot only, post-remediation)

Still **not** oscillation: logout revocation, mount error handling, and redirect depth limits
extend round 13 mount redirect work without removing redirect support.

| Review | Severity | Finding | Remediation | Tests |
|--------|----------|---------|-------------|-------|
| Bugbot | Medium | Logout skipped DB token when auth cookie absent | Revoke live `auth_session_token` from session processes | `test/auth_logout_test.exs` |
| Bugbot | Medium | Invalid mount return caused CaseClauseError | Return `{:error, :invalid_mount}` and handle in HTTP/WS | `test/mount_redirect_test.exs` |
| Bugbot | Medium | Discovery redirect follow unbounded | `Router.mount_assigns_resolved/3` with depth limit | `test/mount_redirect_test.exs` |

### Fifteenth review (Bugbot only, post-remediation)

Still **not** oscillation: discovery error mapping completes round 14 mount-resolution plumbing.

| Review | Severity | Finding | Remediation | Tests |
|--------|----------|---------|-------------|-------|
| Bugbot | Medium | `/.well-known/dialup-agent` unhandled mount resolution errors | Map `:redirect_loop` / `:invalid_mount` to JSON errors | `test/mount_redirect_test.exs` |

### Sixteenth review (Bugbot only, post-remediation)

Still **not** oscillation: discovery auth seeding aligns well-known resolution with HTTP GET mount context.

| Review | Severity | Finding | Remediation | Tests |
|--------|----------|---------|-------------|-------|
| Bugbot | Medium | Discovery ignored auth cookies on protected mounts | Seed `initial_session` from `resolve_conn_user/2` | `test/mount_redirect_test.exs` |

**Design implications for app authors**

- Do not rely on HTTP-only CSRF injection; auth forms on WS-driven pages need no extra work
  as long as they use `@csrf_token` from assigns.
- Logout via HTTP POST notifies live tabs on the **pre-rotate** `dialup_session`, then
  rotates the cookie and updates each `UserSessionProcess` to the new id.
- Live MCP access re-validates the stored auth session token against your Accounts module;
  invalidation on another device downgrades grants without waiting for a WebSocket reconnect.
- Replace the generated Notifier before production; never log password-reset URLs or tokens.
- Enable email confirmation or disable open registration before production; the generator logs users in immediately after register.
- When narrowing `agent_grant/1` capabilities for logged-in users, also set `projections:`
  explicitly if regions or other slices should not appear to agents.
- Re-issue MCP handoff tokens after login if an agent needs elevated capabilities; old guest
  tokens remain valid within guest bounds but will not gain `:all` without a fresh grant.
- Use `mount/2` `{:redirect, path}` for page-level auth guards; HTTP GET, WebSocket navigate,
  and agent discovery all honor the redirect before render.
- Logout revokes DB session tokens from the auth cookie **and** from live `UserSessionProcess`
  instances sharing the connection session id.

## Optional dependencies

The generator adds `ecto_sql`, `bcrypt_elixir`, and `swoosh` to **your app**, not to the
`:dialup` Hex package. Dialup core uses `Dialup.Auth.Hash` with a test fallback when bcrypt
is absent.

## Testing

See `test/auth_*` in the Dialup repo for plug, session token, grant integration, and security
regression tests. In your app, test:

- Cookie issue / validation / deletion
- CSRF rejection on auth POST
- `@csrf_token` present after WebSocket `__init` remount on auth pages
- `current_user` present after login in layout session
- Guest vs authenticated MCP grants
- Auth downgrade clears `current_user` and invalidates elevated MCP tokens
- Logout sync works after login cookie rotation (pre/post rotate session id)
- Revoked auth session tokens invalidate elevated MCP without reconnect
- Guest MCP grants with atom capabilities remain valid after normalization
- Stale ETS/session `current_user` is cleared on reconnect
- Unsafe `return_to` values fall back to `/`