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
- Generate scaffolding:
mix dialup.gen.auth Accounts User users
- Wire your Application module:
use Dialup,
app_dir: __DIR__ <> "/app",
auth_accounts: MyApp.Accounts,
plugs: [{Dialup.Auth.Plug, accounts: MyApp.Accounts}]Run migrations and configure
MyApp.Repo.Use generated pages under
lib/app/log_in/,register/,settings/, andreset_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 below).
Layout session and current_user
On WebSocket connect and HTTP GET, Dialup resolves the auth user from cookies and seeds layout session:
# 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:
def mount(_params, session) do
if session[:current_user], do: {:ok, session}, else: {:redirect, "/log_in"}
endMCP / agent integration
Unauthenticated agent sessions receive minimal capabilities via Dialup.Auth.Grants:
def agent_grant(assigns) do
Dialup.Auth.Grants.for_user(assigns, capabilities: [:add_item])
end- Guest (no
current_user, withauth_accountsconfigured):[: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:
def agent_state(assigns) do
%{user: Dialup.Auth.public_user(assigns[:current_user])}
endAgent-first login flow
When an agent starts before a human is logged in:
- Agent receives a grant with minimal capabilities.
- Agent calls
issue_browser_url(or browser handoff) to invite a human. - Human opens the link, logs in via
/log_in. - WebSocket reconnects with the auth cookie;
current_useris restored and grants are re-evaluated on the live session. - Re-handoff required: MCP tokens issued while guest carry
auth_fingerprint=guestand cannot gain logged-in capabilities. CallPOST /_dialup/agent-handoffagain after login.
See 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.
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 remountAgent-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.
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=authedLogout 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.
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: :humanremain 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/1would allow for the current session (state.auth_user, not stale layout session alone). - Login/logout HTTP notifies live
UserSessionProcessinstances sharing the samedialup_sessionviabroadcast_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:
- HTTP GET —
Dialup.Auth.csrf_token/1sets or reads the cookie and puts the token in initial assigns. - WebSocket upgrade — the same helper runs on
/ws; the token is stored inUserSessionProcessand merged into every render viamerge_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_tokenfrom assigns. - Logout via HTTP POST notifies live tabs on the pre-rotate
dialup_session, then rotates the cookie and updates eachUserSessionProcessto 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/1capabilities for logged-in users, also setprojections: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
:allwithout 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
UserSessionProcessinstances 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_tokenpresent after WebSocket__initremount on auth pagescurrent_userpresent after login in layout session- Guest vs authenticated MCP grants
- Auth downgrade clears
current_userand 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_useris cleared on reconnect - Unsafe
return_tovalues fall back to/