Replace folder sync with an HTTP sync server, keeping the app offline-first

Folder sync left too much to the user's sync tool: platform file access
(the Android SAF plugin), choosing the document, merging conflict copies —
and its merge base could fall out of step with the document (absorbing
conflict copies, "forget", picking a new Android folder), reading every
difference as a deletion.

The new mtgc-server crate stores one versioned library per user and accepts
a write only when it names the version it replaces. It never merges: devices
still do that with merge3, through a new sync cycle over a `Remote` trait in
mtgc-core. The base advances only on knowledge — to what was read, or to a
write the server confirmed — and is stored with the server and account it
belongs to, so a different account starts from an empty ancestor instead of
deleting. The working copy is only rewritten under the app-state lock, and
sync commands now run off the main thread.

Sign-in happens in the browser on the server's pages, via the owner's OIDC
provider and/or local username/password accounts (Argon2id, managed with
`mtgc-server user ...`), and returns a per-device revocable token to the app
by PKCE-protected code: a loopback port on desktop, the mtgc:// scheme on
Android.

The Info tab gains sign-in, the first-sync combine/replace choice, the
account's devices, and JSON export/import; the top bar shows sync state.
Includes a Dockerfile, Compose/Caddy example, and scripts to build and push
${DOCKER_REGISTRY}/mtgc/sync-server.
This commit is contained in:
2026-10-08 22:32:08 -05:00
parent df2cbb372a
commit de55fc634a
41 changed files with 6365 additions and 1662 deletions

View File

