Compare commits

..

2 commits

Author SHA1 Message Date
e331217c14 Merge pull request 'feat: OAuth token storage via OS keychain (#78)' (#83) from issue-78-token-store into main 2026-04-14 00:17:41 +00:00
le king fu
feaed4058d feat: migrate OAuth tokens to OS keychain via token_store (#78)
All checks were successful
PR Check / rust (push) Successful in 17m25s
PR Check / frontend (push) Successful in 2m31s
PR Check / rust (pull_request) Successful in 18m14s
PR Check / frontend (pull_request) Successful in 2m14s
Introduce a new token_store module that persists OAuth tokens in the OS
keychain (Credential Manager on Windows, Secret Service on Linux through
sync-secret-service + crypto-rust, both pure-Rust backends).

- Keychain service name matches the Tauri bundle identifier
  (com.simpl.resultat) so credentials are scoped to the real app
  identity.
- Transparent migration on first load: a legacy tokens.json is copied
  into the keychain, then zeroed and unlinked before removal to reduce
  refresh-token recoverability from unallocated disk blocks.
- Store-mode flag (keychain|file) persisted next to the auth dir.
  After a successful keychain write the store refuses to silently
  downgrade to the file fallback, so a subsequent failure forces
  re-authentication instead of leaking plaintext.
- New get_token_store_mode command exposes the current mode to the
  frontend so a settings banner can warn users running on the file
  fallback.
- auth_commands.rs refactored: all tokens.json read/write/delete paths
  go through token_store; check_subscription_status now uses
  token_store::load().is_some() to trigger migration even when the
  24h throttle would early-return.

Refs #66

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 19:41:54 -04:00
6 changed files with 712 additions and 101 deletions

292
src-tauri/Cargo.lock generated
View file

@ -347,6 +347,15 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-padding"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
dependencies = [
"generic-array",
]
[[package]]
name = "block2"
version = "0.6.2"
@ -484,6 +493,15 @@ dependencies = [
"toml 0.9.11+spec-1.1.0",
]
[[package]]
name = "cbc"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
dependencies = [
"cipher",
]
[[package]]
name = "cc"
version = "1.2.55"
@ -527,6 +545,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrono"
version = "0.4.43"
@ -842,6 +866,35 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "dbus"
version = "0.9.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21b3aa68d7e7abee336255bd7248ea965cc393f3e70411135a6f6a4b651345d4"
dependencies = [
"libc",
"libdbus-sys",
"windows-sys 0.59.0",
]
[[package]]
name = "dbus-secret-service"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6"
dependencies = [
"aes",
"block-padding",
"cbc",
"dbus",
"fastrand",
"hkdf",
"num",
"once_cell",
"sha2",
"zeroize",
]
[[package]]
name = "der"
version = "0.7.10"
@ -2176,6 +2229,7 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"block-padding",
"generic-array",
]
@ -2323,6 +2377,20 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "keyring"
version = "3.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
dependencies = [
"byteorder",
"dbus-secret-service",
"log",
"secret-service",
"windows-sys 0.60.2",
"zeroize",
]
[[package]]
name = "kuchikiki"
version = "0.8.8-speedreader"
@ -2374,6 +2442,15 @@ version = "0.2.180"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
[[package]]
name = "libdbus-sys"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043"
dependencies = [
"pkg-config",
]
[[package]]
name = "libloading"
version = "0.7.4"
@ -2619,12 +2696,39 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nix"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [
"bitflags 2.10.0",
"cfg-if",
"cfg_aliases",
"libc",
"memoffset",
]
[[package]]
name = "nodrop"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
[[package]]
name = "num"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
dependencies = [
"num-bigint",
"num-complex",
"num-integer",
"num-iter",
"num-rational",
"num-traits",
]
[[package]]
name = "num-bigint"
version = "0.4.6"
@ -2651,6 +2755,15 @@ dependencies = [
"zeroize",
]
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.2.0"
@ -2677,6 +2790,17 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
"num-bigint",
"num-integer",
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@ -4003,6 +4127,25 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "secret-service"
version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4"
dependencies = [
"aes",
"cbc",
"futures-util",
"generic-array",
"hkdf",
"num",
"once_cell",
"rand 0.8.5",
"serde",
"sha2",
"zbus 4.4.0",
]
[[package]]
name = "security-framework"
version = "3.5.1"
@ -4289,6 +4432,7 @@ dependencies = [
"encoding_rs",
"hostname",
"jsonwebtoken",
"keyring",
"libsqlite3-sys",
"machine-uid",
"rand 0.8.5",
@ -4309,6 +4453,7 @@ dependencies = [
"tokio",
"urlencoding",
"walkdir",
"zeroize",
]
[[package]]
@ -4629,6 +4774,12 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "string_cache"
version = "0.8.9"
@ -5043,7 +5194,7 @@ dependencies = [
"thiserror 2.0.18",
"url",
"windows",
"zbus",
"zbus 5.13.2",
]
[[package]]
@ -5069,7 +5220,7 @@ dependencies = [
"thiserror 2.0.18",
"tracing",
"windows-sys 0.60.2",
"zbus",
"zbus 5.13.2",
]
[[package]]
@ -6697,6 +6848,16 @@ dependencies = [
"rustix",
]
[[package]]
name = "xdg-home"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6"
dependencies = [
"libc",
"windows-sys 0.59.0",
]
[[package]]
name = "yoke"
version = "0.8.1"
@ -6720,6 +6881,38 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zbus"
version = "4.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725"
dependencies = [
"async-broadcast",
"async-process",
"async-recursion",
"async-trait",
"enumflags2",
"event-listener",
"futures-core",
"futures-sink",
"futures-util",
"hex",
"nix",
"ordered-stream",
"rand 0.8.5",
"serde",
"serde_repr",
"sha1",
"static_assertions",
"tracing",
"uds_windows",
"windows-sys 0.52.0",
"xdg-home",
"zbus_macros 4.4.0",
"zbus_names 3.0.0",
"zvariant 4.2.0",
]
[[package]]
name = "zbus"
version = "5.13.2"
@ -6750,9 +6943,22 @@ dependencies = [
"uuid",
"windows-sys 0.61.2",
"winnow 0.7.14",
"zbus_macros",
"zbus_names",
"zvariant",
"zbus_macros 5.13.2",
"zbus_names 4.3.1",
"zvariant 5.9.2",
]
[[package]]
name = "zbus_macros"
version = "4.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e"
dependencies = [
"proc-macro-crate 3.4.0",
"proc-macro2",
"quote",
"syn 2.0.114",
"zvariant_utils 2.1.0",
]
[[package]]
@ -6765,9 +6971,20 @@ dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"zbus_names",
"zvariant",
"zvariant_utils",
"zbus_names 4.3.1",
"zvariant 5.9.2",
"zvariant_utils 3.3.0",
]
[[package]]
name = "zbus_names"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c"
dependencies = [
"serde",
"static_assertions",
"zvariant 4.2.0",
]
[[package]]
@ -6778,7 +6995,7 @@ checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f"
dependencies = [
"serde",
"winnow 0.7.14",
"zvariant",
"zvariant 5.9.2",
]
[[package]]
@ -6827,6 +7044,20 @@ name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "zerotrie"
@ -6879,6 +7110,19 @@ version = "1.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445"
[[package]]
name = "zvariant"
version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe"
dependencies = [
"endi",
"enumflags2",
"serde",
"static_assertions",
"zvariant_derive 4.2.0",
]
[[package]]
name = "zvariant"
version = "5.9.2"
@ -6889,8 +7133,21 @@ dependencies = [
"enumflags2",
"serde",
"winnow 0.7.14",
"zvariant_derive",
"zvariant_utils",
"zvariant_derive 5.9.2",
"zvariant_utils 3.3.0",
]
[[package]]
name = "zvariant_derive"
version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449"
dependencies = [
"proc-macro-crate 3.4.0",
"proc-macro2",
"quote",
"syn 2.0.114",
"zvariant_utils 2.1.0",
]
[[package]]
@ -6903,7 +7160,18 @@ dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"zvariant_utils",
"zvariant_utils 3.3.0",
]
[[package]]
name = "zvariant_utils"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]

