signup: GitHub sign-ups set a password too
SSO registration accepted no password, so GitHub-made accounts got a one-time app password and nothing to log into Bluesky with afterwards. complete-registration now takes an optional `password`, checks it with validate_password before any account work, and stores it with set_new_user_password once the account exists. With a password set, no initial app password is generated; if storing it fails the old app password path still runs so the user is never locked out. The SSO completion page asks for the password (with a show toggle), and both forms now check the server's rules client-side (8+ characters, upper and lowercase, a digit) with a hint that says so. Tests: sso.rs covers sign-up with a password (no appPassword, then createSession works) and a weak password (400, pending registration kept); vitest covers the client-side rule and the SSO page sending the password.
18 unmodified lines192021222324252627810 unmodified linesExpand all838839840841842843844845846847848113 unmodified linesExpand all962963964965966967968969970971972973974975976977231 unmodified linesExpand all12091210121112121201120212031213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512051206120712081209121012111236123712381239124012411242124312441245124612471248124912501251125212531213121412151216121712181219122012541255125612571258125912601261126295 unmodified lines1358135913601361132513261362136313641365136613674 unmodified lines13721373137413751339134013761377137813791380138133 unmodified lines141514161417141813821383141914201421142218 unmodified lines};use tranquil_pds::sso::SsoConfig;use tranquil_pds::state::AppState;use tranquil_pds::turnstile::require_signup_captcha;use tranquil_pds::validation::validate_password;
fn generate_nonce() -> String { use rand::RngCore; let mut bytes = [0u8; 16];810 unmodified linesExpand all pub telegram_username: Option<String>, pub signal_username: Option<String>, pub did_type: Option<String>, pub did: Option<String>, /// Account password. When given, the account can sign in with it and no /// initial app password is generated. pub password: Option<String>,}
#[derive(Debug, Serialize)]#[serde(rename_all = "camelCase")]113 unmodified linesExpand all { return Err(ApiError::InvalidEmail); }
let password_hash = match input.password.as_deref() { Some(password) => { validate_password(password).map_err(|e| ApiError::InvalidRequest(e.to_string()))?; Some(tranquil_api::common::hash_password_async(password).await?) } None => None, };
let invite_registration = check_registration_invite(&state, input.invite_code.as_deref()).await?;
let reserved = state231 unmodified linesExpand all tracing::warn!("Failed to create default profile for {}: {}", did, e); }; }
let app_password = generate_app_password(); let app_password_name = "bsky.app".to_string(); let app_password_hash = tranquil_api::common::hash_or_internal_error(&app_password)?; let password_set = match &password_hash { Some(hash) => match state .repos .user .set_new_user_password(create_result.user_id, hash) .await { Ok(()) => true, Err(e) => { tracing::error!("Failed to set password for SSO account: {:?}", e); false } }, None => false, };
let initial_app_password = if password_set { None } else { let app_password = generate_app_password(); let app_password_name = "bsky.app".to_string(); let app_password_hash = tranquil_api::common::hash_or_internal_error(&app_password)?;
let app_password_data = tranquil_db_traits::AppPasswordCreate { user_id: create_result.user_id, name: app_password_name.clone(), password_hash: app_password_hash, privilege: tranquil_db_traits::AppPasswordPrivilege::Standard, scopes: None, created_by_controller_did: None, let app_password_data = tranquil_db_traits::AppPasswordCreate { user_id: create_result.user_id, name: app_password_name.clone(), password_hash: app_password_hash, privilege: tranquil_db_traits::AppPasswordPrivilege::Standard, scopes: None, created_by_controller_did: None, }; if let Err(e) = state .repos .session .create_app_password(&app_password_data) .await { tracing::warn!("Failed to create initial app password: {:?}", e); } Some((app_password, app_password_name)) }; if let Err(e) = state .repos .session .create_app_password(&app_password_data) .await { tracing::warn!("Failed to create initial app password: {:?}", e); }
let (app_password, app_password_name) = initial_app_password .map(|(password, name)| (password.into_inner(), name)) .unzip();
let is_standalone = pending_preview.request_uri == "standalone";
if !is_standalone { let request_id = RequestId::new(pending_preview.request_uri.clone());95 unmodified lines handle, redirect_url: "/app/dashboard".to_string(), access_jwt: Some(access_meta.token), refresh_jwt: Some(refresh_meta.token), app_password: Some(app_password.into_inner()), app_password_name: Some(app_password_name), app_password, app_password_name, })); }
return Ok(Json(CompleteRegistrationResponse {4 unmodified lines urlencoding::encode(&pending_preview.request_uri) ), access_jwt: None, refresh_jwt: None, app_password: Some(app_password.into_inner()), app_password_name: Some(app_password_name), app_password, app_password_name, })); }
if let Some(uid) = user_id {33 unmodified lines handle, redirect_url, access_jwt: None, refresh_jwt: None, app_password: Some(app_password.into_inner()), app_password_name: Some(app_password_name), app_password, app_password_name, }))}1283 unmodified linesExpand all128412851286128712881288128912901291129217 unmodified lines1310131113121313131413141315131613171318131931 unmodified lines1283 unmodified linesExpand all "email": "Email", "emailPlaceholder": "you@example.com", "emailHint": "we'll send a code to make sure it's you", "password": "Password", "passwordHint": "8 or more characters. it also logs you into Bluesky", "passwordHint": "8+ characters with upper and lowercase letters and a number. it also logs you into Bluesky", "showPassword": "show", "hidePassword": "hide", "showPasswordLabel": "Show password", "hidePasswordLabel": "Hide password",17 unmodified lines "inviteRequired": "This server needs an invite code", "inviteInvalid": "That invite code didn't work", "captchaRequired": "Please complete the human check", "captchaFailed": "The human check failed, please try it again", "generic": "Something went wrong, please try again" "generic": "Something went wrong, please try again", "passwordWeak": "Use upper and lowercase letters and at least one number" }, "verify": { "kicker": "almost there", "title": "Check your email",31 unmodified lines22 unmodified lines232425262728293031351 unmodified linesExpand all22 unmodified lines handleTaken: 'handle', emailRequired: 'email', passwordRequired: 'password', passwordLength: 'password', passwordWeak: 'password', inviteRequired: 'invite-code', captchaRequired: 'submit', }
351 unmodified linesExpand all3 unmodified lines45678910111221 unmodified lines3435363738394041424344186 unmodified linesExpand all23123223323423523623723823924024124224324424524612 unmodified lines259260261262263264265266267159 unmodified linesExpand all42742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346 unmodified lines3 unmodified lines import { toast } from '../lib/toast.svelte' import SsoIcon from '../components/SsoIcon.svelte' import TurnstileWidget from '../components/TurnstileWidget.svelte' import { captchaHeaders } from '../lib/turnstile' import { passwordProblem } from '../lib/registration/validate'
interface PendingRegistration { request_uri: string provider: string21 unmodified lines let handle = $state('') let email = $state('') let providerEmailOriginal = $state<string | null>(null) let inviteCode = $state('') let password = $state('') let showPassword = $state(false) let passwordError = $state<string | null>(null)
let handleAvailable = $state<boolean | null>(null) let checkingHandle = $state(false) let handleError = $state<string | null>(null)186 unmodified linesExpand all toast.error($_('signup.errors.emailRequired')) return }
const weakPassword = passwordProblem(password) if (weakPassword) { passwordError = $_(`signup.errors.${weakPassword}`) document.getElementById('password')?.focus() return } passwordError = null
const fullHandle = !handle.includes('.') && selectedDomain ? `${handle.trim()}.${selectedDomain}` : handle.trim() submitting = true12 unmodified lines email: email || null, invite_code: inviteCode || null, verification_channel: 'email', did_type: 'plc', password, }), })
const data = await response.json()159 unmodified linesExpand all {/if} </p> </div>
<div> <label class="signup-label" for="password">{$_('signup.password')}</label> <div class="wisp-field" class:bad={!!passwordError}> <input id="password" type={showPassword ? 'text' : 'password'} autocomplete="new-password" bind:value={password} oninput={() => (passwordError = null)} disabled={submitting} required aria-describedby="password-hint" aria-invalid={!!passwordError} /> <button type="button" class="reveal" aria-pressed={showPassword} aria-label={showPassword ? $_('signup.hidePasswordLabel') : $_('signup.showPasswordLabel')} onclick={() => (showPassword = !showPassword)} > {showPassword ? $_('signup.hidePassword') : $_('signup.showPassword')} </button> </div> <p class="signup-hint" class:bad={!!passwordError} id="password-hint" aria-live="polite"> {passwordError ?? $_('signup.passwordHint')} </p> </div>
{#if serverInfo?.inviteCodeRequired} <div> <label class="signup-label" for="invite-code">{$_('signup.inviteCode')}</label> <div class="wisp-field">46 unmodified lines21 unmodified lines22232425262728293030313233343572 unmodified lines10810911011111211311411511611730 unmodified lines14814915015114915215315415515668 unmodified lines22522622722822622923023123223328 unmodified lines26226326426526326626726826927029 unmodified lines3003013023033013043053063073086 unmodified lines31531631731831631932032132232315 unmodified lines33934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838921 unmodified lines unsafeAsHandle, unsafeAsRefreshToken,} from "../lib/types/branded.ts";import Register from "../routes/Register.svelte";import SsoRegisterComplete from "../routes/SsoRegisterComplete.svelte";
const info = (overrides: Partial<RegistrationInfo> = {}): RegistrationInfo => ({ handle: "fern", email: "fern@example.com", password: "correct horse", password: "Correct horse 9", inviteCode: "", ...overrides,});
72 unmodified lines
it("always requires a password of 8+ characters", () => { expect(validateSignup(info({ password: "" }), open, ok)).toBe("passwordRequired"); expect(validateSignup(info({ password: "short" }), open, ok)).toBe("passwordLength"); expect(validateSignup(info({ password: "all lowercase 1" }), open, ok)).toBe("passwordWeak"); expect(validateSignup(info({ password: "NoDigitsHere" }), open, ok)).toBe("passwordWeak"); });
it("requires a handle without dots and an email", () => { expect(validateSignup(info({ handle: " " }), open, ok)).toBe("handleRequired");30 unmodified lines
async function fillForm() { await fireEvent.input(await screen.findByLabelText(/your handle/i), { target: { value: "fern" } }); await fireEvent.input(screen.getByLabelText(/^email$/i), { target: { value: "fern@example.com" } }); await fireEvent.input(screen.getByLabelText(/^password$/i), { target: { value: "correct horse" } }); await fireEvent.input(screen.getByLabelText(/^password$/i), { target: { value: "Correct horse 9" } }); }
it("has no identity-type, verification-method or tab controls", async () => { mockServer();68 unmodified lines expect(screen.getByText(/fern@example.com/)).toBeInTheDocument(); expect(sent).toMatchObject({ handle: "fern.fae.mom", email: "fern@example.com", password: "correct horse", password: "Correct horse 9", didType: "plc", verificationChannel: "email", }); });28 unmodified lines
describe("passkey offer after verification", () => { function verifiedFlow() { const flow = createRegistrationFlow("fae.mom"); flow.info.password = "correct horse"; flow.info.password = "Correct horse 9"; flow.state.account = { did: unsafeAsDid("did:plc:fern"), handle: unsafeAsHandle("fern.fae.mom") }; flow.state.step = "verify"; mockEndpoint("com.atproto.server.confirmSignup", () => jsonResponse({29 unmodified lines return jsonResponse(endpoint.endsWith("startPasskeyRegistration") ? creationOptions : { id: "1", credentialId: "cred-1" }); }); } const flow = createRegistrationFlow("fae.mom"); flow.info.password = "correct horse"; flow.info.password = "Correct horse 9"; flow.state.account = { did: unsafeAsDid("did:plc:fern"), handle: unsafeAsHandle("fern.fae.mom") }; flow.state.session = { accessJwt: unsafeAsAccessToken("access-1"), refreshJwt: unsafeAsRefreshToken("refresh-1") }; flow.state.step = "passkey";
6 unmodified lines ]); expect(calls.every((c) => c.auth?.endsWith(" access-1"))).toBe(true); expect(calls[1].body).toMatchObject({ friendlyName: "laptop", credential: { id: "cred-1" } }); expect(flow.state.step).toBe("redirect-to-dashboard"); expect(flow.info.password).toBe("correct horse"); expect(flow.info.password).toBe("Correct horse 9"); });
it("stays on the offer with a message when the user cancels", async () => { installFakeCredentials();15 unmodified lines await flow.skipPasskey(); expect(flow.state.step).toBe("redirect-to-dashboard"); });});
describe("GitHub sign-up sets a password", () => { function mockSsoPage() { setLocation("/app/oauth/sso-register", "?token=pending-token"); mockEndpoint("/oauth/sso/pending-registration", () => jsonResponse({ request_uri: "urn:mock", provider: "github", provider_user_id: "1", provider_username: "fern", provider_email: "fern@example.com", provider_email_verified: true, })); mockEndpoint("com.atproto.server.describeServer", () => jsonResponse(mockData.describeServer({ availableUserDomains: ["fae.mom"] }))); mockEndpoint("/oauth/sso/check-handle-available", () => jsonResponse({ available: true })); }
it("refuses a weak password before calling the server", async () => { mockSsoPage(); const complete = vi.fn(() => jsonResponse({})); mockEndpoint("/oauth/sso/complete-registration", complete); render(SsoRegisterComplete); const submit = await screen.findByRole("button", { name: /create my account/i }); await waitFor(() => expect(submit).toBeEnabled()); await fireEvent.input(screen.getByLabelText(/^password$/i), { target: { value: "lowercase only" } }); await fireEvent.click(submit); expect(await screen.findByText(/upper and lowercase letters and at least one number/i)).toBeInTheDocument(); expect(complete).not.toHaveBeenCalled(); });
it("sends the password with the registration", async () => { mockSsoPage(); let sent: Record<string, unknown> | null = null; mockEndpoint("/oauth/sso/complete-registration", (_url, init) => { sent = JSON.parse(init?.body as string); return jsonResponse({ did: "did:plc:fern", handle: "fern.fae.mom", redirectUrl: "/app/oauth/consent?request_uri=urn:mock" }); }); Object.defineProperty(globalThis.location, "href", { set: () => {}, get: () => "http://localhost:3000/", configurable: true }); render(SsoRegisterComplete); const submit = await screen.findByRole("button", { name: /create my account/i }); await waitFor(() => expect(submit).toBeEnabled()); await fireEvent.input(screen.getByLabelText(/^password$/i), { target: { value: "Correct horse 9" } }); await fireEvent.click(submit); await waitFor(() => expect(sent).toMatchObject({ password: "Correct horse 9", did_type: "plc", verification_channel: "email" })); });});147 unmodified linesExpand all1481491501511521531541551563 unmodified lines147 unmodified linesExpand all await waitFor(() => expect(submit).toBeDisabled());
fake.rendered[0].callback("solved-token"); await waitFor(() => expect(submit).toBeEnabled()); await fireEvent.input(screen.getByLabelText(/^password$/i), { target: { value: "Correct horse 9" } }); await fireEvent.click(submit);
await waitFor(() => expect(tokens).toEqual(["solved-token"])); await waitFor(() => expect(fake.resets).toEqual(["widget-1"]));3 unmodified lines735 unmodified linesExpand all736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841165 unmodified linesExpand all735 unmodified linesExpand all let ext_id = &external_identities[0]; assert_eq!(ext_id.provider_user_id.as_str(), provider_user_id);}
async fn pending_sso_registration(repos: &tranquil_db::PostgresRepositories) -> (String, String) { let token = format!("pw_reg_token_{}", uuid::Uuid::new_v4().simple()); let provider_user_id = format!("pw_user_{}", uuid::Uuid::new_v4().simple()); let provider_email = format!("sso_pw_{}@example.com", uuid::Uuid::new_v4().simple()); 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(); repos .sso .create_pending_registration( &token, &request_uri, SsoProviderType::Github, &provider_user_id, Some("ssopwuser"), Some(&provider_email), true, ) .await .unwrap(); (token, provider_email)}
#[tokio::test]async fn test_sso_complete_registration_with_password() { let url = base_url().await; let repos = get_test_repos().await; let client = client(); let (token, provider_email) = pending_sso_registration(repos).await; let handle_prefix = format!("ssopw{}", &uuid::Uuid::new_v4().simple().to_string()[..6]); let password = "Correct-Horse-42";
let res = client .post(format!("{}/oauth/sso/complete-registration", url)) .json(&json!({ "token": token, "handle": handle_prefix, "email": provider_email, "verification_channel": "email", "password": password })) .send() .await .unwrap(); assert_eq!(res.status(), StatusCode::OK); let body: Value = res.json().await.unwrap(); assert!( body.get("appPassword").is_none(), "No app password when the account has a password, got: {:?}", body ); let handle = body["handle"].as_str().unwrap().to_string();
let login = client .post(format!("{}/xrpc/com.atproto.server.createSession", url)) .json(&json!({ "identifier": handle, "password": password })) .send() .await .unwrap(); assert_eq!( login.status(), StatusCode::OK, "SSO account should sign in with its password: {:?}", login.text().await );}
#[tokio::test]async fn test_sso_complete_registration_rejects_weak_password() { let url = base_url().await; let repos = get_test_repos().await; let client = client(); let (token, provider_email) = pending_sso_registration(repos).await; let handle_prefix = format!("ssoweak{}", &uuid::Uuid::new_v4().simple().to_string()[..6]);
let res = client .post(format!("{}/oauth/sso/complete-registration", url)) .json(&json!({ "token": token, "handle": handle_prefix, "email": provider_email, "verification_channel": "email", "password": "short" })) .send() .await .unwrap(); assert_eq!(res.status(), StatusCode::BAD_REQUEST); assert!( repos.sso.get_pending_registration(&token).await.unwrap().is_some(), "A rejected password must leave the pending registration usable" );}
#[tokio::test]async fn test_sso_complete_registration_multichannel_discord() { let url = base_url().await; let repos = get_test_repos().await; let client = client();165 unmodified linesExpand all38 unmodified lines3940414243444538 unmodified lines | "handleTaken" | "emailRequired" | "passwordRequired" | "passwordLength" | "passwordWeak" | "inviteRequired" | "captchaRequired";24 unmodified lines2526272829302930313233341 unmodified line363738394041424344454647484950515224 unmodified lines if (!handle) return "handleRequired"; if (handle.includes(".")) return "handleNoDots"; if (extra.handleAvailable === false) return "handleTaken"; if (!info.email.trim()) return "emailRequired"; if (!info.password) return "passwordRequired"; if (info.password.length < MIN_PASSWORD_LENGTH) return "passwordLength"; const passwordError = passwordProblem(info.password); if (passwordError) return passwordError; if (requirements.inviteCodeRequired && !info.inviteCode.trim()) { return "inviteRequired"; } if (requirements.captchaRequired && !extra.captchaToken) {1 unmodified line } return null;}
// Mirrors the server's validate_password: length plus mixed case and a// digit. Its common-password check stays server-side.export function passwordProblem(password: string): SignupError | null { if (!password) return "passwordRequired"; if (password.length < MIN_PASSWORD_LENGTH) return "passwordLength"; const strong = /[a-z]/.test(password) && /[A-Z]/.test(password) && /[0-9]/.test(password); return strong ? null : "passwordWeak";}
export function passkeysSupported(): boolean { return typeof globalThis.PublicKeyCredential === "function";}