@@ -0,0 +1,186 @@
//! Username/password accounts, for servers without an identity provider.
//!
//! Accounts are created by whoever runs the server (`mtgc-server user add`);
//! there is no sign-up page, so a server exposed to the internet can't be
//! filled with accounts by strangers.
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use argon2::password_hash::rand_core::OsRng;
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
use argon2::Argon2;
use axum::extract::{Form, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::Response;
use serde::Deserialize;
use crate::{auth, db, pages, AppState};
/// Failures allowed before each further attempt has to wait.
const FREE_FAILURES: u32 = 5;
const MAX_DELAY: Duration = Duration::from_secs(15 * 60);
pub struct LocalAuth {
/// A real hash of nothing, verified against when the username doesn't
/// exist, so a wrong username takes as long to reject as a wrong password
/// and response times don't reveal which accounts exist.
dummy_hash: String,
failures: Mutex<HashMap<String, Failures>>,
}
struct Failures {
count: u32,
locked_until: Instant,
}
impl LocalAuth {
pub fn new() -> Self {
Self {
dummy_hash: hash_password("not a real password").expect("hashing works"),
failures: Mutex::new(HashMap::new()),
}
}
/// How long until this username may try again, if it must wait.
fn wait(&self, username: &str) -> Option<Duration> {
let map = self.failures.lock().unwrap_or_else(|e| e.into_inner());
let f = map.get(username)?;
f.locked_until.checked_duration_since(Instant::now())
}
fn record_failure(&self, username: &str) {
let mut map = self.failures.lock().unwrap_or_else(|e| e.into_inner());
let f = map
.entry(username.to_string())
.or_insert(Failures { count: 0, locked_until: Instant::now() });
f.count += 1;
if f.count >= FREE_FAILURES {
// 1s, 2s, 4s, ... capped. Slows guessing to a crawl without
// locking the real owner out for long.
let exp = (f.count - FREE_FAILURES).min(20);
let delay = Duration::from_secs(1u64 << exp).min(MAX_DELAY);
f.locked_until = Instant::now() + delay;
}
}
fn record_success(&self, username: &str) {
self.failures.lock().unwrap_or_else(|e| e.into_inner()).remove(username);
}
}
impl Default for LocalAuth {
fn default() -> Self {
Self::new()
}
}
/// Argon2id with the crate's defaults (19 MiB, 2 passes): OWASP's baseline.
pub fn hash_password(password: &str) -> Result<String, String> {
let salt = SaltString::generate(&mut OsRng);
Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map(|h| h.to_string())
.map_err(|e| e.to_string())
}
fn verify_password(password: &str, hash: &str) -> bool {
PasswordHash::new(hash)
.map(|h| Argon2::default().verify_password(password.as_bytes(), &h).is_ok())
.unwrap_or(false)
}
/// Usernames are compared case-insensitively, as people expect them to be.
pub fn normalize_username(name: &str) -> String {
name.trim().to_lowercase()
}
#[derive(Deserialize)]
pub struct LoginForm {
pending: String,
username: String,
password: String,
}
/// The sign-in form's target.
pub async fn submit(
State(state): State<AppState>,
headers: HeaderMap,
Form(form): Form<LoginForm>,
) -> Response {
if !state.config().local_auth {
return pages::error(StatusCode::NOT_FOUND, "Password sign-in is turned off on this server.");
}
// The form only exists inside a sign-in the app started, in this browser;
// without one there is nowhere to send the result.
let pending = match auth::bound_pending(&state, &headers, &form.pending) {
Ok(p) => p,
Err(r) => return r,
};
let oidc_name = state.config().oidc.as_ref().map(|o| o.display_name.clone());
let username = normalize_username(&form.username);
if let Some(wait) = state.0.local.wait(&username) {
let msg = format!("Too many attempts. Try again in {} seconds.", wait.as_secs().max(1));
return pages::login(&pending, oidc_name.as_deref(), Some(&msg), &form.username);
}
let account = match db::local_user(&state.db(), &username) {
Ok(a) => a,
Err(e) => {
tracing::error!("load local user: {e}");
return pages::error(StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong — try again.");
}
};
// Hashing is deliberately slow; keep it off the async workers.
let hash = account
.as_ref()
.map(|(_, h)| h.clone())
.unwrap_or_else(|| state.0.local.dummy_hash.clone());
let password = form.password.clone();
let ok = tokio::task::spawn_blocking(move || verify_password(&password, &hash))
.await
.unwrap_or(false);
match account {
Some((user, _)) if ok => {
state.0.local.record_success(&username);
auth::finish(&state, &pending, &user)
}
_ => {
state.0.local.record_failure(&username);
tracing::warn!(username = %username, "failed password sign-in");
pages::login(&pending, oidc_name.as_deref(), Some("Invalid username or password."), &form.username)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_password_verifies_against_its_own_hash_only() {
let h = hash_password("correct horse").unwrap();
assert!(h.starts_with("$argon2id$"));
assert!(verify_password("correct horse", &h));
assert!(!verify_password("wrong", &h));
assert!(!verify_password("correct horse", "not a hash"));
}
#[test]
fn repeated_failures_make_a_username_wait() {
let a = LocalAuth::new();
for _ in 0..FREE_FAILURES - 1 {
a.record_failure("me");
}
assert!(a.wait("me").is_none(), "a few typos are free");
a.record_failure("me");
assert!(a.wait("me").is_some());
assert!(a.wait("someone-else").is_none(), "only that username waits");
a.record_success("me");
assert!(a.wait("me").is_none());
}
}

View File

@@ -0,0 +1,304 @@
//! Signing a device in, and recognising it afterwards.
//!
//! Every sign-in follows the same shape, whichever way the user proves who
//! they are:
//!
//! 1. The app opens the browser at `/auth/start`, with where to send the user
//! back to (`redirect_uri`, an app-only address), a PKCE challenge, and its
//! own `state`. That becomes a *pending login*.
//! 2. The user signs in on this server's page ([`local`]) or at the identity
//! provider ([`oidc`]).
//! 3. Either way ends in [`finish`]: a single-use code is sent back to the app
//! at its `redirect_uri`.
//! 4. The app redeems the code with its PKCE verifier at `/auth/token` and gets
//! a token for that device alone.
//!
//! PKCE is what makes step 3 safe on a phone: another app that registered the
//! same URL scheme could receive the code, but can't redeem it without the
//! verifier, which never left the app that started the sign-in.
//!
//! Devices hold long-lived tokens rather than the provider's own, because those
//! expire within minutes and a device that has been offline for a week must
//! still be able to sync the moment it reconnects. Each is revocable on its own.
pub mod local;
pub mod oidc;
use axum::extract::{FromRequestParts, Path, Query, State};
use axum::http::header::{COOKIE, SET_COOKIE};
use axum::http::request::Parts;
use axum::http::{HeaderMap, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Redirect, Response};
use axum::Json;
use base64::Engine;
use serde::Deserialize;
use serde_json::json;
use sha2::{Digest, Sha256};
use crate::db::{self, PendingLogin, User};
use crate::{pages, random_token, sha256_hex, ApiError, AppState};
/// How long the user has to finish signing in once the browser opens.
const PENDING_TTL_SECS: i64 = 15 * 60;
/// How long the app has to redeem its code. It does so immediately.
const CODE_TTL_SECS: i64 = 120;
/// The only places a code may be sent: the desktop app's one-shot listener on
/// the loopback interface (any port, RFC 8252 §7.3), or the phone app's URL
/// scheme. Anything else is refused outright rather than redirected to, so the
/// server can never be used to deliver a code to a website.
pub fn redirect_allowed(uri: &str) -> bool {
if uri == "mtgc://auth/callback" {
return true;
}
let Ok(u) = url::Url::parse(uri) else {
return false;
};
u.scheme() == "http"
&& u.host_str() == Some("127.0.0.1")
&& u.port().is_some()
&& u.path() == "/callback"
&& u.query().is_none()
&& u.fragment().is_none()
&& u.username().is_empty()
&& u.password().is_none()
}
#[derive(Deserialize)]
pub struct StartParams {
redirect_uri: String,
code_challenge: String,
#[serde(default)]
code_challenge_method: Option<String>,
state: String,
#[serde(default)]
device_name: Option<String>,
}
/// Step 1: begin a sign-in and show the way to complete it.
pub async fn start(State(state): State<AppState>, Query(p): Query<StartParams>) -> Response {
if !redirect_allowed(&p.redirect_uri) {
return pages::error(StatusCode::BAD_REQUEST, "This sign-in link didn't come from the mtgc app.");
}
if p.code_challenge_method.as_deref().unwrap_or("S256") != "S256" || p.code_challenge.len() < 43 {
return pages::error(StatusCode::BAD_REQUEST, "This version of the app isn't supported — update it.");
}
let device_name = p
.device_name
.map(|n| n.trim().chars().take(80).collect::<String>())
.filter(|n| !n.is_empty())
.unwrap_or_else(|| "Unnamed device".into());
let pending = PendingLogin {
id: random_token(),
redirect_uri: p.redirect_uri,
app_state: p.state.chars().take(200).collect(),
code_challenge: p.code_challenge,
device_name,
oidc_nonce: None,
oidc_verifier: None,
};
if let Err(e) = db::create_pending(&state.db(), &pending, PENDING_TTL_SECS) {
tracing::error!("create pending login: {e}");
return pages::error(StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong — try again.");
}
let c = state.config();
let mut resp = if c.local_auth {
pages::login(&pending, c.oidc.as_ref().map(|o| o.display_name.as_str()), None, "")
} else {
// Nothing to choose between: go straight to the provider.
Redirect::to(&format!("/auth/oidc?pending={}", pending.id)).into_response()
};
let secure = if c.public_url.starts_with("https://") { "; Secure" } else { "" };
let cookie = format!(
"{LOGIN_COOKIE}={}; Path=/auth; HttpOnly; SameSite=Lax; Max-Age={PENDING_TTL_SECS}{secure}",
pending.id
);
if let Ok(v) = HeaderValue::from_str(&cookie) {
resp.headers_mut().append(SET_COOKIE, v);
}
resp
}
/// Ties a sign-in to the browser it started in. The pending login's id already
/// travels in the form and in the provider's `state`, but requiring it in a
/// cookie as well means a link or form planted on another site can't carry
/// someone's browser through a sign-in it didn't start.
const LOGIN_COOKIE: &str = "mtgc_login";
/// The pending login `id`, provided this browser is the one that started it.
/// Fails with the page to show instead.
#[allow(clippy::result_large_err)] // once per sign-in step; boxing buys nothing
pub(crate) fn bound_pending(
state: &AppState,
headers: &HeaderMap,
id: &str,
) -> Result<PendingLogin, Response> {
let expired = || {
pages::error(StatusCode::BAD_REQUEST, "This sign-in has expired. Start again from the app.")
};
let from_cookie = headers
.get_all(COOKIE)
.iter()
.filter_map(|v| v.to_str().ok())
.flat_map(|v| v.split(';'))
.filter_map(|c| c.trim().split_once('='))
.find(|(k, _)| *k == LOGIN_COOKIE)
.map(|(_, v)| v.to_string());
if id.is_empty() || from_cookie.as_deref() != Some(id) {
return Err(expired());
}
match db::pending(&state.db(), id) {
Ok(Some(p)) => Ok(p),
Ok(None) => Err(expired()),
Err(e) => {
tracing::error!("load pending login: {e}");
Err(pages::error(StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong — try again."))
}
}
}
/// Step 3: the user is who they say they are. Mint the app's code and send the
/// browser back to it.
pub(crate) fn finish(state: &AppState, pending: &PendingLogin, user: &User) -> Response {
let code = random_token();
let stored = {
let conn = state.db();
db::create_auth_code(
&conn,
&sha256_hex(&code),
user.id,
&pending.code_challenge,
&pending.device_name,
CODE_TTL_SECS,
)
.and_then(|_| db::delete_pending(&conn, &pending.id))
};
if let Err(e) = stored {
tracing::error!("store auth code: {e}");
return pages::error(StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong — try again.");
}
tracing::info!(user = %user.sub, device = %pending.device_name, "signed in");
pages::return_to_app(&back_to_app(pending, &[("code", &code)]))
}
/// The app's redirect URI with `params` and its own `state` attached.
pub(crate) fn back_to_app(pending: &PendingLogin, params: &[(&str, &str)]) -> String {
let mut u = url::Url::parse(&pending.redirect_uri).expect("validated in start()");
{
let mut q = u.query_pairs_mut();
for (k, v) in params {
q.append_pair(k, v);
}
q.append_pair("state", &pending.app_state);
}
u.into()
}
#[derive(Deserialize)]
pub struct TokenRequest {
code: String,
code_verifier: String,
}
/// Step 4: trade the code and PKCE verifier for this device's token.
pub async fn token(
State(state): State<AppState>,
Json(req): Json<TokenRequest>,
) -> Result<Response, ApiError> {
let invalid = || ApiError::new(StatusCode::BAD_REQUEST, "sign-in code is invalid or expired");
let conn = state.db();
let (user_id, challenge, device_name) =
db::take_auth_code(&conn, &sha256_hex(&req.code))?.ok_or_else(invalid)?;
if pkce_challenge(&req.code_verifier) != challenge {
return Err(invalid());
}
let user = db::user_by_id(&conn, user_id)?.ok_or_else(invalid)?;
let token = format!("mtgc_{}", random_token());
let device_id = db::create_device(&conn, user.id, &sha256_hex(&token), &device_name)?;
Ok(Json(json!({
"token": token,
"device_id": device_id,
"user": user_json(&user),
}))
.into_response())
}
/// S256 per RFC 7636: base64url(SHA-256(verifier)), unpadded.
pub fn pkce_challenge(verifier: &str) -> String {
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()))
}
fn user_json(u: &User) -> serde_json::Value {
json!({
"id": u.public_id,
"username": u.sub,
"display_name": u.display_name,
"email": u.email,
"method": if u.iss == db::LOCAL_ISS { "local" } else { "oidc" },
})
}
/// A request from a signed-in device.
pub struct Authed {
pub user: User,
pub device_id: i64,
}
impl FromRequestParts<AppState> for Authed {
type Rejection = ApiError;
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, ApiError> {
let token = parts
.headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(str::trim)
.ok_or_else(ApiError::unauthorized)?;
let conn = state.db();
let device = db::device_by_token(&conn, &sha256_hex(token))?.ok_or_else(ApiError::unauthorized)?;
let user = db::user_by_id(&conn, device.user_id)?.ok_or_else(ApiError::unauthorized)?;
Ok(Self { user, device_id: device.id })
}
}
/// The signed-in account and its devices.
pub async fn me(State(state): State<AppState>, auth: Authed) -> Result<Response, ApiError> {
let devices = db::devices_for(&state.db(), auth.user.id)?;
let devices: Vec<_> = devices
.iter()
.map(|d| {
json!({
"id": d.id,
"name": d.name,
"created_at": d.created_at,
"last_seen_at": d.last_seen_at,
"current": d.id == auth.device_id,
})
})
.collect();
Ok(Json(json!({ "user": user_json(&auth.user), "devices": devices })).into_response())
}
/// Sign this device out.
pub async fn logout(State(state): State<AppState>, auth: Authed) -> Result<StatusCode, ApiError> {
db::delete_device(&state.db(), auth.user.id, auth.device_id)?;
Ok(StatusCode::NO_CONTENT)
}
/// Sign out another of the user's devices — a lost phone, say.
pub async fn revoke_device(
State(state): State<AppState>,
auth: Authed,
Path(id): Path<i64>,
) -> Result<StatusCode, ApiError> {
if db::delete_device(&state.db(), auth.user.id, id)? {
Ok(StatusCode::NO_CONTENT)
} else {
Err(ApiError::new(StatusCode::NOT_FOUND, "no such device"))
}
}

View File

@@ -0,0 +1,266 @@
//! Signing in through the server owner's OpenID Connect provider.
//!
//! The server is a confidential client: it holds the client secret, makes the
//! code exchange itself, and checks the ID token (signature, issuer, audience,
//! nonce). The provider's tokens are used once, to learn who the user is, and
//! then discarded — devices get their own tokens (see [`super`]).
use std::time::{Duration, Instant};
use axum::extract::{Query, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Redirect, Response};
use openidconnect::core::{CoreAuthenticationFlow, CoreClient, CoreProviderMetadata};
use openidconnect::{
AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl, Nonce, PkceCodeChallenge,
PkceCodeVerifier, RedirectUrl, Scope, TokenResponse,
};
use serde::Deserialize;
use crate::config::OidcConfig;
use crate::{auth, db, pages, AppState};
/// How long discovered provider metadata (including its signing keys) is
/// trusted before being fetched again, so a key rotation at the provider is
/// picked up without restarting the server.
const METADATA_TTL: Duration = Duration::from_secs(60 * 60);
pub struct Oidc {
config: OidcConfig,
http: openidconnect::reqwest::Client,
metadata: tokio::sync::Mutex<Option<(Instant, CoreProviderMetadata)>>,
}
impl Oidc {
pub fn new(config: OidcConfig) -> anyhow::Result<Self> {
IssuerUrl::new(config.issuer.clone())?;
let http = openidconnect::reqwest::ClientBuilder::new()
// Following redirects from the token endpoint would let a
// misbehaving provider bounce the client secret elsewhere.
.redirect(openidconnect::reqwest::redirect::Policy::none())
.timeout(Duration::from_secs(20))
.build()?;
Ok(Self { config, http, metadata: tokio::sync::Mutex::new(None) })
}
/// The provider's metadata, discovered lazily so the server starts (and
/// password sign-in keeps working) even while the provider is down.
async fn metadata(&self, force: bool) -> anyhow::Result<CoreProviderMetadata> {
let mut cached = self.metadata.lock().await;
if let Some((at, meta)) = cached.as_ref() {
if !force && at.elapsed() < METADATA_TTL {
return Ok(meta.clone());
}
}
let meta =
CoreProviderMetadata::discover_async(IssuerUrl::new(self.config.issuer.clone())?, &self.http)
.await?;
*cached = Some((Instant::now(), meta.clone()));
Ok(meta)
}
fn client(
&self,
meta: CoreProviderMetadata,
redirect_uri: String,
) -> anyhow::Result<
CoreClient<
openidconnect::EndpointSet,
openidconnect::EndpointNotSet,
openidconnect::EndpointNotSet,
openidconnect::EndpointNotSet,
openidconnect::EndpointMaybeSet,
openidconnect::EndpointMaybeSet,
>,
> {
Ok(CoreClient::from_provider_metadata(
meta,
ClientId::new(self.config.client_id.clone()),
Some(ClientSecret::new(self.config.client_secret.clone())),
)
.set_redirect_uri(RedirectUrl::new(redirect_uri)?))
}
}
#[derive(Deserialize)]
pub struct BeginParams {
pending: String,
}
/// Send the browser to the provider for a sign-in the app started.
pub async fn begin(
State(state): State<AppState>,
headers: HeaderMap,
Query(p): Query<BeginParams>,
) -> Response {
let Some(oidc) = state.0.oidc.as_ref() else {
return pages::error(StatusCode::NOT_FOUND, "Single sign-on isn't set up on this server.");
};
let pending = match auth::bound_pending(&state, &headers, &p.pending) {
Ok(p) => p,
Err(r) => return r,
};
let meta = match oidc.metadata(false).await {
Ok(m) => m,
Err(e) => {
tracing::error!("OIDC discovery failed: {e:#}");
return pages::error(
StatusCode::BAD_GATEWAY,
"The sign-in provider isn't reachable right now. Try again shortly.",
);
}
};
let client = match oidc.client(meta, state.config().oidc_redirect_uri()) {
Ok(c) => c,
Err(e) => {
tracing::error!("OIDC client: {e:#}");
return pages::error(StatusCode::INTERNAL_SERVER_ERROR, "Single sign-on is misconfigured.");
}
};
let (challenge, verifier) = PkceCodeChallenge::new_random_sha256();
let pending_id = pending.id.clone();
let mut req = client
.authorize_url(
CoreAuthenticationFlow::AuthorizationCode,
// The pending login's id is the state: it is what ties the
// provider's answer back to this sign-in.
move || CsrfToken::new(pending_id),
Nonce::new_random,
)
.set_pkce_challenge(challenge);
for scope in &oidc.config.scopes {
req = req.add_scope(Scope::new(scope.clone()));
}
let (url, _state, nonce) = req.url();
if let Err(e) = db::set_pending_oidc(&state.db(), &pending.id, nonce.secret(), verifier.secret()) {
tracing::error!("store OIDC nonce: {e}");
return pages::error(StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong — try again.");
}
Redirect::to(url.as_str()).into_response()
}
#[derive(Deserialize)]
pub struct CallbackParams {
state: Option<String>,
code: Option<String>,
error: Option<String>,
}
/// The provider's answer.
pub async fn callback(
State(state): State<AppState>,
headers: HeaderMap,
Query(p): Query<CallbackParams>,
) -> Response {
let Some(oidc) = state.0.oidc.as_ref() else {
return pages::error(StatusCode::NOT_FOUND, "Single sign-on isn't set up on this server.");
};
let pending = match auth::bound_pending(&state, &headers, p.state.as_deref().unwrap_or_default()) {
Ok(p) => p,
Err(r) => return r,
};
if let Some(err) = p.error {
// Declined at the provider, or the provider refused. Tell the app, so
// it stops waiting, rather than leaving the user on a dead end here.
tracing::info!("OIDC sign-in ended with {err}");
let _ = db::delete_pending(&state.db(), &pending.id);
return pages::return_to_app(&auth::back_to_app(&pending, &[("error", "access_denied")]));
}
let (Some(code), Some(nonce), Some(verifier)) =
(p.code, pending.oidc_nonce.clone(), pending.oidc_verifier.clone())
else {
return pages::error(StatusCode::BAD_REQUEST, "This sign-in has expired. Start again from the app.");
};
let identity = match identify(&state, oidc, code, nonce, verifier).await {
Ok(i) => i,
Err(e) => {
tracing::warn!("OIDC sign-in failed: {e:#}");
return pages::error(StatusCode::BAD_GATEWAY, "Sign-in couldn't be completed. Start again from the app.");
}
};
let allowed = &oidc.config.allowed_emails;
if !allowed.is_empty() {
let email = identity.email.to_lowercase();
if !identity.email_verified || !allowed.contains(&email) {
tracing::warn!(sub = %identity.sub, email = %email, "OIDC sign-in not on the allow list");
return pages::error(StatusCode::FORBIDDEN, "This account isn't allowed to use this server.");
}
}
let user = match db::upsert_user(
&state.db(),
&identity.iss,
&identity.sub,
&identity.display_name,
&identity.email,
) {
Ok(u) => u,
Err(e) => {
tracing::error!("upsert OIDC user: {e}");
return pages::error(StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong — try again.");
}
};
auth::finish(&state, &pending, &user)
}
struct Identity {
iss: String,
sub: String,
display_name: String,
email: String,
email_verified: bool,
}
async fn identify(
state: &AppState,
oidc: &Oidc,
code: String,
nonce: String,
verifier: String,
) -> anyhow::Result<Identity> {
let meta = oidc.metadata(false).await?;
let client = oidc.client(meta, state.config().oidc_redirect_uri())?;
let response = client
.exchange_code(AuthorizationCode::new(code))?
.set_pkce_verifier(PkceCodeVerifier::new(verifier))
.request_async(&oidc.http)
.await?;
let id_token = response
.id_token()
.ok_or_else(|| anyhow::anyhow!("the provider returned no ID token"))?;
let nonce = Nonce::new(nonce);
let claims = match id_token.claims(&client.id_token_verifier(), &nonce) {
Ok(c) => c.clone(),
Err(first) => {
// Perhaps the provider rotated its signing keys since we last
// looked. Refetch once before giving up.
tracing::info!("ID token didn't verify ({first}); refreshing provider keys");
let meta = oidc.metadata(true).await?;
let client = oidc.client(meta, state.config().oidc_redirect_uri())?;
let claims = id_token.claims(&client.id_token_verifier(), &nonce)?.clone();
claims
}
};
let email = claims.email().map(|e| e.as_str().to_string()).unwrap_or_default();
let display_name = claims
.name()
.and_then(|n| n.get(None))
.map(|n| n.as_str().to_string())
.or_else(|| claims.preferred_username().map(|u| u.as_str().to_string()))
.unwrap_or_else(|| email.clone());
Ok(Identity {
iss: claims.issuer().as_str().to_string(),
sub: claims.subject().as_str().to_string(),
display_name,
email_verified: claims.email_verified().unwrap_or(false),
email,
})
}