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...@@ -681,6 +681,48 @@ pub async fn route(State(app): State<Arc<App>>, request: Request) -> Result<Resp
681 if path == "/auth/status" && method == Method::GET {681 if path == "/auth/status" && method == Method::GET {
682 let csrf = issue(&auth.db.lock().unwrap(), "csrf", json!({}), 900)?;682 let csrf = issue(&auth.db.lock().unwrap(), "csrf", json!({}), 900)?;
683 let mut value = json!({"csrf":csrf,"account":auth.session(&headers,"dashboard")?});683 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 }
684 value["providers"] = guest::providers(726 value["providers"] = guest::providers(
685 auth,727 auth,
686 query.get("next").map(String::as_str).unwrap_or_default(),728 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...@@ -493,7 +493,7 @@ new Dynamic { name = s.meta.name; tagline = s.meta.tagline; hostname = route?.ho
493 Ok(module)493 Ok(module)
494}494}
495495
496async fn launcher(app: Arc<App>) -> Result<Arc<Document>> {496pub(crate) async fn launcher(app: Arc<App>) -> Result<Arc<Document>> {
497 let real = tokio::fs::canonicalize(&app.repo).await?;497 let real = tokio::fs::canonicalize(&app.repo).await?;
498 let state = app.clone();498 let state = app.clone();
499 app.cache.get(format!("launcher:{}",real.display()),Duration::from_secs(365*86400),move || async move {499 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 {...@@ -57,7 +57,7 @@ fn guests_allowed(id: &str, config: &Value) -> bool {
57 config["allowGuests"] == true && (id == "shale" || id.starts_with("shale-preview-"))57 config["allowGuests"] == true && (id == "shale" || id.starts_with("shale-preview-"))
58}58}
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)> {
61 if next.len() > 8192 || next.contains('\\') || next.chars().any(char::is_control) {61 if next.len() > 8192 || next.contains('\\') || next.chars().any(char::is_control) {
62 return Err(invalid("invalid_request"));62 return Err(invalid("invalid_request"));
63 }63 }
...@@ -79,14 +79,28 @@ pub(crate) fn guest_target(auth: &auth::Store, next: &str) -> Result<String> {...@@ -79,14 +79,28 @@ pub(crate) fn guest_target(auth: &auth::Store, next: &str) -> Result<String> {
79 .map(String::as_str)79 .map(String::as_str)
80 .unwrap_or_default();80 .unwrap_or_default();
81 let config = client(&auth.db.lock().unwrap(), id)?;81 let config = client(&auth.db.lock().unwrap(), id)?;
82 if !guests_allowed(id, &config)82 if query.get("response_type").map(String::as_str) != Some("code")
83 || query.get("response_type").map(String::as_str) != Some("code")
84 || !array(&config["redirectUris"])83 || !array(&config["redirectUris"])
85 .iter()84 .iter()
86 .any(|v| v.as_str() == query.get("redirect_uri").map(String::as_str))85 .any(|v| v.as_str() == query.get("redirect_uri").map(String::as_str))
87 {86 {
88 return Err(invalid("access_denied"));87 return Err(invalid("access_denied"));
89 }88 }
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 }
90 Ok(format!(104 Ok(format!(
91 "{}?{}",105 "{}?{}",
92 target.path(),106 target.path(),
dashboard/web/pages/SignIn.css+4-4
...@@ -4,11 +4,11 @@ body { font: 16px "Name Sans", sans-serif; }...@@ -4,11 +4,11 @@ body { font: 16px "Name Sans", sans-serif; }
4.pf-v5-c-login__main button, .pf-v5-c-login__main input { font-family: inherit; }4.pf-v5-c-login__main button, .pf-v5-c-login__main input { font-family: inherit; }
5.pf-v5-c-login__main input[type="submit"] { cursor: pointer; }5.pf-v5-c-login__main input[type="submit"] { cursor: pointer; }
6.pf-v5-c-login__main [aria-disabled="true"], .pf-v5-c-login__main :disabled { opacity: .6; cursor: wait; }6.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); }
10.setup-intro { margin-bottom: 1rem; text-align: center; }7.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; }
12.pf-v5-c-login__main .guest-providers a {12.pf-v5-c-login__main .guest-providers a {
13 display: grid;13 display: grid;
14 grid-template-columns: 20px minmax(0, 1fr) 20px;14 grid-template-columns: 20px minmax(0, 1fr) 20px;
dashboard/web/pages/SignIn.tsx+29-17
...@@ -10,12 +10,13 @@ export function SignIn() {...@@ -10,12 +10,13 @@ export function SignIn() {
10 const setup = params.get("setup");10 const setup = params.get("setup");
11 const statusQuery = new URLSearchParams();11 const statusQuery = new URLSearchParams();
12 if (setup) statusQuery.set("setup", setup);12 if (setup) statusQuery.set("setup", setup);
13 if (params.get("flow")) statusQuery.set("flow", params.get("flow")!);
13 if (params.get("next")) statusQuery.set("next", params.get("next")!);14 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}`));
15 const [username, setUsername] = createSignal("");16 const [username, setUsername] = createSignal("");
16 const [password, setPassword] = createSignal("");17 const [password, setPassword] = createSignal("");
17 const [email, setEmail] = createSignal("");18 const [email, setEmail] = createSignal("");
18 const [remember, setRemember] = createSignal(false);19 const [passwordStep, setPasswordStep] = createSignal(false);
19 const [visible, setVisible] = createSignal(false);20 const [visible, setVisible] = createSignal(false);
20 const [busy, setBusy] = createSignal(false);21 const [busy, setBusy] = createSignal(false);
21 const [error, setError] = createSignal(params.has("guest_error") ? "Guest sign-in wasn't completed. Choose a provider to try again." : "");22 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() {...@@ -23,7 +24,7 @@ export function SignIn() {
23 let conditional: AbortController | undefined;24 let conditional: AbortController | undefined;
24 let disposed = false;25 let disposed = false;
25 const body = () => ({ csrf: status()!.csrf, username: username(), password: password(), email: email(), setup,26 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") ?? "/" });
27 const passkey = async (automatic = false) => {28 const passkey = async (automatic = false) => {
28 if (!status() || busy()) return;29 if (!status() || busy()) return;
29 conditional?.abort();30 conditional?.abort();
...@@ -41,20 +42,27 @@ export function SignIn() {...@@ -41,20 +42,27 @@ export function SignIn() {
41 });42 });
42 if (!(credential instanceof PublicKeyCredential)) throw new Error("Passkey sign-in was canceled. Try again or use your password.");43 if (!(credential instanceof PublicKeyCredential)) throw new Error("Passkey sign-in was canceled. Try again or use your password.");
43 selected = true; setBusy(true);44 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() });
45 window.location.assign(result.next);46 window.location.assign(result.next);
46 } catch (failure) {47 } catch (failure) {
47 if ((!automatic || selected) && !controller.signal.aborted) setError(authReason(failure));48 if ((!automatic || selected) && !controller.signal.aborted) setError(authReason(failure));
48 } finally { if (!automatic || selected) setBusy(false); }49 } finally { if (!automatic || selected) setBusy(false); }
49 };50 };
50 createEffect(() => {51 createEffect(() => {
51 if (!setup && status()) void (async () => {52 if (!setup && !passwordStep() && status()) void (async () => {
52 if (await PublicKeyCredential.isConditionalMediationAvailable?.() && !disposed) await passkey(true);53 if (await PublicKeyCredential.isConditionalMediationAvailable?.() && !disposed && !passwordStep()) await passkey(true);
53 })().catch(() => {});54 })().catch(() => {});
54 });55 });
55 const complete = async () => {56 const complete = async () => {
56 if (!status() || busy()) return;57 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);
58 try {66 try {
59 const result = await authRequest<{ next: string }>(setup ? "setup" : "password", body());67 const result = await authRequest<{ next: string }>(setup ? "setup" : "password", body());
60 window.location.assign(result.next);68 window.location.assign(result.next);
...@@ -73,18 +81,25 @@ export function SignIn() {...@@ -73,18 +81,25 @@ export function SignIn() {
73 <main class="pf-v5-c-login__main">81 <main class="pf-v5-c-login__main">
74 <div id="kc-header"><div id="kc-header-wrapper"><div class="kc-logo-text"><span>snow sign on</span></div></div></div>82 <div id="kc-header"><div id="kc-header-wrapper"><div class="kc-logo-text"><span>snow sign on</span></div></div></div>
75 <div class="card-pf">83 <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>
77 <div id="kc-content"><div id="kc-content-wrapper">85 <div id="kc-content"><div id="kc-content-wrapper">
78 <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>86 <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>
79 <Show when={notice()}><div class="alert-info pf-v5-c-alert" role="status"><span class="pf-v5-c-alert__title">{notice()}</span></div></Show>87 <Show when={notice()}><div class="alert-info pf-v5-c-alert" role="status"><span class="pf-v5-c-alert__title">{notice()}</span></div></Show>
80 <Show when={!status.error} fallback={<button class="pf-v5-c-button pf-m-primary pf-m-block" onClick={() => refetch()}>try again</button>}>88 <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>
81 <div id="kc-form"><div id="kc-form-wrapper">94 <div id="kc-form"><div id="kc-form-wrapper">
82 <form id="kc-form-login" onSubmit={(event) => { event.preventDefault(); void complete(); }}>95 <form id="kc-form-login" onSubmit={(event) => { event.preventDefault(); void complete(); }}>
83 <Show when={setup} fallback={96 <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>}>
84 <div class="pf-v5-c-form__group">98 <div class="pf-v5-c-form__group">
85 <label for="username" class="pf-v5-c-form__label">username or email</label>99 <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())} />
87 </div>101 </div>
102 </Show>
88 }>103 }>
89 <p class="setup-intro">Your account is <b>{status()?.setup ?? "…"}</b>. Add your email and choose a password.</p>104 <p class="setup-intro">Your account is <b>{status()?.setup ?? "…"}</b>. Add your email and choose a password.</p>
90 <div class="pf-v5-c-form__group">105 <div class="pf-v5-c-form__group">
...@@ -92,29 +107,26 @@ export function SignIn() {...@@ -92,29 +107,26 @@ export function SignIn() {
92 <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 />107 <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 />
93 </div>108 </div>
94 </Show>109 </Show>
110 <Show when={setup || passwordStep()}>
95 <div class="pf-v5-c-form__group">111 <div class="pf-v5-c-form__group">
96 <label for="password" class="pf-v5-c-form__label">{setup ? "choose a password" : "password"}</label>112 <label for="password" class="pf-v5-c-form__label">{setup ? "choose a password" : "password"}</label>
97 <div class="pf-v5-c-input-group">113 <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()); }} />
99 <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>115 <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>
100 </div>116 </div>
101 <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>117 <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>
102 </div>118 </div>
103 <Show when={!setup}><div class="pf-v5-c-form__group">119 <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>
105 <div><span><a href="#" onClick={(event) => { event.preventDefault(); setNotice("Ask Clover for a password reset link."); }}>forgot password?</a></span></div>120 <div><span><a href="#" onClick={(event) => { event.preventDefault(); setNotice("Ask Clover for a password reset link."); }}>forgot password?</a></span></div>
106 </div></Show>121 </div></Show>
122 </Show>
107 <div id="kc-form-buttons" class="pf-v5-c-form__group">123 <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()} />
109 </div>125 </div>
110 </form>126 </form>
111 </div></div>127 </div></div>
112 <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>128 <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}>129
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>
118 </Show>130 </Show>
119 </div></div>131 </div></div>
120 </div>132 </div>
tools/dashboard-auth-test.py+1
...@@ -172,6 +172,7 @@ def main():...@@ -172,6 +172,7 @@ def main():
172 file_cookies = {}172 file_cookies = {}
173 handoff = request('/auth/file/sign-in?rd=%2Fclover%2FPublic%2F', cookies=file_cookies, status=302, host=file)173 handoff = request('/auth/file/sign-in?rd=%2Fclover%2FPublic%2F', cookies=file_cookies, status=302, host=file)
174 flow = file_cookies['__Host-snow-flow']174 flow = file_cookies['__Host-snow-flow']
175 assert request('/auth/status?flow=' + flow, cookies={})['service'] == 'copyparty'
175 callback = request('/auth/continue?flow=' + flow, cookies=cookies, status=302)['location']176 callback = request('/auth/continue?flow=' + flow, cookies=cookies, status=302)['location']
176 request('/auth/file/callback?' + callback.split('?', 1)[1], cookies={}, status=403, host=file)177 request('/auth/file/callback?' + callback.split('?', 1)[1], cookies={}, status=403, host=file)
177 finished = request('/auth/file/callback?' + callback.split('?', 1)[1], cookies=file_cookies, status=302, host=file)178 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():...@@ -180,7 +180,11 @@ def main():
180 input=json.dumps({'provider': 'github', 'clientId': 'fixture', 'clientSecret': secret}))180 input=json.dumps({'provider': 'github', 'clientId': 'fixture', 'clientSecret': secret}))
181 assert configured.returncode == 0, configured.stderr181 assert configured.returncode == 0, configured.stderr
182 target = authorize_path(minimal)182 target = authorize_path(minimal)
183 assert request('/auth/status')['service'] == 'snow globe'
183 assert request('/auth/status')['providers'] == []184 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'
184 assert request('/auth/status?' + urllib.parse.urlencode({'next': target}))['providers'] == [{'id':'github','name':'GitHub'}]188 assert request('/auth/status?' + urllib.parse.urlencode({'next': target}))['providers'] == [{'id':'github','name':'GitHub'}]
185 request('/auth/guest/start/github?' + urllib.parse.urlencode({'next': '/'}), status=400)189 request('/auth/guest/start/github?' + urllib.parse.urlencode({'next': '/'}), status=400)
186 started = request('/auth/guest/start/github?' + urllib.parse.urlencode({'next': target}), status=302)190 started = request('/auth/guest/start/github?' + urllib.parse.urlencode({'next': target}), status=302)