View file

@ -44,6 +44,13 @@ tokio = { version = "1", features = ["macros"] }
hostname = "0.4"
urlencoding = "2"
base64 = "0.22"
# OAuth token storage in OS keychain (Credential Manager on Windows,
# Secret Service on Linux). We use sync-secret-service to get sync
# methods that are safe to call from async Tauri commands without
# tokio runtime entanglement. Requires libdbus-1-dev at build time
# on Linux (libdbus-1-3 is present on every desktop Linux at runtime).
keyring = { version = "3.6", default-features = false, features = ["sync-secret-service", "crypto-rust", "windows-native"] }
zeroize = "1"
[dev-dependencies]
# Used in license_commands.rs tests to sign test JWTs. We avoid the `pem`

View file

@ -1,25 +1,28 @@
// OAuth2 PKCE flow for Compte Maximus (Logto) integration.
//
// Architecture:
// - The desktop app is registered as a "Native App" in Logto (public client, no secret).
// - The desktop app is registered as a "Native App" in Logto (public
// client, no secret).
// - OAuth2 Authorization Code + PKCE flow via the system browser.
// - Deep-link callback: simpl-resultat://auth/callback?code=...
// - Tokens are stored as files in app_data_dir/auth/ (encrypted at rest in a future
// iteration via OS keychain). For now, plain JSON — acceptable because:
// (a) the app data dir has user-only permissions,
// (b) the access token is short-lived (1h default in Logto),
// (c) the refresh token is rotated on each use.
// - Tokens are persisted through `token_store` which prefers the OS
// keychain (Credential Manager / Secret Service) and falls back to a
// restricted file only when no prior keychain success has been
// recorded. See `token_store.rs` for details.
//
// The PKCE verifier is held in memory via Tauri managed state, so it cannot be
// intercepted by another process. It is cleared after the callback exchange.
// The PKCE verifier is held in memory via Tauri managed state, so it
// cannot be intercepted by another process. It is cleared after the
// callback exchange.
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use tauri::Manager;
use super::token_store::{
self, auth_dir, chrono_now, write_restricted, StoredTokens,
};
// Logto endpoint — overridable via env var for development.
fn logto_endpoint() -> String {
std::env::var("LOGTO_ENDPOINT")
@ -32,8 +35,6 @@ fn logto_app_id() -> String {
}
const REDIRECT_URI: &str = "simpl-resultat://auth/callback";
const AUTH_DIR: &str = "auth";
const TOKENS_FILE: &str = "tokens.json";
const ACCOUNT_FILE: &str = "account.json";
const LAST_CHECK_FILE: &str = "last_check";
const CHECK_INTERVAL_SECS: i64 = 86400; // 24 hours
@ -52,50 +53,6 @@ pub struct AccountInfo {
pub subscription_status: Option<String>,
}
/// Stored tokens (written to auth/tokens.json).
#[derive(Debug, Clone, Serialize, Deserialize)]
struct StoredTokens {
access_token: String,
refresh_token: Option<String>,
id_token: Option<String>,
expires_at: i64,
}
fn auth_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
let dir = app
.path()
.app_data_dir()
.map_err(|e| format!("Cannot get app data dir: {}", e))?
.join(AUTH_DIR);
if !dir.exists() {
fs::create_dir_all(&dir).map_err(|e| format!("Cannot create auth dir: {}", e))?;
}
Ok(dir)
}
/// Write a file with restricted permissions (0600 on Unix) for sensitive data like tokens.
fn write_restricted(path: &Path, contents: &str) -> Result<(), String> {
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
let mut file = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)
.map_err(|e| format!("Cannot write {}: {}", path.display(), e))?;
file.write_all(contents.as_bytes())
.map_err(|e| format!("Cannot write {}: {}", path.display(), e))?;
}
#[cfg(not(unix))]
{
fs::write(path, contents)
.map_err(|e| format!("Cannot write {}: {}", path.display(), e))?;
}
Ok(())
}
fn generate_pkce() -> (String, String) {
use rand::Rng;
let mut rng = rand::thread_rng();
@ -189,22 +146,20 @@ pub async fn handle_auth_callback(app: tauri::AppHandle, code: String) -> Result
let expires_in = token_resp["expires_in"].as_i64().unwrap_or(3600);
let expires_at = chrono_now() + expires_in;
// Store tokens
// Persist tokens through token_store (prefers keychain over file).
let tokens = StoredTokens {
access_token: access_token.clone(),
refresh_token,
id_token,
expires_at,
};
let dir = auth_dir(&app)?;
let tokens_json =
serde_json::to_string_pretty(&tokens).map_err(|e| format!("Serialize error: {}", e))?;
write_restricted(&dir.join(TOKENS_FILE), &tokens_json)?;
token_store::save(&app, &tokens)?;
// Fetch user info
let account = fetch_userinfo(&endpoint, &access_token).await?;
// Store account info
// Store account info (non-secret; stays in the restricted file store).
let dir = auth_dir(&app)?;
let account_json =
serde_json::to_string_pretty(&account).map_err(|e| format!("Serialize error: {}", e))?;
write_restricted(&dir.join(ACCOUNT_FILE), &account_json)?;
@ -215,16 +170,7 @@ pub async fn handle_auth_callback(app: tauri::AppHandle, code: String) -> Result
/// Refresh the access token using the stored refresh token.
#[tauri::command]
pub async fn refresh_auth_token(app: tauri::AppHandle) -> Result<AccountInfo, String> {
let dir = auth_dir(&app)?;
let tokens_path = dir.join(TOKENS_FILE);
if !tokens_path.exists() {
return Err("Not authenticated".to_string());
}
let tokens_raw =
fs::read_to_string(&tokens_path).map_err(|e| format!("Cannot read tokens: {}", e))?;
let tokens: StoredTokens =
serde_json::from_str(&tokens_raw).map_err(|e| format!("Invalid tokens file: {}", e))?;
let tokens = token_store::load(&app)?.ok_or_else(|| "Not authenticated".to_string())?;
let refresh_token = tokens
.refresh_token
@ -247,8 +193,9 @@ pub async fn refresh_auth_token(app: tauri::AppHandle) -> Result<AccountInfo, St
.map_err(|e| format!("Token refresh failed: {}", e))?;
if !resp.status().is_success() {
// Clear stored tokens on refresh failure
let _ = fs::remove_file(&tokens_path);
// Clear stored tokens on refresh failure.
let _ = token_store::delete(&app);
let dir = auth_dir(&app)?;
let _ = fs::remove_file(dir.join(ACCOUNT_FILE));
return Err("Session expired, please sign in again".to_string());
}
@ -272,11 +219,10 @@ pub async fn refresh_auth_token(app: tauri::AppHandle) -> Result<AccountInfo, St
expires_at: chrono_now() + expires_in,
};
let tokens_json = serde_json::to_string_pretty(&new_tokens)
.map_err(|e| format!("Serialize error: {}", e))?;
write_restricted(&tokens_path, &tokens_json)?;
token_store::save(&app, &new_tokens)?;
let account = fetch_userinfo(&endpoint, &new_access).await?;
let dir = auth_dir(&app)?;
let account_json =
serde_json::to_string_pretty(&account).map_err(|e| format!("Serialize error: {}", e))?;
write_restricted(&dir.join(ACCOUNT_FILE), &account_json)?;
@ -301,8 +247,8 @@ pub fn get_account_info(app: tauri::AppHandle) -> Result<Option<AccountInfo>, St
/// Log out: clear all stored tokens and account info.
#[tauri::command]
pub fn logout(app: tauri::AppHandle) -> Result<(), String> {
token_store::delete(&app)?;
let dir = auth_dir(&app)?;
let _ = fs::remove_file(dir.join(TOKENS_FILE));
let _ = fs::remove_file(dir.join(ACCOUNT_FILE));
Ok(())
}
@ -314,13 +260,14 @@ pub fn logout(app: tauri::AppHandle) -> Result<(), String> {
pub async fn check_subscription_status(
app: tauri::AppHandle,
) -> Result<Option<AccountInfo>, String> {
let dir = auth_dir(&app)?;
// Not authenticated — nothing to check
if !dir.join(TOKENS_FILE).exists() {
// Not authenticated — nothing to check. This also triggers migration
// from a legacy tokens.json file into the keychain when present,
// because token_store::load() performs the migration eagerly.
if token_store::load(&app)?.is_none() {
return Ok(None);
}
let dir = auth_dir(&app)?;
let last_check_path = dir.join(LAST_CHECK_FILE);
let now = chrono_now();
@ -383,10 +330,3 @@ async fn fetch_userinfo(endpoint: &str, access_token: &str) -> Result<AccountInf
.map(|s| s.to_string()),
})
}
fn chrono_now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64
}

