//! The few pages people open in a browser: first-start setup, sign-in for //! the editor (with sign-up through an invite), and the join page an invite //! link opens. use archify_sync_protocol::SetupRequest; use archify_sync_store::{AccountRow, InviteOutcome}; use axum::Form; use axum::extract::{Path, Query, State}; use axum::http::{HeaderValue, StatusCode, header}; use axum::response::{Html, IntoResponse, Redirect, Response}; use serde::{Deserialize, Serialize}; use crate::api; use crate::auth; use crate::error::ApiErr; use crate::metrics::{METRICS, Metrics}; use crate::oidc::{self, Person}; use crate::state::AppState; const CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; form-action 'self' vscode: vscode-insiders: vscodium: cursor: windsurf: http://127.0.0.1:* http://localhost:*; frame-ancestors 'none'; base-uri 'none'"; fn escape(text: &str) -> String { text.replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) .replace('\'', "'") } fn page(state: &AppState, title: &str, body: &str) -> Response { let html = format!( r#" {title} · {server}

{server}

Archify Sync

{body}
"#, title = escape(title), server = escape(&state.config.server_name), ); let mut response = Html(html).into_response(); let headers = response.headers_mut(); headers.insert( header::CONTENT_SECURITY_POLICY, HeaderValue::from_static(CSP), ); headers.insert( header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"), ); headers.insert( header::REFERRER_POLICY, HeaderValue::from_static("no-referrer"), ); headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); response } fn error_line(error: Option<&str>) -> String { error .map(|e| format!(r#"

{}

"#, escape(e))) .unwrap_or_default() } fn hidden(name: &str, value: &str) -> String { format!( r#""#, escape(value) ) } // ------------------------------------------------------------------- setup pub async fn setup_page(State(state): State) -> Response { setup_form(&state, None).await } async fn setup_form(state: &AppState, error: Option<&str>) -> Response { if state.first_start.lock().await.is_none() { return page( state, "Set up", r#"

Set up

This server is set up. Sign in from VS Code with Archify: Sign in to Sync Server…

"#, ); } let body = format!( r#"

Set up the first admin

The first-start code is in the server's log.

{}
"#, error_line(error) ); page(state, "Set up", &body) } #[derive(Deserialize)] pub struct SetupForm { code: String, organisation: String, name: String, display_name: Option, password: String, } pub async fn setup_submit(State(state): State, Form(form): Form) -> Response { let req = SetupRequest { code: form.code, organisation: form.organisation, name: form.name, display_name: form.display_name, password: form.password, }; match api::setup_admin(&state, &req).await { Ok(account) => page( &state, "Set up", &format!( r#"

Done

{} is the admin. In VS Code, run Archify: Sign in to Sync Server… with {}.

"#, escape(&account.display_name), escape(&state.config.public_url) ), ), Err(e) => setup_form(&state, Some(&e.message)).await, } } // --------------------------------------------------------------- sign-in /// The editor's sign-in request, carried through the password form or the identity provider. #[derive(Debug, Deserialize, Serialize, Clone)] pub struct AuthorizeQuery { response_type: String, redirect_uri: String, state: String, code_challenge: String, code_challenge_method: String, invite: Option, } impl AuthorizeQuery { fn to_query_string(&self) -> String { let mut q = url::form_urlencoded::Serializer::new(String::new()); q.append_pair("response_type", &self.response_type) .append_pair("redirect_uri", &self.redirect_uri) .append_pair("state", &self.state) .append_pair("code_challenge", &self.code_challenge) .append_pair("code_challenge_method", &self.code_challenge_method); if let Some(invite) = &self.invite { q.append_pair("invite", invite); } q.finish() } } #[derive(Deserialize)] pub struct AuthorizeForm { #[serde(flatten)] query: AuthorizeQuery, /// `signin` or `signup`. action: String, name: String, password: String, display_name: Option, } fn check_authorize(state: &AppState, q: &AuthorizeQuery) -> Result<(), &'static str> { if q.response_type != "code" || q.code_challenge_method != "S256" || q.code_challenge.len() < 43 { return Err("This sign-in request is not valid. Start again from VS Code."); } if !auth::redirect_allowed(state, &q.redirect_uri) { return Err("This sign-in request does not come from the Archify extension."); } Ok(()) } pub async fn authorize_page( State(state): State, Query(q): Query, ) -> Response { authorize_form(&state, &q, None).await } async fn authorize_form(state: &AppState, q: &AuthorizeQuery, error: Option<&str>) -> Response { if let Err(message) = check_authorize(state, q) { let mut response = page( state, "Sign in", &format!(r#"
{}
"#, error_line(Some(message))), ); *response.status_mut() = StatusCode::BAD_REQUEST; return response; } let fields = [ hidden("response_type", &q.response_type), hidden("redirect_uri", &q.redirect_uri), hidden("state", &q.state), hidden("code_challenge", &q.code_challenge), hidden("code_challenge_method", &q.code_challenge_method), q.invite .as_deref() .map(|i| hidden("invite", i)) .unwrap_or_default(), ] .concat(); // Single sign-on first when the server has it; the password form stays for local accounts. let mut body = state .oidc .as_ref() .map(|o| { format!( r#"

Sign in for VS Code

{}Sign in with {}
"#, error_line(error), escape(&q.to_query_string()), escape(&o.config.label), ) }) .unwrap_or_default(); let (title, error) = if body.is_empty() { ("Sign in for VS Code", error) } else { ("Or with a password", None) }; body.push_str(&format!( r#"

{title}

{}
{fields}{}
"#, error_line(error), hidden("action", "signin"), )); // A valid invite lets someone new make an account in the space's organisation. if let Some(invite) = &q.invite && let Ok(Some((_, space_name))) = state.store.invite_organisation(&auth::sha256(invite)).await { body.push_str(&format!( r#"

New here?

You were invited to {}. Make an account.

{fields}{}
"#, escape(&space_name), hidden("action", "signup"), )); } page(state, "Sign in", &body) } pub async fn authorize_submit( State(state): State, Form(form): Form, ) -> Response { let q = form.query.clone(); if check_authorize(&state, &q).is_err() { return authorize_form(&state, &q, None).await; } let account = match form.action.as_str() { "signup" => sign_up(&state, &form).await, _ => sign_in(&state, &form).await, }; match account { Ok(account) => complete(&state, &q, &account).await, Err(e) => authorize_form(&state, &q, Some(&e.message)).await, } } /// Signed in: joins the invite's space, if any, and sends the editor its one-time code. async fn complete(state: &AppState, q: &AuthorizeQuery, account: &AccountRow) -> Response { if let Some(invite) = &q.invite && let Ok(InviteOutcome::Invalid) = state .store .accept_invite(&auth::sha256(invite), account) .await { tracing::info!(account = %account.id, "signed in with an invite that is no longer valid"); } let code = state .codes .insert(account.id, q.code_challenge.clone(), q.redirect_uri.clone()) .await; let Ok(mut target) = url::Url::parse(&q.redirect_uri) else { return authorize_form(state, q, None).await; }; target .query_pairs_mut() .append_pair("code", &code) .append_pair("state", &q.state); Redirect::to(target.as_str()).into_response() } async fn sign_in(state: &AppState, form: &AuthorizeForm) -> Result { let name = form.name.trim(); if state.sign_in_limits.blocked(name).await { Metrics::inc(&METRICS.sign_in_blocked_total); return Err(ApiErr::too_many( "Too many wrong passwords for this name. Try again in 15 minutes.", )); } let account = state.store.account_by_name(name).await?; match account { Some(a) if a.password_hash .as_deref() .is_some_and(|h| auth::verify_password(&form.password, h)) => { state.sign_in_limits.succeeded(name).await; Ok(a) } _ => { Metrics::inc(&METRICS.sign_in_failures_total); state.sign_in_limits.failed(name).await; // The same work whether or not the name exists. if account_missing(&account) { let _ = auth::hash_password(&form.password); } Err(ApiErr::unauthorized("The name or the password is wrong.")) } } } fn account_missing(account: &Option) -> bool { account .as_ref() .and_then(|a| a.password_hash.as_ref()) .is_none() } async fn sign_up(state: &AppState, form: &AuthorizeForm) -> Result { let invite = form .query .invite .as_deref() .ok_or_else(|| ApiErr::forbidden("New accounts need an invite link."))?; let (organisation, _) = state .store .invite_organisation(&auth::sha256(invite)) .await? .ok_or_else(|| ApiErr::forbidden("This invite link has expired or was used up."))?; let name = form.name.trim(); auth::check_name(name)?; auth::check_new_password(&form.password)?; let hash = auth::hash_password(&form.password)?; let display = form .display_name .as_deref() .map(str::trim) .filter(|s| !s.is_empty()) .unwrap_or(name); Ok(state .store .create_account(organisation, name, display, &hash) .await?) } // ------------------------------------------------------------------- join pub async fn join_page(State(state): State, Path(token): Path) -> Response { let space = state .store .invite_organisation(&auth::sha256(&token)) .await .ok() .flatten(); let Some((_, space_name)) = space else { let mut response = page( &state, "Join", r#"

Join

This invite link has expired or was used up. Ask for a new one.

"#, ); *response.status_mut() = StatusCode::NOT_FOUND; return response; }; let link = |scheme: &str| { let mut url = url::Url::parse(&format!("{scheme}://{}/join", state.config.extension_id)) .expect("valid base"); url.query_pairs_mut() .append_pair("server", &state.config.public_url) .append_pair("invite", &token); escape(url.as_str()) }; let body = format!( r#"

Join {space}

You were invited to work on the model {space} together.

You need the Archify Diagram Viewer extension.

Open in VS Code

Other editors: VSCodium · Cursor

"#, space = escape(&space_name), vscode = link("vscode"), codium = link("vscodium"), cursor = link("cursor"), ); page(&state, "Join", &body) } // --------------------------------------------------------- single sign-on pub async fn oidc_start( State(state): State, Query(q): Query, ) -> Response { let Some(oidc) = &state.oidc else { return StatusCode::NOT_FOUND.into_response(); }; if check_authorize(&state, &q).is_err() { return authorize_form(&state, &q, None).await; } match oidc.start(&state.config.public_url, q.clone()).await { Ok(url) => Redirect::to(&url).into_response(), Err(error) => { tracing::warn!(%error, "single sign-on did not start"); authorize_form( &state, &q, Some(&format!("Single sign-on is not available: {error}.")), ) .await } } } #[derive(Deserialize)] pub struct CallbackQuery { code: Option, state: Option, error: Option, error_description: Option, } fn sso_problem(state: &AppState, message: &str) -> Response { let mut response = page( state, "Sign in", &format!( r#"

Sign in for VS Code

{}

Close this tab and sign in again from VS Code.

"#, error_line(Some(message)) ), ); *response.status_mut() = StatusCode::BAD_REQUEST; response } pub async fn oidc_callback( State(state): State, Query(cb): Query, ) -> Response { let Some(oidc) = &state.oidc else { return StatusCode::NOT_FOUND.into_response(); }; if let Some(error) = cb.error { let detail = cb.error_description.unwrap_or(error); return sso_problem( &state, &format!("The identity provider did not sign you in: {detail}"), ); } let (Some(code), Some(sso_state)) = (cb.code, cb.state) else { return sso_problem(&state, "The identity provider sent no sign-in."); }; let (q, person) = match oidc .finish(&state.config.public_url, &code, &sso_state) .await { Ok(found) => found, Err(error) => { Metrics::inc(&METRICS.sign_in_failures_total); tracing::warn!(%error, "single sign-on failed"); return sso_problem(&state, &format!("Single sign-on failed: {error}.")); } }; match sso_account(&state, &q, &person).await { Ok(account) => { tracing::info!(account = %account.id, name = %account.name, "signed in through single sign-on"); complete(&state, &q, &account).await } Err(e) => { Metrics::inc(&METRICS.sign_in_failures_total); authorize_form(&state, &q, Some(&e.message)).await } } } /// The account of a person the identity provider signed in: found by the /// provider's subject, or made on the first sign-in when an invite or the /// allowed groups let them in. async fn sso_account( state: &AppState, q: &AuthorizeQuery, person: &Person, ) -> Result { let config = &state.oidc.as_ref().expect("checked by the caller").config; if person.subject.is_empty() { return Err(ApiErr::unauthorized( "The identity provider did not say who you are.", )); } let admin = person.in_any(&config.admin_groups); if !config.allowed_groups.is_empty() && !admin && !person.in_any(&config.allowed_groups) { return Err(ApiErr::forbidden(format!( "{} signed you in, but your account there is not in a group this server allows.", config.label ))); } if let Some(mut account) = state .store .account_by_identity(&config.issuer, &person.subject) .await? { if admin && account.role != "admin" { state.store.set_role(account.id, "admin").await?; account.role = "admin".into(); } return Ok(account); } let organisation = match q.invite.as_deref() { Some(invite) => state .store .invite_organisation(&auth::sha256(invite)) .await? .map(|(org, _)| org), None if !config.allowed_groups.is_empty() || admin => Some( state .store .organisation_or_create(&state.config.server_name) .await?, ), None => None, }; let Some(organisation) = organisation else { return Err(ApiErr::forbidden(format!( "{} signed you in, but you have no account on this server yet. Ask someone for an invite link and open it.", config.label ))); }; let name = oidc::account_name(person); let display = person .display_name .clone() .or_else(|| person.username.clone()) .unwrap_or_else(|| name.clone()); let role = if admin { "admin" } else { "member" }; match state .store .create_sso_account( organisation, &name, &display, role, &config.issuer, &person.subject, person.email.as_deref(), ) .await { Ok(account) => Ok(account), // Two sign-ins of the same person at once: the other one made the account. Err(archify_sync_store::StoreError::Conflict("identity")) => state .store .account_by_identity(&config.issuer, &person.subject) .await? .ok_or_else(|| ApiErr::unauthorized("Sign in again.")), Err(e) => Err(e.into()), } }