feat: keyring (#3159)

This commit is contained in:
pochoclin
2025-09-30 13:25:54 -04:00
committed by GitHub
parent 4a871bcd3f
commit 6156df9128
12 changed files with 347 additions and 431 deletions

246
Cargo.lock generated
View File

@@ -111,12 +111,6 @@ dependencies = [
"derive_arbitrary",
]
[[package]]
name = "arraydeque"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"
[[package]]
name = "arrayvec"
version = "0.7.6"
@@ -712,25 +706,6 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "config"
version = "0.15.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b1eb4fb07bc7f012422df02766c7bd5971effb894f573865642f06fa3265440"
dependencies = [
"async-trait",
"convert_case 0.6.0",
"json5",
"pathdiff",
"ron",
"rust-ini",
"serde",
"serde_json",
"toml 0.9.7",
"winnow 0.7.13",
"yaml-rust2",
]
[[package]]
name = "console"
version = "0.15.11"
@@ -808,15 +783,6 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e"
[[package]]
name = "convert_case"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "cookie"
version = "0.18.1"
@@ -1080,7 +1046,7 @@ version = "0.99.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3da29a38df43d6f156149c9b43ded5e018ddff2a855cf2cfd62e8cd7d079c69f"
dependencies = [
"convert_case 0.4.0",
"convert_case",
"proc-macro2",
"quote",
"rustc_version",
@@ -1400,12 +1366,6 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foreign-types"
version = "0.3.2"
@@ -1457,15 +1417,6 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fsevent-sys"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
dependencies = [
"libc",
]
[[package]]
name = "funty"
version = "2.0.0"
@@ -1914,18 +1865,6 @@ name = "hashbrown"
version = "0.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289"
dependencies = [
"foldhash",
]
[[package]]
name = "hashlink"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
dependencies = [
"hashbrown 0.15.2",
]
[[package]]
name = "hdrhistogram"
@@ -2356,26 +2295,6 @@ dependencies = [
"cfb",
]
[[package]]
name = "inotify"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3"
dependencies = [
"bitflags 2.8.0",
"inotify-sys",
"libc",
]
[[package]]
name = "inotify-sys"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
dependencies = [
"libc",
]
[[package]]
name = "insta"
version = "1.43.2"
@@ -2515,17 +2434,6 @@ dependencies = [
"thiserror 1.0.69",
]
[[package]]
name = "json5"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1"
dependencies = [
"pest",
"pest_derive",
"serde",
]
[[package]]
name = "jsonptr"
version = "0.6.3"
@@ -2563,23 +2471,18 @@ dependencies = [
]
[[package]]
name = "kqueue"
version = "1.1.1"
name = "keyring"
version = "3.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a"
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
dependencies = [
"kqueue-sys",
"libc",
]
[[package]]
name = "kqueue-sys"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b"
dependencies = [
"bitflags 1.3.2",
"libc",
"byteorder",
"linux-keyutils",
"log",
"security-framework 2.11.1",
"security-framework 3.3.0",
"windows-sys 0.60.2",
"zeroize",
]
[[package]]
@@ -2660,6 +2563,16 @@ dependencies = [
"redox_syscall",
]
[[package]]
name = "linux-keyutils"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "761e49ec5fd8a5a463f9b84e877c373d888935b71c6be78f3767fe2ae6bed18e"
dependencies = [
"bitflags 2.8.0",
"libc",
]
[[package]]
name = "linux-raw-sys"
version = "0.4.15"
@@ -2832,7 +2745,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd"
dependencies = [
"libc",
"log",
"wasi 0.11.0+wasi-snapshot-preview1",
"windows-sys 0.52.0",
]
@@ -2870,7 +2782,7 @@ dependencies = [
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework 2.11.1",
"security-framework-sys",
"tempfile",
]
@@ -2940,30 +2852,6 @@ dependencies = [
"minimal-lexical",
]
[[package]]
name = "notify"
version = "8.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
dependencies = [
"bitflags 2.8.0",
"fsevent-sys",
"inotify",
"kqueue",
"libc",
"log",
"mio",
"notify-types",
"walkdir",
"windows-sys 0.60.2",
]
[[package]]
name = "notify-types"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e0826a989adedc2a244799e823aece04662b66609d96af8dff7ac6df9a8925d"
[[package]]
name = "nu-ansi-term"
version = "0.50.1"
@@ -3533,51 +3421,6 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pest"
version = "2.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b7cafe60d6cf8e62e1b9b2ea516a089c008945bb5a275416789e7db0bc199dc"
dependencies = [
"memchr",
"thiserror 2.0.11",
"ucd-trie",
]
[[package]]
name = "pest_derive"
version = "2.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "816518421cfc6887a0d62bf441b6ffb4536fcc926395a69e1a85852d4363f57e"
dependencies = [
"pest",
"pest_generator",
]
[[package]]
name = "pest_generator"
version = "2.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d1396fd3a870fc7838768d171b4616d5c91f6cc25e377b673d714567d99377b"
dependencies = [
"pest",
"pest_meta",
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "pest_meta"
version = "2.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1e58089ea25d717bfd31fb534e4f3afcc2cc569c70de3e239778991ea3b7dea"
dependencies = [
"once_cell",
"pest",
"sha2",
]
[[package]]
name = "phf"
version = "0.8.0"
@@ -3877,9 +3720,8 @@ name = "popcorntime-session"
version = "0.0.1"
dependencies = [
"anyhow",
"config",
"jsonwebtoken",
"notify",
"keyring",
"oauth2",
"poem",
"popcorntime-error",
@@ -4433,18 +4275,6 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "ron"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94"
dependencies = [
"base64 0.21.7",
"bitflags 2.8.0",
"serde",
"serde_derive",
]
[[package]]
name = "rust-ini"
version = "0.21.1"
@@ -4635,6 +4465,19 @@ dependencies = [
"security-framework-sys",
]
[[package]]
name = "security-framework"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80fb1d92c5028aa318b4b8bd7302a5bfcf48be96a37fc6fc790f806b0004ee0c"
dependencies = [
"bitflags 2.8.0",
"core-foundation 0.10.0",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.14.0"
@@ -6260,12 +6103,6 @@ version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"
[[package]]
name = "ucd-trie"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
[[package]]
name = "uds_windows"
version = "1.1.0"
@@ -7245,17 +7082,6 @@ dependencies = [
"rustix 1.0.3",
]
[[package]]
name = "yaml-rust2"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2462ea039c445496d8793d052e13787f2b90e750b833afee748e601c17621ed9"
dependencies = [
"arraydeque",
"encoding_rs",
"hashlink",
]
[[package]]
name = "yansi"
version = "1.0.1"

View File

@@ -52,12 +52,10 @@ export const SessionProvider = ({ children }: { children: ReactNode }) => {
setActive(true);
} catch (e) {
setActive(false);
if (
!isPublicRoute(pathRef.current) &&
isTauriError(e) &&
e.code === "errors.session.invalid"
) {
navigateRef.current("/login", { replace: true });
if (isTauriError(e) && e.code === "errors.session.invalid") {
if (!isPublicRoute(pathRef.current)) {
navigateRef.current("/login", { replace: true });
}
} else {
throw e;
}

View File

@@ -1,9 +1,4 @@
import { StrictMode } from "react";
import ReactDOM from "react-dom/client";
import { App } from "@/app";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<StrictMode>
<App />
</StrictMode>
);
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(<App />);

View File

@@ -126,7 +126,7 @@ sessionUpdate: "session-update"
/** user-defined types **/
export type Availability = { providerId: string; providerName: string; logo: string | null; availableTo: Date | null; urlHash: string; audioLanguages: Language[] | null; subtitleLanguages: Language[] | null; pricesType: WatchPriceType[] | null }
export type Code = "errors.unknown" | "errors.graphql.server" | "errors.database.not_available" | "errors.session.invalid" | "errors.events.invalid" | "errors.graphql.no_data"
export type Code = "errors.unknown" | "errors.graphql.server" | "errors.database.not_available" | "errors.session.invalid" | "errors.session.keyring" | "errors.events.invalid" | "errors.graphql.no_data"
export type Country = string
export type Date = string
export type DateTime = string

View File

@@ -11,6 +11,8 @@ pub enum Code {
DatabaseNotAvailable,
#[serde(rename = "errors.session.invalid")]
InvalidSession,
#[serde(rename = "errors.session.keyring")]
InvalidSessionKeyring,
#[serde(rename = "errors.events.invalid")]
InvalidEvent,
#[serde(rename = "errors.graphql.no_data")]
@@ -26,6 +28,7 @@ impl std::fmt::Display for Code {
Code::DatabaseNotAvailable => "errors.database.not_available",
Code::GraphqlNoData => "errors.graphql.no_data",
Code::InvalidEvent => "errors.events.invalid",
Code::InvalidSessionKeyring => "errors.session.keyring",
};
f.write_str(code)
}

View File

@@ -23,6 +23,9 @@ poem.workspace = true
popcorntime-error.workspace = true
oauth2 = "5.0.0"
notify = "8.2.0"
toml = "0.9.7"
config = "0.15.13"
keyring = { version = "3.6.3", features = [
"apple-native",
"windows-native",
"linux-native",
] }

View File

@@ -1,3 +1,4 @@
use crate::server::run_local_oauth_server;
use anyhow::Result;
use oauth2::basic::{BasicClient, BasicTokenType};
use oauth2::{AuthUrl, ClientId, RedirectUrl, TokenUrl};
@@ -13,9 +14,6 @@ use tokio::sync::mpsc;
use tokio::time::timeout;
use url::Url;
use crate::server::run_local_oauth_server;
use crate::session::AppSession;
const PORT: u16 = 8085;
// Thread timeout in seconds
const THREAD_TIMEOUT: u64 = 300;
@@ -108,18 +106,15 @@ impl AuthorizationBroker {
pub async fn exchange_refresh_token(
&self,
session: &AppSession,
refresh_token: &RefreshToken,
) -> Result<AuthorizationBrokerResponse> {
match session.refresh_token() {
Some(refresh_token) => self
.oauth2_client
.exchange_refresh_token(&RefreshToken::new(refresh_token.to_string()))
.request_async(self.reqwest_client.as_ref())
.await
.map(Into::into)
.map_err(Into::into),
None => Err(anyhow::anyhow!("No refresh token found")),
}
self
.oauth2_client
.exchange_refresh_token(refresh_token)
.request_async(self.reqwest_client.as_ref())
.await
.map(Into::into)
.map_err(Into::into)
}
pub async fn authorize_in_background(
@@ -133,7 +128,6 @@ impl AuthorizationBroker {
}
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
let (tx, mut rx) = mpsc::channel::<(AuthorizationCode, CsrfToken)>(1);
let http_client = self.reqwest_client.clone();
let oauth2_client = self.oauth2_client.clone();

View File

@@ -0,0 +1,64 @@
use anyhow::{Context, Result};
use core::fmt;
use keyring::{Credential, default::default_credential_builder};
use popcorntime_error::Code;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::RwLock;
const SERVICE: &str = "Popcorn Time";
#[derive(Clone, Serialize, Deserialize, Default)]
pub struct SecretBundle {
pub access_token: Option<String>,
pub refresh_token: Option<String>,
}
impl fmt::Debug for SecretBundle {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SecretBundle")
.field("access_token", &self.access_token.as_ref().map(|_| "****"))
.field(
"refresh_token",
&self.refresh_token.as_ref().map(|_| "****"),
)
.finish()
}
}
#[derive(Debug)]
pub struct KeyringVault {
credential: Box<Credential>,
cache: Arc<RwLock<Option<SecretBundle>>>,
}
impl KeyringVault {
pub fn new(namespace: &str) -> Result<Self> {
let credential = default_credential_builder().build(None, SERVICE, namespace)?;
Ok(Self {
credential,
cache: Arc::new(RwLock::new(None)),
})
}
pub fn get(&self) -> Result<SecretBundle> {
match self.credential.get_secret() {
Ok(s) => {
let bundle: SecretBundle = serde_json::from_slice(&s)?;
self.cache.try_write()?.replace(bundle.clone());
Ok(bundle)
}
Err(keyring::Error::NoEntry) => Ok(SecretBundle::default()),
Err(e) => Err(e).context(Code::InvalidSessionKeyring),
}
}
pub fn set(&self, bundle: SecretBundle) -> Result<()> {
let bundled = serde_json::to_vec(&bundle)?;
self.credential.set_secret(&bundled).map_err(Into::into)
}
pub fn delete(&self) -> Result<()> {
self.credential.delete_credential().map_err(Into::into)
}
}

View File

@@ -1,64 +1,90 @@
use anyhow::{Context, Result};
use authorization::{AuthorizationBroker, AuthorizationBrokerEvent, AuthorizationBrokerResponse};
use consts::{AUTH_SERVER, CLIENT_ID};
use oauth2::RefreshToken;
use popcorntime_error::Code;
use session::AppSession;
use std::{path::Path, sync::Arc};
use storage::{InnerSessionStore, SessionStore};
use tokio::sync::RwLock;
use storage::SessionStore;
use tokio::sync::{Mutex, RwLock, broadcast};
pub mod authorization;
pub mod consts;
pub mod jwks;
mod keyring;
mod server;
pub mod session;
pub mod storage;
#[derive(Debug, Clone)]
#[derive(Clone)]
pub struct SessionUpdateEvent {
pub access_token: Option<String>,
pub refresh_token: Option<String>,
pub expires_at: Option<time::OffsetDateTime>,
}
#[derive(Debug)]
pub struct AuthorizationService {
broker: Arc<AuthorizationBroker>,
store: Arc<SessionStore>,
snapshot: Arc<RwLock<AppSession>>,
refresh_gate: Arc<Mutex<()>>,
rx: broadcast::Receiver<SessionUpdateEvent>,
}
impl AuthorizationService {
pub fn new(storage_dir: &Path) -> Result<Self> {
let store = SessionStore::new(storage_dir)?;
pub fn new(storage_dir: &Path, namespace: &str) -> Result<Self> {
let (tx, rx) = broadcast::channel(16);
let store = SessionStore::new(storage_dir, namespace)?.with_broadcast(tx);
let broker = AuthorizationBroker::new(CLIENT_ID, AUTH_SERVER)?;
let mut current_session = AppSession::new(&format!("{}/.well-known/jwks.json", AUTH_SERVER))?;
let current_store = store.get_with_secrets()?;
let current_session = AppSession::new(&format!("{}/.well-known/jwks.json", AUTH_SERVER))?
.with_access_token(current_store.access_token.clone())
.with_refresh_token(current_store.refresh_token.clone())
.with_expires_at(current_store.expires_at);
let current_store = store.get()?;
current_session.with_access_token(current_store.access_token.clone());
current_session.with_refresh_token(current_store.refresh_token.clone());
current_session.with_expires_at(current_store.expires_at);
Ok(Self {
broker: Arc::new(broker),
store: Arc::new(store),
snapshot: Arc::new(RwLock::new(current_session)),
refresh_gate: Arc::new(Mutex::new(())),
rx,
})
}
/// Watch the config file in the background and update the session store
/// - send initial value on-start
/// - send updated value on-write
pub fn watch_config_in_background(
pub fn on_access_token_update(
&self,
send_event: impl Fn(InnerSessionStore) -> Result<()> + Send + Sync + 'static,
send_event: impl Fn(SessionUpdateEvent) -> Result<()> + Send + Sync + 'static,
) -> Result<()> {
// resubscribe to get a fully isolated receiver
let mut rx = self.rx.resubscribe();
let snapshot = self.snapshot.clone();
self.store.watch_in_background(move |session| {
// async update
tokio::spawn(async move {
let snapshot_isolated = snapshot.clone();
let session_isolated = session.clone();
tokio::spawn(async move {
let mut inner = snapshot_isolated.write().await;
inner.with_access_token(session_isolated.access_token.clone());
inner.with_refresh_token(session_isolated.refresh_token.clone());
inner.with_expires_at(session_isolated.expires_at);
});
send_event(session)
})
loop {
match rx.recv().await {
Ok(event) => {
// rebuild the inner session
let mut current_session = snapshot_isolated.write().await;
*current_session = current_session
.clone()
.with_access_token(event.access_token.clone())
.with_refresh_token(event.refresh_token.clone())
.with_expires_at(event.expires_at);
// send the event
if let Err(err) = send_event(event) {
tracing::error!("Failed to send session update event: {:?}", err);
}
}
Err(broadcast::error::RecvError::Lagged(_)) => continue,
Err(broadcast::error::RecvError::Closed) => break,
}
}
});
Ok(())
}
pub async fn authorize_in_background(
@@ -92,49 +118,65 @@ impl AuthorizationService {
)
}
/// Try to get the current access token
/// Locks may fail, so this may return None even if there is an access token
/// We currently only use this at startup to initialize the API client
/// It should be fine as there is no concurrency at that point
pub fn try_access_token(&self) -> Option<String> {
self.snapshot.try_read().ok().and_then(|s| s.access_token())
}
pub fn set_onboarded(&self, onboarded: bool) -> Result<()> {
let inner_settings = self.store.clone();
inner_settings.update_onboarding_complete(onboarded)
}
pub async fn validate(&self) -> Result<()> {
let mut session = self.snapshot.write().await;
let inner_settings = self.store.clone();
match session.validate().await {
Ok(_) => Ok(()),
Err(err) => {
// probably expired token
if err.is::<Code>() {
tracing::info!("Refreshing token");
let AuthorizationBrokerResponse {
access_token,
expires_in,
refresh_token,
} = self
.broker
.exchange_refresh_token(&session)
.await
.context(Code::InvalidSession)?;
// update storage -- a `AppSession` will be updated in the background
if let Err(err) = inner_settings.update_access_token(
access_token.clone(),
Some(refresh_token),
expires_in,
) {
tracing::error!("Failed to update access_token: {:?}", err);
};
// make sure the access token is updated
// we dont want to relay on the watch_in_background to update the session
session.with_access_token(Some(access_token));
return session.validate().await;
}
Err(err)
}
async fn fast_validate(&self) -> Result<()> {
let session = self.snapshot.read().await;
if session.validate().await.is_ok() {
return Ok(());
}
Err(anyhow::anyhow!("No valid access token found").context(Code::InvalidSession))
}
pub async fn validate(&self) -> Result<()> {
// fast path
if self.fast_validate().await.is_ok() {
return Ok(());
}
// prevent multiple validate
let _guard = self.refresh_gate.lock().await;
// if we were running in a lock
// another thread may have refreshed the token
if self.fast_validate().await.is_ok() {
return Ok(());
}
let refresh_input = { self.snapshot.read().await.refresh_token() }
.ok_or_else(|| anyhow::anyhow!("No valid tokens found").context(Code::InvalidSession))?;
let AuthorizationBrokerResponse {
access_token,
expires_in,
refresh_token,
} = self
.broker
.exchange_refresh_token(&RefreshToken::new(refresh_input))
.await
.context(Code::InvalidSession)?;
if let Err(err) = self.store.update_access_token(
access_token.clone(),
Some(refresh_token.clone()),
expires_in,
) {
tracing::error!("Failed to update_access_token: {err:?}");
}
self.fast_validate().await
}
pub async fn logout(&self) -> Result<()> {

View File

@@ -31,25 +31,19 @@ impl AppSession {
self.refresh_token.clone()
}
pub fn with_access_token(&mut self, access_token: Option<String>) {
if access_token == self.access_token {
return;
}
pub fn with_access_token(mut self, access_token: Option<String>) -> Self {
self.access_token = access_token;
self
}
pub fn with_refresh_token(&mut self, refresh_token: Option<String>) {
if refresh_token == self.refresh_token {
return;
}
pub fn with_refresh_token(mut self, refresh_token: Option<String>) -> Self {
self.refresh_token = refresh_token;
self
}
pub fn with_expires_at(&mut self, expires_at: Option<time::OffsetDateTime>) {
if expires_at == self.expires_at {
return;
}
pub fn with_expires_at(mut self, expires_at: Option<time::OffsetDateTime>) -> Self {
self.expires_at = expires_at;
self
}
pub async fn validate(&self) -> Result<()> {

View File

@@ -1,37 +1,39 @@
use crate::{
SessionUpdateEvent,
keyring::{self, SecretBundle},
};
use anyhow::Result;
use config::{Config, File};
use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher, event::ModifyKind};
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use serde::{Deserialize, Serialize};
use std::{
fs,
path::{Path, PathBuf},
sync::{Arc, RwLock, mpsc},
sync::{Arc, RwLock},
time::Duration,
};
use tokio::task::spawn_blocking;
use tokio::sync::broadcast;
const SETTINGS_FILE: &str = "settings.toml";
const SETTINGS_FILE: &str = "session.toml";
#[derive(Debug, Clone)]
#[derive(Debug)]
pub struct SessionStore<S = InnerSessionStore> {
pub path: PathBuf,
pub snapshot: Arc<RwLock<S>>,
vault: keyring::KeyringVault,
tx: Option<broadcast::Sender<SessionUpdateEvent>>,
}
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)]
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone, Default)]
#[serde(rename_all = "camelCase")]
pub struct InnerSessionStore {
#[serde(default)]
pub onboarding_complete: bool,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub oauth_app: Option<OAuthApp>,
#[serde(default)]
pub access_token: Option<String>,
#[serde(default)]
pub refresh_token: Option<String>,
#[serde(default)]
#[serde(with = "time::serde::rfc3339::option")]
pub expires_at: Option<time::OffsetDateTime>,
#[serde(default)]
pub refresh_token: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
@@ -42,31 +44,60 @@ pub struct OAuthApp {
}
impl InnerSessionStore {
fn load(path: &Path) -> Result<Self> {
Config::builder()
.add_source(File::from(path).required(false))
.build()?
.try_deserialize()
.map_err(Into::into)
fn load_without_secrets(path: &Path) -> Result<Self> {
let file_str = fs::read(path);
match file_str {
Ok(s) => {
toml::from_slice(&s).map_err(|err| anyhow::anyhow!("Failed to parse settings: {:?}", err))
}
Err(err) => {
if err.kind() == std::io::ErrorKind::NotFound {
Ok(Default::default())
} else {
Err(anyhow::anyhow!("Failed to read settings: {:?}", err))
}
}
}
}
}
impl SessionStore<InnerSessionStore> {
pub fn new(config_dir: &Path) -> Result<Self> {
pub fn new(config_dir: &Path, namespace: &str) -> Result<Self> {
let path = config_dir.join(SETTINGS_FILE);
let inner = InnerSessionStore::load(&path)?;
let mut inner = InnerSessionStore::load_without_secrets(&path)?;
let vault = keyring::KeyringVault::new(namespace)?;
let SecretBundle {
access_token,
refresh_token,
} = vault.get()?;
inner.access_token = access_token;
inner.refresh_token = refresh_token;
let snapshot = Arc::new(RwLock::new(inner));
Ok(Self { path, snapshot })
Ok(Self {
path,
snapshot,
vault,
tx: None,
})
}
pub fn get(&self) -> Result<InnerSessionStore> {
pub fn with_broadcast(mut self, tx: broadcast::Sender<SessionUpdateEvent>) -> Self {
self.tx = Some(tx);
self
}
pub fn get_with_secrets(&self) -> Result<InnerSessionStore> {
let snapshot = self
.snapshot
.read()
.map_err(|_| anyhow::anyhow!("Failed to get settings"))?;
Ok(snapshot.clone())
}
// FIXME: remove
pub fn update_onboarding_complete(&self, update: bool) -> Result<()> {
match self.snapshot.write() {
Ok(mut settings) => {
@@ -77,16 +108,23 @@ impl SessionStore<InnerSessionStore> {
return Err(anyhow::anyhow!("Failed to update onboarding_complete"));
}
}
self.save()?;
self.save_and_signal()?;
Ok(())
}
pub fn update_access_token(
pub(crate) fn update_access_token(
&self,
access_token: String,
refresh_token: Option<String>,
expires_in: Option<Duration>,
) -> Result<()> {
self.vault.set(SecretBundle {
access_token: Some(access_token.clone()),
refresh_token: refresh_token.clone(),
})?;
tracing::info!("Updating access_token in memory");
match self.snapshot.write() {
Ok(mut settings) => {
settings.access_token = Some(access_token);
@@ -104,11 +142,13 @@ impl SessionStore<InnerSessionStore> {
return Err(anyhow::anyhow!("Failed to update access_token"));
}
}
self.save()?;
self.save_and_signal()?;
Ok(())
}
pub fn delete_access_token(&self) -> Result<()> {
self.vault.delete()?;
match self.snapshot.write() {
Ok(mut settings) => {
settings.access_token = None;
@@ -120,82 +160,42 @@ impl SessionStore<InnerSessionStore> {
return Err(anyhow::anyhow!("Failed to delete access_token"));
}
}
self.save()?;
Ok(())
}
pub fn watch_in_background(
&self,
send_event: impl Fn(InnerSessionStore) -> Result<()> + Send + Sync + 'static,
) -> Result<()> {
let (tx, rx) = mpsc::channel();
let config_path = self.path.clone();
let watcher_config = notify::Config::default()
.with_compare_contents(true)
.with_poll_interval(Duration::from_secs(2));
// make sure file exist
if !config_path.exists() {
std::fs::write(&config_path, "")
.map_err(|_| anyhow::anyhow!("unable to write settings file"))?;
}
// send initial settings
if let Ok(update) = InnerSessionStore::load(&config_path) {
tracing::info!("settings.json initialized");
send_event(update)?;
}
let snapshot = self.snapshot.clone();
spawn_blocking(move || -> Result<()> {
let mut watcher: RecommendedWatcher = Watcher::new(tx, watcher_config)?;
watcher.watch(&config_path, RecursiveMode::NonRecursive)?;
loop {
match rx.recv() {
Ok(Ok(Event {
// windows throw `Any`
kind: EventKind::Modify(ModifyKind::Any) | EventKind::Modify(ModifyKind::Data(_)),
..
})) => {
let Ok(mut last_seen_settings) = snapshot.write() else {
continue;
};
if let Ok(update) = InnerSessionStore::load(&config_path) {
tracing::info!("settings.json modified; refreshing settings");
*last_seen_settings = update.clone();
send_event(update)?;
}
}
Err(_) => {
tracing::error!(
"Error watching config file {:?} - watcher terminated",
config_path
);
break;
}
_ => {
// Noop
}
}
}
Ok(())
});
self.save_and_signal()?;
Ok(())
}
}
impl<S: Clone + Serialize + DeserializeOwned> SessionStore<S> {
pub fn save(&self) -> Result<()> {
impl SessionStore<InnerSessionStore> {
pub fn save_and_signal(&self) -> Result<()> {
match self.snapshot.read() {
Ok(settings) => {
tracing::info!("Saving settings to {:?}", self.path);
let toml = toml::to_string(&settings.clone()).unwrap();
std::fs::write(&self.path, toml).map_err(Into::into)
// make sure we don't write secrets to disk
let mut tmp = settings.clone();
tmp.access_token = None;
tmp.refresh_token = None;
let toml = toml::to_string(&tmp)?;
std::fs::write(&self.path, toml)?;
// signal update
if let Some(tx) = &self.tx {
tracing::info!("Signaling settings update");
if tx
.send(SessionUpdateEvent {
access_token: settings.access_token.clone(),
refresh_token: settings.refresh_token.clone(),
expires_at: settings.expires_at,
})
.is_err()
{
// we don't attach error to prevent leaking any access token
tracing::error!("Failed to send update signal");
}
}
Ok(())
}
Err(err) => {
tracing::error!("Failed to save settings: {:?}", err);

View File

@@ -2,7 +2,7 @@
use anyhow::Context;
use popcorntime_error::Code;
use popcorntime_graphql_client::client::ApiClient;
use popcorntime_session::AuthorizationService;
use popcorntime_session::{AuthorizationService, SessionUpdateEvent};
use popcorntime_tauri::event::{SessionServerReady, SessionUpdate};
#[cfg(debug_assertions)]
use specta_typescript::Typescript;
@@ -88,28 +88,25 @@ fn main() {
tracing::info!(version = %app_handle.package_info().version,
name = %app_handle.package_info().name, "starting app");
let auth_service = AuthorizationService::new(&config_dir)?;
let auth_service =
AuthorizationService::new(&config_dir, app_handle.config().identifier.as_str())?;
app_handle.manage(ApiClient::new(auth_service.try_access_token())?);
// initialize default API client
app_handle.manage(ApiClient::new(None)?);
// watch config in background
auth_service.watch_config_in_background({
let app_handle = app_handle.clone();
move |app_settings| {
let api_client = app_handle.state::<ApiClient>();
match api_client.update_access_token(app_settings.access_token.clone()) {
Ok(_) => {
tracing::debug!("[ApiClient] Access token updated");
}
Err(err) => {
tracing::error!("[ApiClient] Failed to update access token: {:?}", err);
}
let app_handle_isolated = app_handle.clone();
auth_service.on_access_token_update(
move |SessionUpdateEvent { access_token, .. }| {
// update api client access token
let api_client = app_handle_isolated.state::<ApiClient>();
if let Err(err) = api_client.update_access_token(access_token) {
tracing::error!("Failed to update api client access_token: {:?}", err);
}
// send frontend event
SessionUpdate.emit(&app_handle).context(Code::InvalidEvent)
}
})?;
// signal frontend
SessionUpdate
.emit(&app_handle_isolated)
.context(Code::InvalidEvent)
},
)?;
let app_handle_isolated = app_handle.clone();
app.deep_link().on_open_url(move |event| {