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

ConceptCookie / IDPurpose
Connection sessiondialup_sessionIdentifies the live UserSessionProcess (one browser tab)
Auth session_dialup_user_tokenOpaque 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:
mix dialup.gen.auth Accounts User users
  1. Wire your Application module:
use Dialup,
  app_dir: __DIR__ <> "/app",
  auth_accounts: MyApp.Accounts,
  plugs: [{Dialup.Auth.Plug, accounts: MyApp.Accounts}]
  1. Run migrations and configure MyApp.Repo.

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

MethodPathDescription
POST/log_inEmail + password → auth cookie + rotated dialup_session
POST/log_outClears auth cookie + rotates connection session
POST/registerCreates user and logs in
POST/reset_password/requestSends reset email (if configured)
POST/reset_password/confirmSets 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"}
end

MCP / 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, 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:

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

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.

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

ReviewSeverityFindingRemediationTests
BugbotHighWS remount drops @csrf_token; auth forms fail CSRF after __initPropagate csrf_token from /ws upgrade through WebSocket → UserSessionProcessmerge_for_render/1test/auth_session_bridge_test.exs
BugbotHighauth_user: nil not forwarded on reconnect; stale current_user after cookie lossauth_opts/1 always passes auth_user: (including nil); apply_auth_opts/2 uses Keyword.fetch/2test/auth_session_bridge_test.exs
BugbotMediumUnvalidated return_to on login → open redirectDialup.Auth.safe_redirect_path/1 in Dialup.Auth.Plugtest/auth_plug_test.exs, test/auth_integration_test.exs
SecurityMediumOpen redirect via return_toSame as aboveSame as above
SecurityMediumWebSocket reconnect retains auth after cookie clearedSame as Bugbot auth_user propagation; remount on auth changetest/auth_session_bridge_test.exs
SecurityMediumMCP bearer tokens keep elevated capabilities after logoutfetch_grant/2 checks grant capabilities ⊆ current agent_grant/1; excess → :grant_expiredtest/auth_session_bridge_test.exs

Second review (post-remediation)

ReviewSeverityFindingRemediationTests
BugbotHighAtom/string capability mismatch in fetch_grant/2 falsely expired guest grantsNormalize capability and projection names to strings before subset checkstest/auth_session_bridge_test.exs (guest read_scene)
BugbotMediumETS-restored session[:current_user] survived when auth_user was nilauth_identity_stale?/1 forces remount; merge_for_render/1 syncs current_user from auth_usertest/auth_session_bridge_test.exs (stale session)
SecurityMediumLogout in another tab left MCP tokens elevated until WS reconnectsync_connection_sessions/2 on login/logout → broadcast_auth_user/2sync_auth_user/1test/auth_session_bridge_test.exs (sync without reconnect)
SecurityMediumGrant re-validation ignored projections (:regions leaked after logout)projections_subset?/2 in fetch_grant/2; settings template scopes projections per auth stategenerator 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.

ReviewSeverityFindingRemediationTests
BugbotHighLogin/logout broadcast keyed to post-rotate cookie; live processes kept pre-rotate session_idsync_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)
SecurityMediumReset password token reusable until TTLGenerated Accounts.reset_user_password/2 deletes reset_password tokens; new requests delete old reset tokensgenerator template
SecurityMediumStolen session token survives re-loginGenerated generate_user_session_token/1 deletes existing "session" tokens before insertgenerator template
SecurityMediumDefault Notifier logs reset URL to stdoutNotifier logs email only; guides require Swoosh (or similar) before productiongenerator 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.

ReviewSeverityFindingRemediationTests
BugbotHighStale auth_user after DB session-token invalidation on another deviceStore auth_session_token on UserSessionProcess; ensure_live_auth/1 re-resolves via get_user_by_session_token/1 before MCP grant checkstest/auth_session_bridge_test.exs (revoked token)
BugbotMediumWebSocket upgrade ignored dialup_current_user from plugsDialup.Auth.resolve_conn_user/2 shared by HTTP and /wstest/auth_plug_test.exs
SecurityMediumCross-device logout/re-login left elevated MCP bearer validSame token re-validation in fetch_grant/2; HTTP auth sync clears stored token when only auth_user is broadcastsame 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.

