use std::fs;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use openidconnect::core::{
CoreAuthDisplay, CoreClaimName, CoreClaimType, CoreClient, CoreClientAuthMethod,
CoreErrorResponseType, CoreGrantType, CoreJsonWebKey, CoreJweContentEncryptionAlgorithm,
CoreJweKeyManagementAlgorithm, CoreResponseMode, CoreResponseType, CoreRevocableToken,
CoreSubjectIdentifierType, CoreTokenResponse,
};
use openidconnect::{
reqwest, AdditionalProviderMetadata, AuthenticationFlow, AuthorizationCode, ClientId,
CsrfToken, IssuerUrl, Nonce, OAuth2TokenResponse, PkceCodeChallenge, ProviderMetadata,
RedirectUrl, RefreshToken, RequestTokenError, RevocationUrl, Scope, TokenResponse,
};
use serde::{Deserialize, Serialize};
use tauri::{Manager, Url};
use tauri_plugin_opener::OpenerExt;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpListener;
use tokio::sync::{oneshot, Mutex};
const CLIENT_ID: &str = "quple";
const SIGN_IN_TIMEOUT: Duration = Duration::from_secs(10 * 60);
const EXPIRY_MARGIN: Duration = Duration::from_secs(60);
#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct Session {
username: String,
access_token: String,
expires_at: u64,
}
#[derive(Serialize, Deserialize)]
struct SavedSession {
issuer: String,
username: String,
refresh_token: String,
}
#[derive(Default)]
struct Inner {
saved: Option<SavedSession>,
access_token: Option<(String, Instant, u64)>,
}
#[derive(Default)]
pub struct QuineAuth {
inner: Mutex<Inner>,
cancel: std::sync::Mutex<Option<oneshot::Sender<()>>>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
struct RevocationMetadata {
revocation_endpoint: Option<String>,
}
impl AdditionalProviderMetadata for RevocationMetadata {}
type QuineProviderMetadata = ProviderMetadata<
RevocationMetadata,
CoreAuthDisplay,
CoreClientAuthMethod,
CoreClaimName,
CoreClaimType,
CoreGrantType,
CoreJweContentEncryptionAlgorithm,
CoreJweKeyManagementAlgorithm,
CoreJsonWebKey,
CoreResponseMode,
CoreResponseType,
CoreSubjectIdentifierType,
>;
fn http_client() -> Result<reqwest::Client, String> {
reqwest::ClientBuilder::new()
.redirect(reqwest::redirect::Policy::none())
.timeout(Duration::from_secs(20))
.build()
.map_err(|e| e.to_string())
}
async fn discover(issuer: &str, http: &reqwest::Client) -> Result<QuineProviderMetadata, String> {
let issuer_url = IssuerUrl::new(issuer.trim_end_matches('/').to_string())
.map_err(|e| format!("invalid identity server URL: {e}"))?;
QuineProviderMetadata::discover_async(issuer_url, http)
.await
.map_err(|e| format!("can't reach the Quine identity server ({issuer}): {e}"))
}
fn session_path(app: &tauri::AppHandle) -> Result<std::path::PathBuf, String> {
let dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
Ok(dir.join("quine-session.json"))
}
fn save_session(app: &tauri::AppHandle, saved: &SavedSession) -> Result<(), String> {
let path = session_path(app)?;
let raw = serde_json::to_vec(saved).map_err(|e| e.to_string())?;
let tmp = path.with_extension("json.tmp");
fs::write(&tmp, raw).map_err(|e| format!("could not save sign-in: {e}"))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&tmp, fs::Permissions::from_mode(0o600)).map_err(|e| e.to_string())?;
}
fs::rename(&tmp, &path).map_err(|e| format!("could not save sign-in: {e}"))
}
fn delete_session(app: &tauri::AppHandle) {
if let Ok(path) = session_path(app) {
let _ = fs::remove_file(path);
}
}
fn now_millis() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
fn remember_access(inner: &mut Inner, tokens: &CoreTokenResponse) -> Session {
let lifetime = tokens.expires_in().unwrap_or(Duration::from_secs(300));
let expires_at = now_millis() + lifetime.as_millis() as u64;
let token = tokens.access_token().secret().clone();
inner.access_token = Some((token.clone(), Instant::now() + lifetime, expires_at));
Session {
username: inner.saved.as_ref().map(|s| s.username.clone()).unwrap_or_default(),
access_token: token,
expires_at,
}
}
fn is_invalid_grant<RE: std::error::Error + 'static>(
err: &RequestTokenError<RE, openidconnect::StandardErrorResponse<CoreErrorResponseType>>,
) -> bool {
matches!(err, RequestTokenError::ServerResponse(resp)
if *resp.error() == CoreErrorResponseType::InvalidGrant)
}
async fn refresh(app: &tauri::AppHandle, inner: &mut Inner) -> Result<Session, String> {
let saved = inner.saved.as_ref().ok_or("signed_out")?;
let http = http_client()?;
let metadata = discover(&saved.issuer, &http).await?;
let client = CoreClient::from_provider_metadata(metadata, ClientId::new(CLIENT_ID.into()), None);
let tokens = client
.exchange_refresh_token(&RefreshToken::new(saved.refresh_token.clone()))
.map_err(|e| e.to_string())?
.request_async(&http)
.await;
let tokens = match tokens {
Ok(t) => t,
Err(e) if is_invalid_grant(&e) => {
inner.saved = None;
inner.access_token = None;
delete_session(app);
return Err("signed_out".into());
}
Err(e) => return Err(format!("can't reach the Quine identity server: {e}")),
};
if let (Some(new_refresh), Some(saved)) = (tokens.refresh_token(), inner.saved.as_mut()) {
saved.refresh_token = new_refresh.secret().clone();
save_session(app, saved)?;
}
Ok(remember_access(inner, &tokens))
}
async fn await_callback(listener: TcpListener) -> Result<Url, String> {
loop {
let (mut stream, _) = listener.accept().await.map_err(|e| e.to_string())?;
let mut request_line = String::new();
BufReader::new(&mut stream)
.read_line(&mut request_line)
.await
.map_err(|e| e.to_string())?;
let target = request_line.split_whitespace().nth(1).unwrap_or("/");
let url = Url::parse(&format!("http://127.0.0.1{target}")).map_err(|e| e.to_string())?;
if url.path() != "/callback" {
let _ = stream
.write_all(b"HTTP/1.1 404 Not Found\r\ncontent-length: 0\r\nconnection: close\r\n\r\n")
.await;
continue;
}
let failed = url.query_pairs().any(|(k, _)| k == "error");
let body = format!(
"<!doctype html><meta charset=utf-8><title>Quple</title>\
<body style=\"font:16px -apple-system,sans-serif;display:grid;place-items:center;height:90vh\">\
<p>{}</p>",
if failed {
"Sign-in didn't complete. You can close this tab and try again in Quple."
} else {
"You're signed in to Quple. You can close this tab."
}
);
let response = format!(
"HTTP/1.1 200 OK\r\ncontent-type: text/html; charset=utf-8\r\ncontent-length: {}\r\n\
cache-control: no-store\r\nconnection: close\r\n\r\n{body}",
body.len()
);
let _ = stream.write_all(response.as_bytes()).await;
return Ok(url);
}
}
#[tauri::command]
pub async fn quine_sign_in(
app: tauri::AppHandle,
auth: tauri::State<'_, QuineAuth>,
issuer: String,
) -> Result<Session, String> {
let http = http_client()?;
let metadata = discover(&issuer, &http).await?;
let listener = TcpListener::bind("127.0.0.1:0").await.map_err(|e| e.to_string())?;
let port = listener.local_addr().map_err(|e| e.to_string())?.port();
let redirect = RedirectUrl::new(format!("http://127.0.0.1:{port}/callback")).map_err(|e| e.to_string())?;
let client = CoreClient::from_provider_metadata(metadata, ClientId::new(CLIENT_ID.into()), None)
.set_redirect_uri(redirect);
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
let (authorize_url, csrf_state, nonce) = client
.authorize_url(
AuthenticationFlow::<CoreResponseType>::AuthorizationCode,
CsrfToken::new_random,
Nonce::new_random,
)
.add_scope(Scope::new("profile".into()))
.add_scope(Scope::new("offline_access".into()))
.set_pkce_challenge(pkce_challenge)
.url();
app.opener()
.open_url(authorize_url.as_str(), None::<&str>)
.map_err(|e| format!("could not open the browser: {e}"))?;
let (cancel_tx, cancel_rx) = oneshot::channel();
*auth.cancel.lock().unwrap() = Some(cancel_tx);
let callback = tokio::select! {
result = await_callback(listener) => result,
_ = cancel_rx => Err("cancelled".to_string()),
_ = tokio::time::sleep(SIGN_IN_TIMEOUT) => Err("Sign-in timed out. Please try again.".to_string()),
};
auth.cancel.lock().unwrap().take();
let callback = callback?;
if let Some(main) = app.get_webview_window("main") {
let _ = main.set_focus();
}
let param = |name: &str| {
callback
.query_pairs()
.find(|(k, _)| k == name)
.map(|(_, v)| v.into_owned())
};
if param("state").as_deref() != Some(csrf_state.secret().as_str()) {
return Err("Sign-in response didn't match this request. Please try again.".into());
}
if let Some(error) = param("error") {
let detail = param("error_description").unwrap_or(error);
return Err(format!("Sign-in failed: {detail}"));
}
let code = param("code").ok_or("Sign-in response had no code.")?;
let tokens = client
.exchange_code(AuthorizationCode::new(code))
.map_err(|e| e.to_string())?
.set_pkce_verifier(pkce_verifier)
.request_async(&http)
.await
.map_err(|e| format!("Sign-in failed at the token endpoint: {e}"))?;
let id_token = tokens.id_token().ok_or("identity server returned no ID token")?;
let claims = id_token
.claims(&client.id_token_verifier(), &nonce)
.map_err(|e| format!("identity server returned an invalid ID token: {e}"))?;
let username = claims
.preferred_username()
.map(|u| u.to_string())
.unwrap_or_else(|| claims.subject().to_string());
let refresh_token = tokens
.refresh_token()
.ok_or("identity server returned no refresh token")?
.secret()
.clone();
let saved = SavedSession {
issuer: issuer.trim_end_matches('/').to_string(),
username,
refresh_token,
};
save_session(&app, &saved)?;
let mut inner = auth.inner.lock().await;
inner.saved = Some(saved);
Ok(remember_access(&mut inner, &tokens))
}
#[tauri::command]
pub fn quine_cancel_sign_in(auth: tauri::State<'_, QuineAuth>) {
if let Some(tx) = auth.cancel.lock().unwrap().take() {
let _ = tx.send(());
}
}
#[tauri::command]
pub async fn quine_restore(
app: tauri::AppHandle,
auth: tauri::State<'_, QuineAuth>,
issuer: String,
) -> Result<Option<Session>, String> {
let saved = match fs::read(session_path(&app)?) {
Ok(raw) => serde_json::from_slice::<SavedSession>(&raw).ok(),
Err(_) => None,
};
let Some(saved) = saved.filter(|s| s.issuer == issuer.trim_end_matches('/')) else {
return Ok(None);
};
let mut inner = auth.inner.lock().await;
inner.saved = Some(saved);
match refresh(&app, &mut inner).await {
Ok(session) => Ok(Some(session)),
Err(e) if e == "signed_out" => Ok(None),
Err(e) => Err(e),
}
}
#[tauri::command]
pub async fn quine_access_token(
app: tauri::AppHandle,
auth: tauri::State<'_, QuineAuth>,
force: bool,
) -> Result<Session, String> {
let mut inner = auth.inner.lock().await;
if !force {
if let (Some((token, expiry, expires_at)), Some(saved)) = (&inner.access_token, &inner.saved) {
if Instant::now() + EXPIRY_MARGIN < *expiry {
return Ok(Session {
username: saved.username.clone(),
access_token: token.clone(),
expires_at: *expires_at,
});
}
}
}
refresh(&app, &mut inner).await
}
#[tauri::command]
pub async fn quine_sign_out(
app: tauri::AppHandle,
auth: tauri::State<'_, QuineAuth>,
) -> Result<(), String> {
let mut inner = auth.inner.lock().await;
if let Some(saved) = inner.saved.take() {
if let Ok(http) = http_client() {
if let Ok(metadata) = discover(&saved.issuer, &http).await {
let revocation = metadata
.additional_metadata()
.revocation_endpoint
.clone()
.and_then(|u| RevocationUrl::new(u).ok());
if let Some(revocation) = revocation {
let client = CoreClient::from_provider_metadata(metadata, ClientId::new(CLIENT_ID.into()), None)
.set_revocation_url(revocation);
let token: CoreRevocableToken = RefreshToken::new(saved.refresh_token).into();
if let Ok(request) = client.revoke_token(token) {
let _ = request.request_async(&http).await;
}
}
}
}
}
inner.access_token = None;
delete_session(&app);
Ok(())
}