Files
archify-vscode-ext-sync/crates/server/src/pages.rs
T
root-at-skic 7da2be887d Sign-in ends on a page with a button back to the editor
Browsers open an app link only after a click on the page. With a live
identity provider session the whole sign-in ran without one, so the
redirect to vscode:// was dropped and the editor never got its code. The
page opens the editor at once and keeps a button for when that is held
back. Server 0.1.4, chart 0.1.5.
2026-11-08 19:56:53 +02:00

603 lines
23 KiB
Rust

//! 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('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
fn page(state: &AppState, title: &str, body: &str) -> Response {
let html = format!(
r#"<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title} · {server}</title><style>
:root {{ color-scheme: light dark; --fg: #1f2328; --muted: #59636e; --bg: #f6f8fa; --card: #fff; --line: #d1d9e0; --accent: #0969da; --bad: #d1242f; }}
@media (prefers-color-scheme: dark) {{ :root {{ --fg: #e6edf3; --muted: #9198a1; --bg: #0d1117; --card: #151b23; --line: #3d444d; --accent: #4493f8; --bad: #f85149; }} }}
body {{ margin: 0; font: 15px/1.5 system-ui, sans-serif; color: var(--fg); background: var(--bg); }}
main {{ max-width: 420px; margin: 48px auto; padding: 0 16px; }}
.card {{ background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: 24px; margin-bottom: 16px; }}
h1 {{ font-size: 20px; margin: 0 0 4px; }} h2 {{ font-size: 16px; margin: 0 0 12px; }}
p.muted {{ color: var(--muted); margin: 0 0 16px; }}
label {{ display: block; margin: 12px 0 4px; font-weight: 600; }}
input {{ width: 100%; box-sizing: border-box; padding: 8px; border: 1px solid var(--line); border-radius: 6px; background: transparent; color: inherit; font: inherit; }}
button, a.button {{ display: inline-block; margin-top: 16px; padding: 8px 16px; border: 0; border-radius: 6px; background: var(--accent); color: #fff; font: inherit; font-weight: 600; text-decoration: none; cursor: pointer; }}
.error {{ color: var(--bad); margin: 0 0 12px; }} code {{ font-size: 14px; }}
</style></head><body><main><h1>{server}</h1><p class="muted">Archify Sync</p>{body}</main></body></html>"#,
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#"<p class="error">{}</p>"#, escape(e)))
.unwrap_or_default()
}
fn hidden(name: &str, value: &str) -> String {
format!(
r#"<input type="hidden" name="{name}" value="{}">"#,
escape(value)
)
}
// ------------------------------------------------------------------- setup
pub async fn setup_page(State(state): State<AppState>) -> 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#"<div class="card"><h2>Set up</h2><p>This server is set up. Sign in from VS Code with <b>Archify: Sign in to Sync Server…</b></p></div>"#,
);
}
let body = format!(
r#"<div class="card"><h2>Set up the first admin</h2><p class="muted">The first-start code is in the server's log.</p>{}
<form method="post" action="/setup">
<label for="code">First-start code</label><input id="code" name="code" autocomplete="off" required>
<label for="organisation">Organisation</label><input id="organisation" name="organisation" required>
<label for="name">Sign-in name</label><input id="name" name="name" autocomplete="username" required>
<label for="display_name">Your name</label><input id="display_name" name="display_name">
<label for="password">Password (10 characters or more)</label><input id="password" name="password" type="password" autocomplete="new-password" required>
<button type="submit">Set up</button></form></div>"#,
error_line(error)
);
page(state, "Set up", &body)
}
#[derive(Deserialize)]
pub struct SetupForm {
code: String,
organisation: String,
name: String,
display_name: Option<String>,
password: String,
}
pub async fn setup_submit(State(state): State<AppState>, Form(form): Form<SetupForm>) -> 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#"<div class="card"><h2>Done</h2><p>{} is the admin. In VS Code, run <b>Archify: Sign in to Sync Server…</b> with <code>{}</code>.</p></div>"#,
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<String>,
}
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<String>,
}
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<AppState>,
Query(q): Query<AuthorizeQuery>,
) -> Response {
// With single sign-on there is nothing to choose: straight to the identity
// provider. The page only shows when something went wrong there.
if state.oidc.is_some() && check_authorize(&state, &q).is_ok() {
return Redirect::to(&format!("/oidc/start?{}", q.to_query_string())).into_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#"<div class="card">{}</div>"#, 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();
// With single sign-on the identity provider is the only way in: no
// password form, no new local accounts. An invite goes through it too.
if let Some(o) = &state.oidc {
let invited = match &q.invite {
Some(invite) => match state.store.invite_organisation(&auth::sha256(invite)).await {
Ok(Some((_, space_name))) => format!(
r#"<p class="muted">You were invited to <b>{}</b>.</p>"#,
escape(&space_name)
),
_ => String::new(),
},
None => String::new(),
};
let body = format!(
r#"<div class="card"><h2>Sign in for VS Code</h2>{}{invited}<a class="button" href="/oidc/start?{}">Sign in with {}</a></div>"#,
error_line(error),
escape(&q.to_query_string()),
escape(&o.config.label),
);
return page(state, "Sign in", &body);
}
let mut body = String::new();
let title = "Sign in for VS Code";
body.push_str(&format!(
r#"<div class="card"><h2>{title}</h2>{}
<form method="post" action="/authorize">{fields}{}
<label for="name">Sign-in name</label><input id="name" name="name" autocomplete="username" required>
<label for="password">Password</label><input id="password" name="password" type="password" autocomplete="current-password" required>
<button type="submit">Sign in</button></form></div>"#,
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#"<div class="card"><h2>New here?</h2><p class="muted">You were invited to <b>{}</b>. Make an account.</p>
<form method="post" action="/authorize">{fields}{}
<label for="new-name">Sign-in name</label><input id="new-name" name="name" autocomplete="username" required>
<label for="new-display">Your name</label><input id="new-display" name="display_name">
<label for="new-password">Password (10 characters or more)</label><input id="new-password" name="password" type="password" autocomplete="new-password" required>
<button type="submit">Make account</button></form></div>"#,
escape(&space_name),
hidden("action", "signup"),
));
}
page(state, "Sign in", &body)
}
pub async fn authorize_submit(
State(state): State<AppState>,
Form(form): Form<AuthorizeForm>,
) -> Response {
let q = form.query.clone();
if check_authorize(&state, &q).is_err() {
return authorize_form(&state, &q, None).await;
}
if let Some(o) = &state.oidc {
let message = format!("This server signs in with {} only.", o.config.label);
return authorize_form(&state, &q, Some(&message)).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);
// A page, not a redirect: browsers open an app link only after a click on
// the page, and with a live identity provider session nobody clicked. The
// page tries at once and keeps a button for when the browser held back.
let link = escape(target.as_str());
page(
state,
"Signed in",
&format!(
r#"<meta http-equiv="refresh" content="0;url={link}"><div class="card"><h2>Signed in as {}</h2><p class="muted">Your editor finishes the sign-in. If it did not open, use the button.</p><a class="button" href="{link}">Open the editor</a></div>"#,
escape(&account.display_name),
),
)
}
async fn sign_in(state: &AppState, form: &AuthorizeForm) -> Result<AccountRow, ApiErr> {
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<AccountRow>) -> bool {
account
.as_ref()
.and_then(|a| a.password_hash.as_ref())
.is_none()
}
async fn sign_up(state: &AppState, form: &AuthorizeForm) -> Result<AccountRow, ApiErr> {
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<AppState>, Path(token): Path<String>) -> 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#"<div class="card"><h2>Join</h2><p class="error">This invite link has expired or was used up. Ask for a new one.</p></div>"#,
);
*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#"<div class="card"><h2>Join {space}</h2><p>You were invited to work on the model <b>{space}</b> together.</p>
<p class="muted">You need the Archify Diagram Viewer extension.</p>
<a class="button" href="{vscode}">Open in VS Code</a></div>
<div class="card"><p class="muted">Other editors: <a href="{codium}">VSCodium</a> · <a href="{cursor}">Cursor</a></p></div>"#,
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<AppState>,
Query(q): Query<AuthorizeQuery>,
) -> 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<String>,
state: Option<String>,
error: Option<String>,
error_description: Option<String>,
}
fn sso_problem(state: &AppState, message: &str) -> Response {
let mut response = page(
state,
"Sign in",
&format!(
r#"<div class="card"><h2>Sign in for VS Code</h2>{}<p class="muted">Close this tab and sign in again from VS Code.</p></div>"#,
error_line(Some(message))
),
);
*response.status_mut() = StatusCode::BAD_REQUEST;
response
}
pub async fn oidc_callback(
State(state): State<AppState>,
Query(cb): Query<CallbackQuery>,
) -> 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<AccountRow, ApiErr> {
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()),
}
}