Archify Sync server, Dockerfile and Helm chart
Rust server (axum, yrs, sqlx/PostgreSQL): live shared models over the y-websocket protocol, versions, browser sign-in with PKCE, invites, single sign-on through OIDC, metrics. Docker image on distroless, compose file, and a Helm chart with one pod, optional bundled PostgreSQL and secrets from existing Secrets.
This commit is contained in:
commit
5c43b90ef8
67 files changed
+8362
No files matched your search
@@ -0,0 +1,574 @@
|
||||
//! 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#"<!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 {
|
||||
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();
|
||||
// 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#"<div class="card"><h2>Sign in for VS Code</h2>{}<a class="button" href="/oidc/start?{}">Sign in with {}</a></div>"#,
|
||||
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#"<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;
|
||||
}
|
||||
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<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 => {
|
||||
state.store.first_organisation().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()),
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user