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:
186
crates/mtgc-server/src/auth/local.rs
Normal file
186
crates/mtgc-server/src/auth/local.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
304
crates/mtgc-server/src/auth/mod.rs
Normal file
304
crates/mtgc-server/src/auth/mod.rs
Normal 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"))
|
||||
}
|
||||
}
|
||||
266
crates/mtgc-server/src/auth/oidc.rs
Normal file
266
crates/mtgc-server/src/auth/oidc.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user