Files
artcraft-services/_database/sql/user_login_challenges.md
T

17 KiB

Website-approved device login

Implemented across storyteller-web, the browser approval page, and the Tauri login dialog. Apply the two migrations before deploying the API; deploy the web approval route before releasing the desktop buttons. No production migrations or deployments were performed while developing this change.

Call the flow device approval, and each attempt a login challenge. It works for a desktop browser link or a phone scanning a QR code, regardless of how the approving account signed in. It creates an ordinary cookie-based web session.

The design borrows separate device/browser credentials, polling, explicit consent, and code comparison from OAuth device authorization (RFC 8628). It is an ArtCraft session bridge, not an OAuth token endpoint implementation.

Two migrations

  1. 2026-09-25-120000-0000_create_user_login_challenges creates the challenge and its terminal outcome audit record together. A separate failure table is not needed for one immutable outcome per attempt.
  2. 2026-09-25-120000-0001_alter_user_sessions_add_maybe_creation_type adds nullable provenance to cookie sessions. Existing rows and older writers retain NULL.

Both have reverse migrations and matching materialized schemas under migrations_squashed/user_tables/. Deploy the schema before new writers. Roll back feature code before dropping the column/table; rollback removes bridge audit records but does not revoke sessions already issued.

UserWebSessionCreationType values:

Rust variant Stored value Meaning
DirectLogin direct_login Password or SSO login/signup, including desktop password login.
DeviceApproval device_approval A new session minted from an approved login challenge.
Impersonation impersonation Existing consented staff impersonation flow.
None NULL Unknown provenance, including existing rows and older writers.

DirectLogin describes the creation mechanism more accurately than Web, since ordinary password login is also available in the desktop app. This field does not replace maybe_impersonation_user_token or influence authentication/authorization. MCP and API credentials continue using mcp_sessions and api_keys respectively. Existing password/SSO and impersonation session writers now record provenance. There is no historical backfill or default that would invent it.

Credentials and confirmation

Generate two independent 32-byte CSPRNG values per challenge. Encode each as unpadded base64url (43 ASCII characters). Store only the SHA-256 digest of that canonical ASCII representation in the two uniquely indexed BINARY(32) columns. Reject noncanonical input instead of accepting multiple representations. These are random tokens, not passwords, so a fast cryptographic hash is appropriate.

Value Who receives it What it permits
token Internal logs/support Identifying a row; no authentication.
approval token Desktop, browser link, QR Review and authenticated approval/decline only.
device token Initiating desktop only Polling and collecting the newly authorized session.
confirmation code Desktop and approval screen Visual comparison only; no lookup or authentication.

Use eight uniformly random characters from BCDFGHJKLMNPQRSTVWXZ for the code. Store WDJBMJHT, display WDJB-MJHT, and ask the user to verify the match. It need not be unique because it is never used to locate or authorize a challenge. Manual short-code entry is outside this initial flow; both entry points carry the full approval token. The private device token must never be in the URL or QR code.

The website displays the requesting IP, the account being authorized, the matching code, and explicit Approve desktop login / Decline actions. An existing website session skips only the login screen, never consent. If login is needed, keep the pending approval context in first-party browser state and resume the confirmation page after password or Google SSO login. Recheck expiry after login. Do not send the bridge tokens to Google or another identity provider. Allow only the fixed first-party return route, not an arbitrary caller-supplied redirect URL.

The approval page is a read-only GET; link scanners and previews cannot decide the challenge. Decisions require a valid, non-impersonated user web session plus CSRF/origin protection. Never accept an API key or MCP credential as consent. Require the user to have initiated the request on a device in their possession; the visible code and IP help detect phishing but are not authentication factors.

The approval URL uses HTTPS and carries the browser credential in its fragment. The page captures and removes that fragment before referral collection or React providers initialize, skips analytics/referral tracking on direct entry, and sets a no-referrer policy. API responses send Cache-Control: no-store and Referrer-Policy: no-referrer; API credentials travel in JSON bodies, never query strings. Shared fonts and the Google sign-in provider still load. Keep request and response bodies out of gateway logs. Invalid credentials reveal no account or challenge details. The IP follows the existing trusted-gateway forwarding policy; the gateway must replace untrusted incoming forwarding headers.

State and the 20-minute deadline

stateDiagram-v2
  [*] --> pending
  pending --> approved: authenticated approval
  pending --> failed: user_declined
  pending --> failed: expired
  approved --> failed: expired before redemption
  approved --> redeemed: atomically mint one session
  redeemed --> [*]
  failed --> [*]

