oauth: let clients go straight to an SSO provider
/oauth/authorize?request_uri=…&sso=github now forwards `sso` to the login page (or, for prompt=create, the sign-up page) when that provider is enabled; unknown or disabled values are ignored. The page drops the param from the address bar and starts the provider right away, so a client like wisp.place can offer "Sign in with GitHub" without a second click on the PDS. A failed attempt lands on the normal page and never retries on its own.
9 unmodified lines10111213141516171718192021221 unmodified line2425262728293031324 unmodified lines373839403941424344454 unmodified lines5051525352545556575824 unmodified lines9 unmodified lines }
interface Props { disabled?: boolean autoStart?: string | null onError: (message: string) => void }
let { disabled = false, onError }: Props = $props() let { disabled = false, autoStart = null, onError }: Props = $props()
let providers = $state<SsoProvider[]>([]) let initiating = $state<string | null>(null)
1 unmodified line fetch('/oauth/sso/providers') .then((response) => (response.ok ? response.json() : { providers: [] })) .then((data: { providers?: SsoProvider[] }) => { providers = (data.providers ?? []).toSorted((a, b) => a.name.localeCompare(b.name)) if (autoStart && providers.some((p) => p.provider === autoStart)) start(autoStart, false) }) .catch(() => { providers = [] })4 unmodified lines url.searchParams.set('request_uri', await getOAuthRequestUri('create')) window.location.href = url.toString() }
async function start(provider: string) { async function start(provider: string, retryExpired = true) { initiating = provider try { const response = await fetch('/oauth/sso/initiate', { method: 'POST',4 unmodified lines if (response.ok && data.redirect_url) { window.location.href = data.redirect_url return } if (!response.ok) { if (!response.ok && retryExpired) { await restartWithFreshRequest() return } onError($_('signup.errors.generic'))24 unmodified lines12345678910111213/** * Reads the `sso` query param (set by /oauth/authorize when the client asked * to go straight to a provider) and removes it from the address bar, so a * failed or cancelled attempt never starts again on reload. */export function takeSsoShortcut(): string | null { const url = new URL(globalThis.location.href); const provider = url.searchParams.get("sso"); if (!provider) return null; url.searchParams.delete("sso"); globalThis.history.replaceState(globalThis.history.state, "", url); return provider;}7 unmodified lines891011121314151658 unmodified lines7576777879808182831 unmodified line8586878889909192939495436 unmodified linesExpand all7 unmodified lines type WebAuthnRequestOptionsResponse, } from '../lib/webauthn' import SsoIcon from '../components/SsoIcon.svelte' import { getRandomHandle } from '../components/RandomHandle.svelte' import { takeSsoShortcut } from '../lib/sso'
const handlePlaceholder = getRandomHandle()
interface SsoProvider {58 unmodified lines fetchSsoProviders() })
async function fetchSsoProviders() { const shortcut = takeSsoShortcut() try { const response = await fetch('/oauth/sso/providers') if (response.ok) { const data = await response.json()1 unmodified line } } catch { ssoProviders = [] } if (shortcut && ssoProviders.some((p) => p.provider === shortcut)) { handleSsoLogin(shortcut) } }
async function handleSsoLogin(provider: string) { const requestUri = getRequestUri()436 unmodified linesExpand all14 unmodified lines15161718192021222313 unmodified lines373839404142434445170 unmodified linesExpand all216217218219218220221222223224160 unmodified linesExpand all14 unmodified lines import type { ServerDescription } from '../lib/types/api' import SsoSignupButtons from '../components/SsoSignupButtons.svelte' import TurnstileWidget from '../components/TurnstileWidget.svelte' import { ensureRequestUri, getRequestUriFromUrl } from '../lib/oauth' import { takeSsoShortcut } from '../lib/sso'
const FIELD_FOR_ERROR: Record<SignupError, string> = { handleRequired: 'handle', handleNoDots: 'handle',13 unmodified lines let showPassword = $state(false) let invalidField = $state<string | null>(null) let captchaResetKey = $state(0) let completing = false const ssoShortcut = takeSsoShortcut()
const domains = $derived(serverInfo?.availableUserDomains ?? []) const requirements = $derived(requirementsFrom(serverInfo))
170 unmodified linesExpand all <p class="signup-sub">{$_('signup.forClient', { values: { client: clientName } })}</p> {/if} </header>
<SsoSignupButtons disabled={flow.state.submitting} onError={(m) => flow?.setError(m)} /> <SsoSignupButtons disabled={flow.state.submitting} autoStart={ssoShortcut} onError={(m) => flow?.setError(m)} />
<form class="signup-form" onsubmit={handleSubmit} novalidate> <div> <label class="signup-label" for="handle">{$_('signup.handle')}</label>160 unmodified linesExpand all385 unmodified linesExpand all386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437385 unmodified linesExpand all await fireEvent.click(submit); await waitFor(() => expect(sent).toMatchObject({ password: "Correct horse 9", did_type: "plc", verification_channel: "email" })); });});
describe("sso shortcut from /oauth/authorize", () => { it("starts GitHub sign-up straight away and drops the param", async () => { setLocation("/app/oauth/register", "?request_uri=urn:mock:req&sso=github"); Object.defineProperty(globalThis.location, "href", { set: () => {}, get: () => "http://localhost:3000/app/oauth/register?request_uri=urn:mock:req&sso=github", configurable: true, }); const replaced: string[] = []; vi.spyOn(globalThis.history, "replaceState").mockImplementation((_s, _t, url) => { replaced.push(String(url)); }); mockEndpoint("com.atproto.server.describeServer", () => jsonResponse(mockData.describeServer({ availableUserDomains: ["fae.mom"] }))); mockEndpoint("/oauth/sso/providers", () => jsonResponse({ providers: [{ provider: "github", name: "GitHub", icon: "github" }] })); mockEndpoint("/oauth/authorize", () => jsonResponse({ client_name: "wisp.place" })); let body: Record<string, unknown> | null = null; mockEndpoint("/oauth/sso/initiate", (_url, init) => { body = JSON.parse(init?.body as string); return jsonResponse({ redirect_url: "https://github.com/login/oauth" }); }); render(Register); await waitFor(() => expect(body).toEqual({ provider: "github", action: "register", request_uri: "urn:mock:req" })); expect(replaced.some((u) => !u.includes("sso="))).toBe(true); });
it("ignores providers the server doesn't offer", async () => { setLocation("/app/oauth/register", "?request_uri=urn:mock:req&sso=discord"); Object.defineProperty(globalThis.location, "href", { set: () => {}, get: () => "http://localhost:3000/app/oauth/register?request_uri=urn:mock:req&sso=discord", configurable: true, }); vi.spyOn(globalThis.history, "replaceState").mockImplementation(() => {}); mockEndpoint("com.atproto.server.describeServer", () => jsonResponse(mockData.describeServer({ availableUserDomains: ["fae.mom"] }))); mockEndpoint("/oauth/sso/providers", () => jsonResponse({ providers: [{ provider: "github", name: "GitHub", icon: "github" }] })); mockEndpoint("/oauth/authorize", () => jsonResponse({ client_name: "wisp.place" })); const initiate = vi.fn(() => jsonResponse({})); mockEndpoint("/oauth/sso/initiate", initiate); render(Register); await screen.findByRole("button", { name: /sign up with github/i }); expect(initiate).not.toHaveBeenCalled(); });});1002 unmodified linesExpand all1003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411002 unmodified linesExpand all assert_eq!(res.status(), StatusCode::BAD_REQUEST); let body: Value = res.json().await.unwrap(); assert_eq!(body["error"], "MissingDiscordId");}
#[tokio::test]async fn test_authorize_ignores_sso_shortcut_for_disabled_providers() { let url = base_url().await; let repos = get_test_repos().await; let client = reqwest::Client::builder() .redirect(reqwest::redirect::Policy::none()) .build() .unwrap();
for sso in ["github", "not-a-provider"] { let request_uri = format!("urn:ietf:params:oauth:request_uri:{}", uuid::Uuid::new_v4()); repos .oauth .create_authorization_request(&RequestId::new(&request_uri), &test_request_data()) .await .unwrap();
let res = client .get(format!("{}/oauth/authorize", url)) .query(&[("request_uri", request_uri.as_str()), ("sso", sso)]) .send() .await .unwrap();
assert!(res.status().is_redirection(), "sso={}: {}", sso, res.status()); let location = res.headers()["location"].to_str().unwrap(); assert!( location.starts_with("/app/oauth/login?request_uri=") && !location.contains("sso="), "sso={} should fall back to the plain login page, got {}", sso, location ); }}152 unmodified linesExpand all153154155156157158159160161162163164165166167159160168169170171172173174175176177178179180181182183184185816 unmodified linesExpand all152 unmodified linesExpand all } else { tracing::info!("No login_hint in request"); }
let sso_provider = query .sso .as_deref() .and_then(tranquil_db_traits::SsoProviderType::parse) .filter(|provider| state.sso_manager.get_provider(*provider).is_some()); let sso_param = sso_provider .map(|provider| format!("&sso={}", provider.as_str())) .unwrap_or_default();
if request_data.parameters.prompt == Some(Prompt::Create) { return redirect_see_other(&format!( "/app/oauth/register?request_uri={}", url_encode(&request_uri) "/app/oauth/register?request_uri={}{}", url_encode(&request_uri), sso_param )); }
if sso_provider.is_some() { return redirect_see_other(&format!( "/app/oauth/login?request_uri={}{}", url_encode(&request_uri), sso_param )); }
if !force_new_account && let Some(device_id) = extract_device_cookie(&headers) && let Ok(accounts) = state .repos816 unmodified linesExpand all112 unmodified linesExpand all113114115116117118119120121122123181 unmodified linesExpand all112 unmodified linesExpand allpub struct AuthorizeQuery { pub request_uri: Option<String>, pub client_id: Option<String>, pub new_account: Option<bool>, /// SSO provider to start right away, e.g. `github`. Unknown or disabled /// providers are ignored. pub sso: Option<String>,}
#[derive(Debug, Serialize)]pub struct AuthorizeResponse {181 unmodified linesExpand all