authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-05 10:41:12-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-05 15:36:02-07:00
log692d41a6eabb6e6a88da2770eb3fc61e00dd75dd
treee14b7a12e6eadfd209bf4981d7febd0e76c00896
parent71af6251fb7ec7241bc3bd1a340d4fc3096267c4
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

Use a two-step sign-in form with service-specific headings

Keep Shale providers above the form, remove remember me, and preserve invitation and passkey flows. Resolve headings from validated client destinations and the existing service catalog. Assisted-by: gpt-6

7 files changed, 98 insertions(+), 25 deletions(-)

dashboard/src/auth.rs+42
......@@ -681,6 +681,48 @@ pub async fn route(State(app): State<Arc<App>>, request: Request) -> Result<Resp
681681 if path == "/auth/status" && method == Method::GET {
682682 let csrf = issue(&auth.db.lock().unwrap(), "csrf", json!({}), 900)?;
683683 let mut value = json!({"csrf":csrf,"account":auth.session(&headers,"dashboard")?});
684 value["service"] = json!("snow globe");
685 if let Some(flow) = query.get("flow") {
686 if !pending(&auth.db.lock().unwrap(), flow, "file", false)?.is_null() {
687 value["service"] = json!("copyparty");
688 }
689 } else if let Ok((target, config)) = oidc::sign_in_target(
690 auth,
691 query.get("next").map(String::as_str).unwrap_or_default(),
692 ) {
693 value["service"] = config["name"].clone();
694 if target
695 .query_pairs()
696 .any(|(key, value)| key == "client_id" && value == "forward-auth")
697 {
698 if let Some(redirect) = target
699 .query_pairs()
700 .find(|(key, _)| key == "redirect_uri")
701 .and_then(|(_, value)| url::Url::parse(&value).ok())
702 {
703 value["service"] = json!(
704 redirect
705 .host_str()
706 .unwrap_or("snow globe")
707 .split('.')
708 .next()
709 .unwrap_or("snow globe")
710 );
711 if let Ok(catalog) = core::launcher(app.clone()).await {
712 if let Some(service) = array(&catalog.value).iter().find(|service| {
713 service["urls"].as_object().is_some_and(|urls| {
714 urls.values().any(|url| {
715 url.as_str()
716 == Some(redirect.origin().ascii_serialization().as_str())
717 })
718 })
719 }) {
720 value["service"] = service["name"].clone();
721 }
722 }
723 }
724 }
725 }
684726 value["providers"] = guest::providers(
685727 auth,
686728 query.get("next").map(String::as_str).unwrap_or_default(),
dashboard/src/core.rs+1-1
......@@ -493,7 +493,7 @@ new Dynamic { name = s.meta.name; tagline = s.meta.tagline; hostname = route?.ho
493493 Ok(module)
494494}
495495
496async fn launcher(app: Arc<App>) -> Result<Arc<Document>> {
496pub(crate) async fn launcher(app: Arc<App>) -> Result<Arc<Document>> {
497497 let real = tokio::fs::canonicalize(&app.repo).await?;
498498 let state = app.clone();
499499 app.cache.get(format!("launcher:{}",real.display()),Duration::from_secs(365*86400),move || async move {
dashboard/src/oidc.rs+17-3
......@@ -57,7 +57,7 @@ fn guests_allowed(id: &str, config: &Value) -> bool {
5757 config["allowGuests"] == true && (id == "shale" || id.starts_with("shale-preview-"))
5858}
5959
60pub(crate) fn guest_target(auth: &auth::Store, next: &str) -> Result<String> {
60pub(crate) fn sign_in_target(auth: &auth::Store, next: &str) -> Result<(url::Url, Value)> {
6161 if next.len() > 8192 || next.contains('\\') || next.chars().any(char::is_control) {
6262 return Err(invalid("invalid_request"));
6363 }
......@@ -79,14 +79,28 @@ pub(crate) fn guest_target(auth: &auth::Store, next: &str) -> Result<String> {
7979 .map(String::as_str)
8080 .unwrap_or_default();
8181 let config = client(&auth.db.lock().unwrap(), id)?;
82 if !guests_allowed(id, &config)
83 || query.get("response_type").map(String::as_str) != Some("code")
82 if query.get("response_type").map(String::as_str) != Some("code")
8483 || !array(&config["redirectUris"])
8584 .iter()
8685 .any(|v| v.as_str() == query.get("redirect_uri").map(String::as_str))
8786 {
8887 return Err(invalid("access_denied"));
8988 }
89 Ok((target, config))
90}
91
92pub(crate) fn guest_target(auth: &auth::Store, next: &str) -> Result<String> {
93 let (target, config) = sign_in_target(auth, next)?;
94 let query = fields(target.query().unwrap_or_default())?;
95 if !guests_allowed(
96 query
97 .get("client_id")
98 .map(String::as_str)
99 .unwrap_or_default(),
100 &config,
101 ) {
102 return Err(invalid("access_denied"));
103 }
90104 Ok(format!(
91105 "{}?{}",
92106 target.path(),
dashboard/web/pages/SignIn.css+4-4
......@@ -4,11 +4,11 @@ body { font: 16px "Name Sans", sans-serif; }
44.pf-v5-c-login__main button, .pf-v5-c-login__main input { font-family: inherit; }
55.pf-v5-c-login__main input[type="submit"] { cursor: pointer; }
66.pf-v5-c-login__main [aria-disabled="true"], .pf-v5-c-login__main :disabled { opacity: .6; cursor: wait; }
7.pf-v5-c-login__main .checkbox label { position: relative; }
8.pf-v5-c-login__main .checkbox input { display: block; position: absolute; opacity: 0; }
9.pf-v5-c-login__main .checkbox label:has(:focus-visible) { outline: 2px solid var(--primary); }
107.setup-intro { margin-bottom: 1rem; text-align: center; }
11.guest-providers { margin-top: 1.5rem; }
8.guest-providers { margin-bottom: 1.5rem; }
9.sign-in-account { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 1rem; }
10.sign-in-account span { overflow-wrap: anywhere; }
11.sign-in-account button { background: none; border: 0; padding: 0; color: var(--text); text-decoration: underline; cursor: pointer; }
1212.pf-v5-c-login__main .guest-providers a {
1313 display: grid;
1414 grid-template-columns: 20px minmax(0, 1fr) 20px;
dashboard/web/pages/SignIn.tsx+29-17
......@@ -10,12 +10,13 @@ export function SignIn() {
1010 const setup = params.get("setup");
1111 const statusQuery = new URLSearchParams();
1212 if (setup) statusQuery.set("setup", setup);
13 if (params.get("flow")) statusQuery.set("flow", params.get("flow")!);
1314 if (params.get("next")) statusQuery.set("next", params.get("next")!);
14 const [status, { refetch }] = createResource(() => authRequest<{ csrf: string; setup?: string; providers?: { id: string; name: string }[] }>(`status?${statusQuery}`));
15 const [status, { refetch }] = createResource(() => authRequest<{ csrf: string; service: string; setup?: string; providers?: { id: string; name: string }[] }>(`status?${statusQuery}`));
1516 const [username, setUsername] = createSignal("");
1617 const [password, setPassword] = createSignal("");
1718 const [email, setEmail] = createSignal("");
18 const [remember, setRemember] = createSignal(false);
19 const [passwordStep, setPasswordStep] = createSignal(false);
1920 const [visible, setVisible] = createSignal(false);
2021 const [busy, setBusy] = createSignal(false);
2122 const [error, setError] = createSignal(params.has("guest_error") ? "Guest sign-in wasn't completed. Choose a provider to try again." : "");
......@@ -23,7 +24,7 @@ export function SignIn() {
2324 let conditional: AbortController | undefined;
2425 let disposed = false;
2526 const body = () => ({ csrf: status()!.csrf, username: username(), password: password(), email: email(), setup,
26 remember: remember(), flow: params.get("flow") ?? "", next: params.get("next") ?? "/" });
27 remember: false, flow: params.get("flow") ?? "", next: params.get("next") ?? "/" });
2728 const passkey = async (automatic = false) => {
2829 if (!status() || busy()) return;
2930 conditional?.abort();
......@@ -41,20 +42,27 @@ export function SignIn() {
4142 });
4243 if (!(credential instanceof PublicKeyCredential)) throw new Error("Passkey sign-in was canceled. Try again or use your password.");
4344 selected = true; setBusy(true);
44 const result = await authRequest<{ next: string }>("passkey/finish", { csrf: request.csrf, token, remember: remember(), credential: credential.toJSON() });
45 const result = await authRequest<{ next: string }>("passkey/finish", { csrf: request.csrf, token, remember: false, credential: credential.toJSON() });
4546 window.location.assign(result.next);
4647 } catch (failure) {
4748 if ((!automatic || selected) && !controller.signal.aborted) setError(authReason(failure));
4849 } finally { if (!automatic || selected) setBusy(false); }
4950 };
5051 createEffect(() => {
51 if (!setup && status()) void (async () => {
52 if (await PublicKeyCredential.isConditionalMediationAvailable?.() && !disposed) await passkey(true);
52 if (!setup && !passwordStep() && status()) void (async () => {
53 if (await PublicKeyCredential.isConditionalMediationAvailable?.() && !disposed && !passwordStep()) await passkey(true);
5354 })().catch(() => {});
5455 });
5556 const complete = async () => {
5657 if (!status() || busy()) return;
57 conditional?.abort(); setBusy(true); setError(""); setNotice("");
58 conditional?.abort(); setError(""); setNotice("");
59 if (!setup && !passwordStep()) {
60 if (!username().trim()) return;
61 setUsername(username().trim());
62 setPasswordStep(true);
63 return;
64 }
65 setBusy(true);
5866 try {
5967 const result = await authRequest<{ next: string }>(setup ? "setup" : "password", body());
6068 window.location.assign(result.next);
......@@ -73,18 +81,25 @@ export function SignIn() {
7381 <main class="pf-v5-c-login__main">
7482 <div id="kc-header"><div id="kc-header-wrapper"><div class="kc-logo-text"><span>snow sign on</span></div></div></div>
7583 <div class="card-pf">
76 <header><h1 id="kc-page-title">{setup ? "welcome" : "sign in to your account"}</h1></header>
84 <header><h1 id="kc-page-title">{setup ? "welcome" : `sign in to ${status()?.service?.toLowerCase() ?? "snow globe"}`}</h1></header>
7785 <div id="kc-content"><div id="kc-content-wrapper">
7886 <Show when={status.error || error()}><div class="alert-error pf-v5-c-alert" role="alert"><span class="pf-v5-c-alert__title">{status.error ? authReason(status.error) : error()}</span></div></Show>
7987 <Show when={notice()}><div class="alert-info pf-v5-c-alert" role="status"><span class="pf-v5-c-alert__title">{notice()}</span></div></Show>
8088 <Show when={!status.error} fallback={<button class="pf-v5-c-button pf-m-primary pf-m-block" onClick={() => refetch()}>try again</button>}>
89 <Show when={!setup && status()?.providers?.length}>
90 <div id="kc-social-providers" class="guest-providers">
91 <For each={status()?.providers}>{(provider) => <a rel="external" data-provider={provider.id} class="pf-v5-c-button pf-m-primary pf-m-block" href={`/auth/guest/start/${provider.id}?next=${encodeURIComponent(params.get("next") ?? "")}`}>{provider.id === "astheno" ? <Stone size={18} aria-hidden="true" /> : <img src={githubIcon} width="18" height="18" alt="" />}<span>continue with {provider.name}</span></a>}</For>
92 </div>
93 </Show>
8194 <div id="kc-form"><div id="kc-form-wrapper">
8295 <form id="kc-form-login" onSubmit={(event) => { event.preventDefault(); void complete(); }}>
8396 <Show when={setup} fallback={
97 <Show when={!passwordStep()} fallback={<div class="sign-in-account"><span>{username()}</span><button type="button" onClick={() => { setPassword(""); setVisible(false); setError(""); setNotice(""); setPasswordStep(false); }}>change</button><input name="username" type="hidden" autocomplete="username" value={username()} /></div>}>
8498 <div class="pf-v5-c-form__group">
8599 <label for="username" class="pf-v5-c-form__label">username or email</label>
86 <input id="username" class="pf-v5-c-form-control" name="username" type="text" required autocomplete="username webauthn" value={username()} onInput={(event) => setUsername(event.currentTarget.value)} autofocus />
100 <input id="username" class="pf-v5-c-form-control" name="username" type="text" required autocomplete="username webauthn" value={username()} onInput={(event) => setUsername(event.currentTarget.value)} autofocus ref={(input) => queueMicrotask(() => input.focus())} />
87101 </div>
102 </Show>
88103 }>
89104 <p class="setup-intro">Your account is <b>{status()?.setup ?? "…"}</b>. Add your email and choose a password.</p>
90105 <div class="pf-v5-c-form__group">
......@@ -92,29 +107,26 @@ export function SignIn() {
92107 <input id="email" class="pf-v5-c-form-control" name="email" type="email" required autocomplete="email" value={email()} onInput={(event) => setEmail(event.currentTarget.value)} autofocus />
93108 </div>
94109 </Show>
110 <Show when={setup || passwordStep()}>
95111 <div class="pf-v5-c-form__group">
96112 <label for="password" class="pf-v5-c-form__label">{setup ? "choose a password" : "password"}</label>
97113 <div class="pf-v5-c-input-group">
98 <input id="password" class="pf-v5-c-form-control" name="password" type={visible() ? "text" : "password"} required minLength={setup ? 8 : undefined} maxLength={1024} autocomplete={setup ? "new-password" : "current-password"} value={password()} onInput={(event) => setPassword(event.currentTarget.value)} />
114 <input id="password" class="pf-v5-c-form-control" name="password" type={visible() ? "text" : "password"} required minLength={setup ? 8 : undefined} maxLength={1024} autocomplete={setup ? "new-password" : "current-password"} value={password()} onInput={(event) => setPassword(event.currentTarget.value)} ref={(input) => { if (!setup) queueMicrotask(() => input.focus()); }} />
99115 <button class="pf-v5-c-button pf-m-control" type="button" aria-label={visible() ? "Hide password" : "Show password"} aria-controls="password" data-password-toggle onClick={() => setVisible(!visible())}><i aria-hidden="true" /></button>
100116 </div>
101117 <Show when={setup}><span class="pf-v5-c-helper-text__item-text">Use at least 8 characters. You can add a passkey next.</span></Show>
102118 </div>
103119 <Show when={!setup}><div class="pf-v5-c-form__group">
104 <div id="kc-form-options"><div class="checkbox"><label><input id="rememberMe" name="rememberMe" type="checkbox" checked={remember()} onChange={(event) => setRemember(event.currentTarget.checked)} /> remember me</label></div></div>
105120 <div><span><a href="#" onClick={(event) => { event.preventDefault(); setNotice("Ask Clover for a password reset link."); }}>forgot password?</a></span></div>
106121 </div></Show>
122 </Show>
107123 <div id="kc-form-buttons" class="pf-v5-c-form__group">
108 <input class="pf-v5-c-button pf-m-primary pf-m-block" name="login" id="kc-login" type="submit" value={setup ? "create account" : "Sign In"} disabled={busy() || !status()} aria-busy={busy()} />
124 <input class="pf-v5-c-button pf-m-primary pf-m-block" name="login" id="kc-login" type="submit" value={setup ? "create account" : "sign in"} disabled={busy() || !status()} aria-busy={busy()} />
109125 </div>
110126 </form>
111127 </div></div>
112128 <Show when={!setup}><a id="authenticateWebAuthnButton" href="#" class="pf-v5-c-button pf-m-secondary pf-m-block" aria-disabled={busy() || !status()} onClick={(event) => { event.preventDefault(); void passkey(); }}>sign in with passkey</a></Show>
113 <Show when={!setup && status()?.providers?.length}>
114 <div id="kc-social-providers" class="guest-providers">
115 <For each={status()?.providers}>{(provider) => <a rel="external" data-provider={provider.id} class="pf-v5-c-button pf-m-primary pf-m-block" href={`/auth/guest/start/${provider.id}?next=${encodeURIComponent(params.get("next") ?? "")}`}>{provider.id === "astheno" ? <Stone size={18} aria-hidden="true" /> : <img src={githubIcon} width="18" height="18" alt="" />}<span>continue with {provider.name}</span></a>}</For>
116 </div>
117 </Show>
129
118130 </Show>
119131 </div></div>
120132 </div>
tools/dashboard-auth-test.py+1
......@@ -172,6 +172,7 @@ def main():
172172 file_cookies = {}
173173 handoff = request('/auth/file/sign-in?rd=%2Fclover%2FPublic%2F', cookies=file_cookies, status=302, host=file)
174174 flow = file_cookies['__Host-snow-flow']
175 assert request('/auth/status?flow=' + flow, cookies={})['service'] == 'copyparty'
175176 callback = request('/auth/continue?flow=' + flow, cookies=cookies, status=302)['location']
176177 request('/auth/file/callback?' + callback.split('?', 1)[1], cookies={}, status=403, host=file)
177178 finished = request('/auth/file/callback?' + callback.split('?', 1)[1], cookies=file_cookies, status=302, host=file)
tools/dashboard-oidc-test.py+4
......@@ -180,7 +180,11 @@ def main():
180180 input=json.dumps({'provider': 'github', 'clientId': 'fixture', 'clientSecret': secret}))
181181 assert configured.returncode == 0, configured.stderr
182182 target = authorize_path(minimal)
183 assert request('/auth/status')['service'] == 'snow globe'
183184 assert request('/auth/status')['providers'] == []
185 assert request('/auth/status?' + urllib.parse.urlencode({'next': target}))['service'] == 'shale'
186 forged = target.replace(urllib.parse.quote(callback, safe=''), urllib.parse.quote('https://evil.example/callback', safe=''))
187 assert request('/auth/status?' + urllib.parse.urlencode({'next': forged}))['service'] == 'snow globe'
184188 assert request('/auth/status?' + urllib.parse.urlencode({'next': target}))['providers'] == [{'id':'github','name':'GitHub'}]
185189 request('/auth/guest/start/github?' + urllib.parse.urlencode({'next': '/'}), status=400)
186190 started = request('/auth/guest/start/github?' + urllib.parse.urlencode({'next': target}), status=302)