//! 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#"
{} 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#"
"#,
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}
{}
"#,
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.
"#,
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.
"#,
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()),
}
}