Set created_at = NOW() and expires_at = NOW() + INTERVAL 20 MINUTE in the same INSERT using the database's clock. The default expiry is immediate so a writer that omits it fails closed. Neither approval nor polling extends this deadline. All eligibility checks require expires_at > NOW(); equality means expired. The create query enforces this exact lifetime; these migrations do not add triggers or database CHECK constraints. Keep database connections in UTC.

The persisted status alone is insufficient to decide validity. A pending or approved row whose deadline passed is already expired even before a sweeper marks it failed. Approvals and first redemptions must check the deadline again under the row lock. Polls are normally read-only and do not touch updated_at.

Status Required accompanying data
pending No decision, failure, redemption, or session reference.
approved Deciding user, decision IP/time; no failure, redemption, or session reference.
redeemed Approval data plus session ID, first redemption IP/time; no failure.
failed Failure type/time; no redemption or session reference; retain any prior approval.

UserLoginChallengeFailureType initially has two values:

  • user_declined: record the deciding user, browser IP in both decision/failure IP fields, and the decision time in both decision/failure timestamps. Keep the original desktop IP. Polling with the valid device token reports the decline.
  • expired: record maybe_failed_at = expires_at. Retain original desktop IP and any prior approval, but leave the failure IP NULL: a timeout has no actor. A scheduled sweep records abandoned requests even when nobody polls again.

Failed challenges are unusable for approval or redemption. Their digests and audit metadata remain for diagnosing failures; do not physically delete the row when a user declines. A new attempt creates a new row and fresh tokens. Closing the desktop dialog stops polling; unused requests expire normally.

Invalid tokens, failed website sign-ins, throttling, and transient infrastructure errors do not terminally fail a valid challenge. Record those requests in the existing operational/abuse logging with their IP and failure category, without credentials. A second decision cannot replace the first actor or outcome.

Atomic redemption and response loss

The website's approval records consent; it never creates or receives the desktop cookie. The desktop's authenticated poll performs redemption after approval:

  1. Begin an InnoDB transaction on the primary and find the challenge by the device token digest with SELECT ... FOR UPDATE.
  2. Validate the immutable deadline and state after taking the lock. Handle failed/pending states without creating a session. Revalidate that the approved user exists and is permitted to log in under the ordinary session rules.
  3. For approved, insert a fresh user_sessions row owned by the deciding user, with maybe_creation_type = 'device_approval', no impersonation, the current desktop redemption IP, and the ordinary web-session lifetime.
  4. Store its numeric ID, redemption IP/time, and status = 'redeemed' on the challenge in that same transaction. Generate the signed cookie using HttpUserSessionManager::create_cookie before commit, so signing failure rolls back too. Commit successfully before returning any credential to the caller.

Both approval/decline and redemption must serialize on the same challenge row; conditional updates include the expected state and the unexpired deadline, and must check rows affected. SQL for this flow belongs in mysql_queries, with executor/transaction support, not in the web handler. All three Rust enums in this change are database enums; separate client-facing status/failure enums include an unknown-value fallback. Desktop callers fail closed on an unknown state.

One-time means at most one new session per challenge. If the first successful HTTP response is lost, a retry with the same device token may return the same session until the original 20-minute deadline. For redeemed, read the linked session and verify its owner, creation type, absence of impersonation, expiry, deletion state, and the user's current eligibility before reconstructing its cookie. Never mint a replacement or resurrect a deleted session. After the deadline, return expiry without a cookie; the already-issued session keeps its normal lifetime, and the audit outcome remains redeemed.

The unique nullable session ID index prevents two challenges from referencing one session and supports retries without duplicating bearer credentials in this table. The reference is intentionally signed because the legacy user_sessions.id is signed. Like neighboring schemas, references are indexed logical links rather than cascading foreign keys; removal of a session must not erase the audit row. If the referenced session is missing, redemption fails closed.

Implemented endpoints and clients

All endpoints are JSON POSTs under /v1/login_challenges:

Endpoint Authentication Effect
create None; limited by requesting IP Returns device token, verification URL, code, expiry.
review Approval token + web session + Origin Returns code, requesting IP, account, state, expiry.
decide Approval token + web session + Origin Records the first Yes/No; never creates a desktop session.
poll Private device token Reports state or atomically redeems approved consent.

