From 2e83d39d2bd98304fe193cd2b8a86164ec301eae Mon Sep 17 00:00:00 2001
From: Brandon Thomas
Date: Sat, 26 Sep 2026 00:22:45 -0700
Subject: [PATCH] login challenge on website works
---
_database/sql/migrations/AGENTS.md | 1 +
_database/sql/migrations/CLAUDE.md | 7 +-
_database/sql/user_login_challenges.md | 31 ++--
.../endpoints/login_challenges/handlers.rs | 30 +++-
.../endpoints/login_challenges/security.rs | 2 +-
.../src/components/auth/signup-form.tsx | 7 +
.../src/lib/login-bridge-context.ts | 28 +---
.../pages/forgot-password/forgot-password.tsx | 9 +-
.../pages/forgot-password/verify-reset.tsx | 11 +-
.../src/pages/login/desktop-login.tsx | 96 ++----------
.../artcraft-webapp/src/pages/login/login.tsx | 6 +-
.../src/pages/signup/signup.tsx | 15 +-
.../apps/artcraft-webapp/tailwind.config.js | 1 +
.../apps/artcraft-webapp/tsconfig.app.json | 7 +-
frontend/apps/artcraft-webapp/tsconfig.json | 9 +-
frontend/apps/artcraft-website/index.html | 19 ++-
.../apps/artcraft-website/src/app/app.tsx | 5 +-
.../src/components/auth/auth-gate-modal.tsx | 4 +-
frontend/apps/artcraft-website/src/main.tsx | 8 +-
.../src/pages/login/desktop-login.spec.tsx | 144 ++++++++++++++++++
.../src/pages/login/desktop-login.tsx | 36 +++++
.../apps/artcraft-website/tailwind.config.js | 1 +
.../apps/artcraft-website/tsconfig.app.json | 7 +-
frontend/apps/artcraft-website/tsconfig.json | 9 +-
frontend/libs/login/src/index.ts | 2 +
.../login/src/lib/desktop-login-approval.tsx | 127 +++++++++++++++
.../login/src/lib/login-bridge-context.ts | 39 +++++
frontend/libs/login/tsconfig.json | 3 +
frontend/libs/login/tsconfig.lib.json | 5 +
frontend/tsconfig.base.json | 8 +-
30 files changed, 518 insertions(+), 159 deletions(-)
mode change 120000 => 100644 _database/sql/migrations/CLAUDE.md
create mode 100644 frontend/apps/artcraft-website/src/pages/login/desktop-login.spec.tsx
create mode 100644 frontend/apps/artcraft-website/src/pages/login/desktop-login.tsx
create mode 100644 frontend/libs/login/src/lib/desktop-login-approval.tsx
create mode 100644 frontend/libs/login/src/lib/login-bridge-context.ts
diff --git a/_database/sql/migrations/AGENTS.md b/_database/sql/migrations/AGENTS.md
index d5423532c..4f80e91ee 100644
--- a/_database/sql/migrations/AGENTS.md
+++ b/_database/sql/migrations/AGENTS.md
@@ -33,6 +33,7 @@ These are created with the `diesel` tool, and in particular:
## Rules
+- Keep documentation in this directory as regular files, not symlinks. Diesel can treat a documentation symlink as a migration directory and reject the entire migration list.
- Every `up.sql` must have a corresponding `down.sql` that cleanly reverses the migration.
- For `CREATE TABLE` migrations, the `down.sql` is typically `DROP TABLE IF EXISTS table_name;`.
- For `ALTER TABLE` migrations, the `down.sql` reverses the alteration (e.g. `DROP COLUMN`).
diff --git a/_database/sql/migrations/CLAUDE.md b/_database/sql/migrations/CLAUDE.md
deleted file mode 120000
index 47dc3e3d8..000000000
--- a/_database/sql/migrations/CLAUDE.md
+++ /dev/null
@@ -1 +0,0 @@
-AGENTS.md
\ No newline at end of file
diff --git a/_database/sql/migrations/CLAUDE.md b/_database/sql/migrations/CLAUDE.md
new file mode 100644
index 000000000..5e5256509
--- /dev/null
+++ b/_database/sql/migrations/CLAUDE.md
@@ -0,0 +1,6 @@
+# SQL Migrations
+
+Read and follow [AGENTS.md](AGENTS.md) in this directory.
+
+This must remain a regular file. Diesel treats a symlink here as a migration
+directory and fails with `Invalid migration directory`.
diff --git a/_database/sql/user_login_challenges.md b/_database/sql/user_login_challenges.md
index 5f5fc35f3..b6ed393e0 100644
--- a/_database/sql/user_login_challenges.md
+++ b/_database/sql/user_login_challenges.md
@@ -197,18 +197,23 @@ 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 website route is `https://app.getartcraft.com/login/desktop`. The token is kept
+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 ignores late UI results. An already
-in-flight HTTP response may still update the native HTTP cookie jar.
+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.
-The Tauri command synchronizes the HTTP cookie jar with native credentials, scoped
-to the configured API origin, and the UI verifies the ordinary session endpoint
-before reporting success. No browser session is copied into the desktop.
+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
@@ -250,13 +255,15 @@ Coverage:
- 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.
-- Seven desktop UI tests: native browser invocation, QR/code values, polling,
- successful credential synchronization and downstream session verification,
- decline, timeout, network backoff, unmount, and unknown-state handling.
-- Three native tests: HTTP response cookie to native credentials, retry safety,
- exclusion of unrelated/expired cookies, and local/production origin isolation.
+- 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 HTTP and Google/native boundaries; they do not
+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
diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/login_challenges/handlers.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/login_challenges/handlers.rs
index 6be735b7b..abfc0837a 100644
--- a/crates/service/web/storyteller_web/src/http_server/endpoints/login_challenges/handlers.rs
+++ b/crates/service/web/storyteller_web/src/http_server/endpoints/login_challenges/handlers.rs
@@ -14,6 +14,7 @@ use super::security::{new_confirmation_code, new_secret, rate_limit, request_ip,
const VERIFICATION_PAGE: &str = "https://app.getartcraft.com/login/desktop";
+
pub fn configure(cfg: &mut web::ServiceConfig) {
cfg.service(web::scope("/v1/login_challenges").wrap(DefaultHeaders::new().add(("Cache-Control", "no-store")).add(("Referrer-Policy", "no-referrer"))).app_data(web::JsonConfig::default().limit(1024)).route("/create", web::post().to(create)).route("/review", web::post().to(review)).route("/decide", web::post().to(decide)).route("/poll", web::post().to(poll)));
}
@@ -36,7 +37,7 @@ pub async fn create(request: HttpRequest, _body: web::Json &'static str {
+ let host = request.connection_info().host().to_owned();
+ if host.starts_with("localhost:") || host.starts_with("127.0.0.1:") {
+ "http://localhost:4201/login/desktop"
+ } else {
+ VERIFICATION_PAGE
+ }
+}
+
fn outcome(challenge: &LoginChallenge, expired: bool) -> Result {
// Keep an explicit decline even when its deadline has since elapsed.
let declined = challenge.maybe_failure_type.as_deref() == Some("user_declined");
@@ -148,3 +158,21 @@ fn outcome(challenge: &LoginChallenge, expired: bool) -> Result Result<(), CommonWebErr
// Local web development is permitted only when the API is also local.
let host = request.connection_info().host().to_owned();
let local_api = host.starts_with("localhost:") || host.starts_with("127.0.0.1:");
- if local_api && ["http://localhost:4201", "http://127.0.0.1:4201"].contains(&origin) {
+ if local_api && ["http://localhost:4200", "http://127.0.0.1:4200", "http://localhost:4201", "http://127.0.0.1:4201"].contains(&origin) {
return Ok(());
}
Err(CommonWebError::Forbidden)
diff --git a/frontend/apps/artcraft-webapp/src/components/auth/signup-form.tsx b/frontend/apps/artcraft-webapp/src/components/auth/signup-form.tsx
index c3c932fa3..c56a90185 100644
--- a/frontend/apps/artcraft-webapp/src/components/auth/signup-form.tsx
+++ b/frontend/apps/artcraft-webapp/src/components/auth/signup-form.tsx
@@ -21,6 +21,8 @@ interface SignupFormProps {
signupSource: string;
className?: string;
autoFocus?: boolean;
+ /** Let the enclosing flow resume after Google, without a billing redirect. */
+ continueAfterGoogleLogin?: boolean;
}
export const SignupForm = ({
@@ -28,6 +30,7 @@ export const SignupForm = ({
signupSource,
className = "",
autoFocus = false,
+ continueAfterGoogleLogin = false,
}: SignupFormProps) => {
const navigate = useNavigate();
const [email, setEmail] = useState("");
@@ -84,6 +87,10 @@ export const SignupForm = ({
};
const handleGoogleSuccess = async () => {
+ if (continueAfterGoogleLogin) {
+ onSuccess();
+ return;
+ }
// Refresh the session and check the subscription in parallel; users without
// an active subscription are pushed to pricing, subscribers go home.
const [, subscribed] = await Promise.all([
diff --git a/frontend/apps/artcraft-webapp/src/lib/login-bridge-context.ts b/frontend/apps/artcraft-webapp/src/lib/login-bridge-context.ts
index 5adcba271..f547b3d07 100644
--- a/frontend/apps/artcraft-webapp/src/lib/login-bridge-context.ts
+++ b/frontend/apps/artcraft-webapp/src/lib/login-bridge-context.ts
@@ -1,27 +1 @@
-export const LOGIN_BRIDGE_PATH = "/login/desktop";
-const STORAGE_KEY = "artcraft_pending_desktop_approval";
-const TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/;
-
-/** Capture the fragment before referral tracking or any third-party code can see it. */
-export function captureLoginBridgeContext(): string | null {
- if (window.location.pathname !== LOGIN_BRIDGE_PATH) return null;
- const fragment = new URLSearchParams(window.location.hash.slice(1));
- const supplied = fragment.get("approval_token");
- if (supplied !== null) {
- // Remove even malformed input from the URL. It must not become attribution data.
- window.history.replaceState(window.history.state, "", LOGIN_BRIDGE_PATH);
- sessionStorage.removeItem(STORAGE_KEY);
- if (TOKEN_PATTERN.test(supplied)) sessionStorage.setItem(STORAGE_KEY, supplied);
- }
- const stored = sessionStorage.getItem(STORAGE_KEY);
- return stored && TOKEN_PATTERN.test(stored) ? stored : null;
-}
-
-export function clearLoginBridgeContext(): void {
- sessionStorage.removeItem(STORAGE_KEY);
-}
-
-export function safeAuthReturnPath(value: string | null): string {
- if (!value || !value.startsWith("/") || value.startsWith("//") || /[\\\u0000-\u001f]/.test(value)) return "/";
- return value;
-}
+export { captureLoginBridgeContext, clearLoginBridgeContext, LOGIN_BRIDGE_PATH, safeAuthReturnPath, authContinuationUrl } from "@frontend/login";
diff --git a/frontend/apps/artcraft-webapp/src/pages/forgot-password/forgot-password.tsx b/frontend/apps/artcraft-webapp/src/pages/forgot-password/forgot-password.tsx
index 47942d4c5..b6b807d40 100644
--- a/frontend/apps/artcraft-webapp/src/pages/forgot-password/forgot-password.tsx
+++ b/frontend/apps/artcraft-webapp/src/pages/forgot-password/forgot-password.tsx
@@ -3,14 +3,17 @@ import { ArrowLeftIcon, LoaderCircleIcon, MailIcon } from "lucide-react";
import { Button } from "@storyteller/ui-button";
import { Input } from "@storyteller/ui-input";
import { useState } from "react";
-import { Link } from "react-router-dom";
+import { Link, useSearchParams } from "react-router-dom";
import { PasswordResetApi } from "@storyteller/api";
import { AuthHeader } from "../../components/auth/auth-layout";
import { AuthPageFrame } from "../../components/auth/auth-page-frame";
import Seo from "../../components/seo";
+import { authContinuationUrl } from "../../lib/login-bridge-context";
const ForgotPassword = () => {
+ const [searchParams] = useSearchParams();
+ const from = searchParams.get("from");
const [email, setEmail] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
@@ -109,7 +112,7 @@ const ForgotPassword = () => {
We've sent a password reset code to {email}