ReviewSeverityFindingRemediationTests
BugbotHighHTTP login broadcast cleared auth_session_token, bypassing cross-device revalidationlog_in_user/3 passes new token through sync_connection_sessions/2broadcast_auth_user/3test/auth_session_bridge_test.exs (login broadcast + revoke)
BugbotMediumPercent-encoded // bypassed safe_redirect_path/1Decode before local_path?/1; reject both raw and decoded unsafe pathstest/auth_plug_test.exs, test/auth_integration_test.exs
SecurityMediumSame login-sync token gap as Bugbot HighSame as abovesame as above
SecurityMediumCRLF in return_to could inject Location headersReject \r, \n, \0 in local_path?/1test/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.

ReviewSeverityFindingRemediationTests
BugbotMediumDouble-encoded %252f bypassed single URI.decode/1fully_decode/1 (bounded) before local_path?/1test/auth_integration_test.exs
SecurityMediumBrowser join sent authenticated HTML before joiner auth appliedPass joiner auth into browser_join_with_token/5; apply_auth_sync/3 before first rendertest/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.

ReviewSeverityFindingRemediationTests
BugbotHighRevoked tokens did not revalidate on WS navigate/eventensure_live_auth/1 in do_navigate/2, handle_cast event/reconnecttest/auth_session_bridge_test.exs (navigate after revoke)
SecurityMediumWeak SHA-256 password fallback outside :testDialup.Auth.Hash.hash_password/1 raises without bcrypt_elixir in non-test envsexisting 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.

ReviewSeverityFindingRemediationTests
BugbotHighensure_live_auth/1 dropped auth_session_token on user re-resolvePreserve token in sync opts; compare users by idexisting bridge tests
BugbotMedium/register crashed when register_user/1 optionalGuard with function_exported?/3test/auth_plug_test.exs
SecurityMediumMCP grant skipped revalidation when current_page == nilDeny when auth_accounts configuredtest/auth_session_bridge_test.exs
SecurityMediumPassword reset left session tokens validDelete "session" tokens in generated reset_user_password/2generator 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.

ReviewSeverityFindingRemediationTests
BugbotMedium/reset_password/confirm crashed without optional callbacksfunction_exported?/3 guardsplug error path
BugbotMediumNil auth_session_token skipped DB revalidationRequire token when auth_accounts configuredauthed_opts/1 in bridge tests
SecurityMediumEncoded .. in return_toReject .. path segments after decodetest/auth_integration_test.exs

Tenth review (post-remediation)

ReviewSeverityFindingRemediationTests
BugbotMedium__init skipped ensure_live_auth/1Run after apply_auth_opts in init castbridge tests
BugbotMediumMissing login params raised in accountsReturn :invalid_credentials when fields absenttest/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.

ReviewSeverityFindingRemediationTests
BugbotMediumReset routes crashed on missing email/token paramsBinary guards before Accounts callbackstest/auth_plug_test.exs
BugbotMediumrequire_authenticated_user/2 skipped safe_redirect_path/1Sanitize login_path headertest/auth_integration_test.exs
SecurityMediumDB role changes not reflected when user id unchangedCompare public_user/1 in auth_user_current?/2test/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.

ReviewSeverityFindingRemediationTests
BugbotHighNavigate skipped stale session remount when auth already nilUse mount_session/3 when auth_identity_stale?/1test/auth_session_bridge_test.exs
SecurityMediumGuest MCP token read authenticated state after auth upgradeStore auth_fingerprint on grants; reject mismatch in fetch_grant/2test/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.

ReviewSeverityFindingRemediationTests
BugbotHighmount/2 {:redirect, path} unsupported; settings page 500 for guestsHandle mount redirect in Router + UserSessionProcess + HTTP GETtest/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.

ReviewSeverityFindingRemediationTests
BugbotMediumLogout skipped DB token when auth cookie absentRevoke live auth_session_token from session processestest/auth_logout_test.exs
BugbotMediumInvalid mount return caused CaseClauseErrorReturn {:error, :invalid_mount} and handle in HTTP/WStest/mount_redirect_test.exs
BugbotMediumDiscovery redirect follow unboundedRouter.mount_assigns_resolved/3 with depth limittest/mount_redirect_test.exs

Fifteenth review (Bugbot only, post-remediation)

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

ReviewSeverityFindingRemediationTests
BugbotMedium/.well-known/dialup-agent unhandled mount resolution errorsMap :redirect_loop / :invalid_mount to JSON errorstest/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.

ReviewSeverityFindingRemediationTests
BugbotMediumDiscovery ignored auth cookies on protected mountsSeed initial_session from resolve_conn_user/2test/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 /