The API advertises five-second polling. Per-process IP limits are ten creates and 120 other requests per minute, shared across Actix workers, with bounded memory. Production gateways can additionally enforce aggregate limits across replicas. A worker expires up to 1,000 abandoned challenges every 30 seconds. Audit retention is intentionally not an automatic purge policy in this change.

The production website route is https://app.getartcraft.com/login/desktop. Local API requests use http://localhost:4201/login/desktop; run that webapp with USE_LOCAL_API=1 so approval reaches the same database. The token is kept in tab-scoped session storage through login, including Google login for accounts without a subscription. Returning to the page still requires explicit consent. The desktop displays the QR code, code comparison, countdown, and status. Opening the website invokes the native system browser. Network errors back off up to 30 seconds; closing the dialog stops polling and cancels the native handle. A response arriving after cancellation cannot install credentials.

Successful polling returns the signed value and the normal Set-Cookie header. Tauri commands use the Rust artcraft_client bindings for creation, polling, and ordinary session verification, all against AppEnvConfigs.storyteller_host. Rust keeps the private device token and signed session out of IPC/JavaScript; the UI holds only a local handle and public display data. After verifying the downstream session, Rust installs and persists the cookie in the native HTTP jar and updates the credential manager. No browser session is copied into the desktop.

Integration tests

The backend harness uses the fixed local Unix socket /tmp/mysql.sock and local root account. It never reads DATABASE_URL, any environment database URL, DNS, or a TCP endpoint (including localhost SSH forwarding). Each test creates a new randomly named artcraft_test_bridge_* database, replays all migrations, and drops only its own database on success. Existing storyteller and artcraft_test data are not used. A failed test leaves its isolated database for diagnosis. The local MySQL instance must be running; there is no fallback to a remote database.

From the services repository:

SQLX_OFFLINE=true cargo test --offline -p storyteller-web --bin storyteller-web login_challenges
cd frontend
npm exec vitest -- run --config apps/artcraft-webapp/vite.config.ts src/pages/login/desktop-login.spec.tsx src/pages/login/login-bridge-continuation.spec.tsx

SQLX_OFFLINE controls compile-time query metadata only. These tests still execute real SQL against local MySQL. Do not enable skip_database_tests for this run.

From the desktop repository:

SQLX_OFFLINE=true cargo test --offline -p artcraft --lib login_bridge_tests
cd frontend
npm exec vitest -- run --config libs/components/login-modal/vite.config.ts src/lib/DesktopLoginBridge.spec.tsx

Coverage:

  • Six backend integration tests: passwordless account approval, zero sessions before redemption, rejection/audit IPs, token separation, CSRF, MCP rejection, invalid approving sessions, concurrent decisions/redemptions, response-loss retries, abandoned expiry, expiry during lock contention, revocation, bans, signed-cookie authentication through the production session guard, and an issued session remaining valid after the challenge expires.
  • Eight browser tests: API/consent interaction, code/IP display, decline, expiry, invalid requests, safe return URLs, login continuation, and Google sign-in continuation without subscription gating or bridge credentials in SSO requests.
  • Eight desktop UI tests: IPC-only transport (JavaScript HTTP is forbidden), native browser invocation, QR/code values, polling, verified user results, decline, timeout, network backoff, cancellation, and host/status diagnostics.
  • Ten native tests: real API bindings against ephemeral loopback HTTP servers, downstream verification before cookie installation, persistence, retries, rejection, timeout, cancellation during HTTP, unknown states, no redirects, redacted errors, local/production origin isolation, and visitor-only telemetry.

Frontend tests replace external Google/native boundaries; they do not perform a live Google OAuth exchange or drive a packaged Tauri GUI. Backend tests use real MySQL and the production cookie signer/session guard with Redis disabled. Existing session-cache TTL behavior is unchanged. SQL lookups now reject expired sessions; bridge consent/redemption always checks MySQL directly.

Validation also covered both migration directions, nullable compatibility with legacy session writers, uniqueness constraints, fail-closed expiry defaults, and matching materialized schemas. The focused web TypeScript check and the production webapp Nx build (including 52 dependency tasks) passed. Broad workspace TypeScript checks have existing errors. The desktop Nx build is blocked by the existing api -> tauri-api -> api cycle; its direct Vite login bundle builds but declaration checks report unavailable dependency outputs. A source-only desktop check reports four existing model-list imports of src/index.js, with no bridge-file errors. The full native Rust test target compiled successfully.