View file

@ -4,6 +4,7 @@ pub mod export_import_commands;
pub mod fs_commands;
pub mod license_commands;
pub mod profile_commands;
pub mod token_store;
pub use auth_commands::*;
pub use entitlements::*;
@ -11,3 +12,4 @@ pub use export_import_commands::*;
pub use fs_commands::*;
pub use license_commands::*;
pub use profile_commands::*;
pub use token_store::*;

View file

@ -0,0 +1,393 @@
// OAuth token storage abstraction.
//
// This module centralises how OAuth2 tokens are persisted. It tries the OS
// keychain first (Credential Manager on Windows, Secret Service on Linux
// via libdbus) and falls back to a restricted file on disk if the keychain
// is unavailable.
//
// Security properties:
// - The keychain service name matches the Tauri bundle identifier
// (com.simpl.resultat) so OS tools and future macOS builds can scope
// credentials correctly.
// - A `store_mode` flag is persisted next to the fallback file. Once the
// keychain has been used successfully, the store refuses to silently
// downgrade to the file fallback: a subsequent keychain failure is
// surfaced as an error so the caller can force re-authentication
// instead of leaving the user with undetected plaintext tokens.
// - Migration from an existing `tokens.json` file zeros the file contents
// and fsyncs before unlinking, reducing the window where the refresh
// token is recoverable from unallocated disk blocks.
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use tauri::Manager;
use zeroize::Zeroize;
// Keychain identifiers. The service name matches tauri.conf.json's
// `identifier` so credentials are scoped to the real app identity.
const KEYCHAIN_SERVICE: &str = "com.simpl.resultat";
const KEYCHAIN_USER_TOKENS: &str = "oauth-tokens";
pub(crate) const AUTH_DIR: &str = "auth";
const TOKENS_FILE: &str = "tokens.json";
const STORE_MODE_FILE: &str = "store_mode";
/// Where token material currently lives. Exposed via a Tauri command so
/// the frontend can display a security banner when the app has fallen
/// back to the file store.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum StoreMode {
Keychain,
File,
}
impl StoreMode {
fn as_str(&self) -> &'static str {
match self {
StoreMode::Keychain => "keychain",
StoreMode::File => "file",
}
}
fn parse(raw: &str) -> Option<Self> {
match raw.trim() {
"keychain" => Some(StoreMode::Keychain),
"file" => Some(StoreMode::File),
_ => None,
}
}
}
/// Serialised OAuth token bundle. Owned by `token_store` because this is
/// the only module that should reach for the persisted bytes.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoredTokens {
pub access_token: String,
pub refresh_token: Option<String>,
pub id_token: Option<String>,
pub expires_at: i64,
}
/// Resolve `<app_data_dir>/auth/`, creating it if needed. Shared with
/// auth_commands.rs which still writes non-secret files (account info,
/// last-check timestamp) in the same directory.
pub(crate) fn auth_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
let dir = app
.path()
.app_data_dir()
.map_err(|e| format!("Cannot get app data dir: {}", e))?
.join(AUTH_DIR);
if !dir.exists() {
fs::create_dir_all(&dir).map_err(|e| format!("Cannot create auth dir: {}", e))?;
}
Ok(dir)
}
/// Write a file with 0600 permissions on Unix. Windows has no cheap
/// equivalent here; callers that rely on this function for secrets should
/// treat the Windows path as a last-resort fallback and surface the
/// degraded state to the user (see StoreMode).
pub(crate) fn write_restricted(path: &Path, contents: &str) -> Result<(), String> {
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
let mut file = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)
.map_err(|e| format!("Cannot write {}: {}", path.display(), e))?;
file.write_all(contents.as_bytes())
.map_err(|e| format!("Cannot write {}: {}", path.display(), e))?;
file.sync_all()
.map_err(|e| format!("Cannot fsync {}: {}", path.display(), e))?;
}
#[cfg(not(unix))]
{
fs::write(path, contents)
.map_err(|e| format!("Cannot write {}: {}", path.display(), e))?;
}
Ok(())
}
pub(crate) fn chrono_now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64
}
fn read_store_mode(dir: &Path) -> Option<StoreMode> {
let path = dir.join(STORE_MODE_FILE);
let raw = fs::read_to_string(&path).ok()?;
StoreMode::parse(&raw)
}
fn write_store_mode(dir: &Path, mode: StoreMode) -> Result<(), String> {
write_restricted(&dir.join(STORE_MODE_FILE), mode.as_str())
}
/// Overwrite the file contents with zeros, fsync, then unlink. Best-effort
/// mitigation against recovery of the refresh token from unallocated
/// blocks on copy-on-write filesystems where a plain unlink leaves the
/// ciphertext recoverable. Not a substitute for proper disk encryption.
fn zero_and_delete(path: &Path) -> Result<(), String> {
if !path.exists() {
return Ok(());
}
let len = fs::metadata(path)
.map(|m| m.len() as usize)
.unwrap_or(0)
.max(512);
let mut zeros = vec![0u8; len];
{
let mut f = fs::OpenOptions::new()
.write(true)
.truncate(false)
.open(path)
.map_err(|e| format!("Cannot open {} for wipe: {}", path.display(), e))?;
f.write_all(&zeros)
.map_err(|e| format!("Cannot zero {}: {}", path.display(), e))?;
f.sync_all()
.map_err(|e| format!("Cannot fsync {}: {}", path.display(), e))?;
}
zeros.zeroize();
fs::remove_file(path).map_err(|e| format!("Cannot remove {}: {}", path.display(), e))
}
fn tokens_to_json(tokens: &StoredTokens) -> Result<String, String> {
serde_json::to_string(tokens).map_err(|e| format!("Serialize error: {}", e))
}
fn tokens_from_json(raw: &str) -> Result<StoredTokens, String> {
serde_json::from_str(raw).map_err(|e| format!("Invalid tokens payload: {}", e))
}
fn keychain_entry() -> Result<keyring::Entry, keyring::Error> {
keyring::Entry::new(KEYCHAIN_SERVICE, KEYCHAIN_USER_TOKENS)
}
fn keychain_save(json: &str) -> Result<(), keyring::Error> {
keychain_entry()?.set_password(json)
}
fn keychain_load() -> Result<Option<String>, keyring::Error> {
match keychain_entry()?.get_password() {
Ok(v) => Ok(Some(v)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => Err(e),
}
}
fn keychain_delete() -> Result<(), keyring::Error> {
match keychain_entry()?.delete_credential() {
Ok(()) => Ok(()),
Err(keyring::Error::NoEntry) => Ok(()),
Err(e) => Err(e),
}
}
/// Persist the current OAuth token bundle.
///
/// Tries the OS keychain first. If the keychain write fails AND the
/// persisted `store_mode` flag shows the keychain has worked before, the
/// caller receives an error instead of a silent downgrade — this
/// prevents a hostile local process from forcing the app into the
/// weaker file-fallback path. On a fresh install (no prior flag), the
/// fallback is allowed but recorded so subsequent calls know the app is
/// running in degraded mode.
pub fn save(app: &tauri::AppHandle, tokens: &StoredTokens) -> Result<(), String> {
let json = tokens_to_json(tokens)?;
let dir = auth_dir(app)?;
let previous_mode = read_store_mode(&dir);
match keychain_save(&json) {
Ok(()) => {
// Keychain succeeded. Clean up any residual file from a prior
// fallback or migration source so nothing stays readable.
let residual = dir.join(TOKENS_FILE);
if residual.exists() {
let _ = zero_and_delete(&residual);
}
write_store_mode(&dir, StoreMode::Keychain)?;
Ok(())
}
Err(err) => {
if previous_mode == Some(StoreMode::Keychain) {
// Refuse to downgrade after a prior success — surface the
// failure so the caller can force re-auth instead of
// silently leaking tokens to disk.
return Err(format!(
"Keychain unavailable after prior success — refusing to downgrade. \
Original error: {}",
err
));
}
eprintln!(
"token_store: keychain unavailable, falling back to file store ({})",
err
);
write_restricted(&dir.join(TOKENS_FILE), &json)?;
write_store_mode(&dir, StoreMode::File)?;
Ok(())
}
}
}
/// Load the current OAuth token bundle.
///
/// Tries the keychain first. If the keychain is empty but a legacy
/// `tokens.json` file exists, this is a first-run migration: the tokens
/// are copied into the keychain, the file is zeroed and unlinked, and
/// `store_mode` is updated. If the keychain itself is unreachable, the
/// function falls back to reading the file — unless the `store_mode`
/// flag indicates the keychain has worked before, in which case it
/// returns an error to force re-auth.
pub fn load(app: &tauri::AppHandle) -> Result<Option<StoredTokens>, String> {
let dir = auth_dir(app)?;
let previous_mode = read_store_mode(&dir);
let residual = dir.join(TOKENS_FILE);
match keychain_load() {
Ok(Some(raw)) => {
let tokens = tokens_from_json(&raw)?;
// Defensive: if a leftover file is still around (e.g. a prior
// crash between keychain write and file delete), clean it up.
if residual.exists() {
let _ = zero_and_delete(&residual);
}
if previous_mode != Some(StoreMode::Keychain) {
write_store_mode(&dir, StoreMode::Keychain)?;
}
Ok(Some(tokens))
}
Ok(None) => {
// Keychain reachable but empty. Migrate from a legacy file if
// one exists, otherwise report no stored session.
if residual.exists() {
let raw = fs::read_to_string(&residual)
.map_err(|e| format!("Cannot read {}: {}", residual.display(), e))?;
let tokens = tokens_from_json(&raw)?;
// Push into keychain and wipe the file. If the keychain
// push fails here, we keep the file rather than losing
// the user's session.
match keychain_save(&raw) {
Ok(()) => {
let _ = zero_and_delete(&residual);
write_store_mode(&dir, StoreMode::Keychain)?;
}
Err(e) => {
eprintln!("token_store: migration to keychain failed ({})", e);
write_store_mode(&dir, StoreMode::File)?;
}
}
Ok(Some(tokens))
} else {
Ok(None)
}
}
Err(err) => {
if previous_mode == Some(StoreMode::Keychain) {
return Err(format!(
"Keychain unavailable after prior success: {}",
err
));
}
// No prior keychain success: honour the file fallback if any.
eprintln!(
"token_store: keychain unreachable, using file fallback ({})",
err
);
if residual.exists() {
let raw = fs::read_to_string(&residual)
.map_err(|e| format!("Cannot read {}: {}", residual.display(), e))?;
write_store_mode(&dir, StoreMode::File)?;
Ok(Some(tokens_from_json(&raw)?))
} else {
Ok(None)
}
}
}
}
/// Delete the stored tokens from both the keychain and the file
/// fallback. Both deletions are best-effort and ignore "no entry"
/// errors to stay idempotent.
pub fn delete(app: &tauri::AppHandle) -> Result<(), String> {
let dir = auth_dir(app)?;
if let Err(err) = keychain_delete() {
eprintln!("token_store: keychain delete failed ({})", err);
}
let residual = dir.join(TOKENS_FILE);
if residual.exists() {
let _ = zero_and_delete(&residual);
}
// Leave the store_mode flag alone: it still describes what the app
// should trust the next time `save` is called.
Ok(())
}
/// Current store mode, derived from the persisted flag. Returns `None`
/// if no tokens have ever been written (no flag file).
pub fn store_mode(app: &tauri::AppHandle) -> Result<Option<StoreMode>, String> {
let dir = auth_dir(app)?;
Ok(read_store_mode(&dir))
}
/// Tauri command: expose the current store mode to the frontend.
/// Returns `"keychain"`, `"file"`, or `null` if the app has no stored
/// session yet. Used by the settings UI to show a security banner when
/// the fallback is active.
#[tauri::command]
pub fn get_token_store_mode(app: tauri::AppHandle) -> Result<Option<String>, String> {
Ok(store_mode(&app)?.map(|m| m.as_str().to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn store_mode_roundtrip() {
assert_eq!(StoreMode::parse("keychain"), Some(StoreMode::Keychain));
assert_eq!(StoreMode::parse("file"), Some(StoreMode::File));
assert_eq!(StoreMode::parse("other"), None);
assert_eq!(StoreMode::Keychain.as_str(), "keychain");
assert_eq!(StoreMode::File.as_str(), "file");
}
#[test]
fn stored_tokens_serde_roundtrip() {
let tokens = StoredTokens {
access_token: "at".into(),
refresh_token: Some("rt".into()),
id_token: Some("it".into()),
expires_at: 42,
};
let json = tokens_to_json(&tokens).unwrap();
let decoded = tokens_from_json(&json).unwrap();
assert_eq!(decoded.access_token, "at");
assert_eq!(decoded.refresh_token.as_deref(), Some("rt"));
assert_eq!(decoded.id_token.as_deref(), Some("it"));
assert_eq!(decoded.expires_at, 42);
}
#[test]
fn zero_and_delete_removes_file() {
use std::io::Write as _;
let tmp = std::env::temp_dir().join(format!(
"simpl-resultat-token-store-test-{}",
std::process::id()
));
let mut f = fs::File::create(&tmp).unwrap();
f.write_all(b"sensitive").unwrap();
drop(f);
assert!(tmp.exists());
zero_and_delete(&tmp).unwrap();
assert!(!tmp.exists());
}
}

View file

@ -184,6 +184,7 @@ pub fn run() {
commands::get_account_info,
commands::check_subscription_status,
commands::logout,
commands::get_token_store_mode,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");