feat: OAuth token storage via OS keychain (#78) #83
6 changed files with 712 additions and 101 deletions
292
src-tauri/Cargo.lock
generated
292
src-tauri/Cargo.lock
generated
|
|
@ -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]]
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
393
src-tauri/src/commands/token_store.rs
Normal file
393
src-tauri/src/commands/token_store.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in a new issue