From 46e449413dad5dc03add69157c60281623577ed0 Mon Sep 17 00:00:00 2001 From: Dustin Wyatt Date: Sat, 25 Jul 2026 19:07:13 -0500 Subject: [PATCH 01/14] Extract PKCE challenge generation into a shared module The Granola login flow needs the same RFC 7636 primitive the Dropbox flow already has, so move it out of the Dropbox-specific OAuth module rather than duplicating it. Verifier entropy becomes a parameter: Dropbox keeps its 64 bytes (86 characters) via PKCE_ENTROPY_BYTES, and Granola will ask for 32 (43 characters) to match what its desktop client sends. Claude-Session: https://claude.ai/code/session_012c8WU6fhQDaVekXJX5hGhH --- src/commands/sync.rs | 7 ++-- src/main.rs | 1 + src/pkce.rs | 77 ++++++++++++++++++++++++++++++++++++++++++++ src/sync/oauth.rs | 61 ++--------------------------------- 4 files changed, 85 insertions(+), 61 deletions(-) create mode 100644 src/pkce.rs diff --git a/src/commands/sync.rs b/src/commands/sync.rs index 8efe750..572f619 100644 --- a/src/commands/sync.rs +++ b/src/commands/sync.rs @@ -14,7 +14,10 @@ use crate::output::format::OutputMode; use crate::sync::config::{config_path, SyncConfig}; use crate::sync::dropbox::{format_timestamp, parse_dropbox_time, DropboxClient, FileMetadata}; use crate::sync::metadata::SyncMetadata; -use crate::sync::oauth::{build_auth_url, exchange_code, refresh_access_token, PkceChallenge}; +use crate::pkce::PkceChallenge; +use crate::sync::oauth::{ + build_auth_url, exchange_code, refresh_access_token, PKCE_ENTROPY_BYTES, +}; use crate::sync::SyncError; /// Remote paths on Dropbox (within app folder) @@ -51,7 +54,7 @@ fn init() -> Result<()> { } // Generate PKCE challenge - let pkce = PkceChallenge::generate(); + let pkce = PkceChallenge::generate(PKCE_ENTROPY_BYTES); let auth_url = build_auth_url(&pkce.challenge); println!("\nOpening browser for Dropbox authorization..."); diff --git a/src/main.rs b/src/main.rs index 7ed0ad9..9d3ac7f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ mod db; mod embed; mod models; mod output; +mod pkce; mod platform; mod query; mod sync; diff --git a/src/pkce.rs b/src/pkce.rs new file mode 100644 index 0000000..9e8da59 --- /dev/null +++ b/src/pkce.rs @@ -0,0 +1,77 @@ +//! PKCE challenge generation (RFC 7636), shared by the OAuth flows. + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use rand::{rngs::OsRng, RngCore}; +use sha2::{Digest, Sha256}; + +/// PKCE verifier/challenge pair for an OAuth flow. +#[derive(Debug)] +pub struct PkceChallenge { + /// Random verifier string (sent during token exchange) + pub verifier: String, + /// SHA256 hash of the verifier (sent during authorization) + pub challenge: String, +} + +impl PkceChallenge { + /// Generate a pair from `entropy_bytes` of OS randomness. + /// + /// RFC 7636 requires a 43-128 character verifier. Base64url encoding + /// produces 4 characters per 3 bytes with no padding, so 32 bytes yields + /// the 43-character minimum and 64 bytes yields 86. + pub fn generate(entropy_bytes: usize) -> Self { + let mut bytes = vec![0u8; entropy_bytes]; + OsRng.fill_bytes(&mut bytes); + + let verifier = URL_SAFE_NO_PAD.encode(&bytes); + + let mut hasher = Sha256::new(); + hasher.update(verifier.as_bytes()); + let challenge = URL_SAFE_NO_PAD.encode(hasher.finalize()); + + Self { verifier, challenge } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_verifier_length_from_entropy() { + // 64 bytes -> 86 chars (Dropbox), 32 bytes -> 43 chars (Granola) + assert_eq!(PkceChallenge::generate(64).verifier.len(), 86); + assert_eq!(PkceChallenge::generate(32).verifier.len(), 43); + } + + #[test] + fn test_verifier_is_unreserved_charset() { + // RFC 7636 restricts the verifier to [A-Za-z0-9-._~] + let verifier = PkceChallenge::generate(32).verifier; + assert!(verifier + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '_' | '~'))); + } + + #[test] + fn test_challenge_is_s256_of_verifier() { + let pkce = PkceChallenge::generate(64); + + // Challenge is base64url of a SHA256 hash = 43 chars + assert_eq!(pkce.challenge.len(), 43); + + let mut hasher = Sha256::new(); + hasher.update(pkce.verifier.as_bytes()); + let expected = URL_SAFE_NO_PAD.encode(hasher.finalize()); + assert_eq!(pkce.challenge, expected); + } + + #[test] + fn test_pkce_uniqueness() { + let first = PkceChallenge::generate(64); + let second = PkceChallenge::generate(64); + + assert_ne!(first.verifier, second.verifier); + assert_ne!(first.challenge, second.challenge); + } +} diff --git a/src/sync/oauth.rs b/src/sync/oauth.rs index b5d69f8..cd7c0ca 100644 --- a/src/sync/oauth.rs +++ b/src/sync/oauth.rs @@ -2,10 +2,7 @@ //! //! Implements RFC 7636 (PKCE) for secure authorization without embedding secrets. -use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; -use rand::{rngs::OsRng, RngCore}; use serde::Deserialize; -use sha2::{Digest, Sha256}; use super::{SyncError, SyncResult}; @@ -16,34 +13,8 @@ const CLIENT_ID: &str = "bqkd8myq8v5w7xu"; const AUTHORIZE_URL: &str = "https://www.dropbox.com/oauth2/authorize"; const TOKEN_URL: &str = "https://api.dropboxapi.com/oauth2/token"; -/// PKCE verifier/challenge pair for OAuth flow. -#[derive(Debug)] -pub struct PkceChallenge { - /// Random verifier string (sent during token exchange) - pub verifier: String, - /// SHA256 hash of verifier (sent during authorization) - pub challenge: String, -} - -impl PkceChallenge { - /// Generate a new PKCE challenge pair. - pub fn generate() -> Self { - // Generate 64 random bytes - let mut bytes = [0u8; 64]; - OsRng.fill_bytes(&mut bytes); - - // Base64url encode for verifier - let verifier = URL_SAFE_NO_PAD.encode(bytes); - - // SHA256 hash then base64url encode for challenge - let mut hasher = Sha256::new(); - hasher.update(verifier.as_bytes()); - let hash = hasher.finalize(); - let challenge = URL_SAFE_NO_PAD.encode(hash); - - Self { verifier, challenge } - } -} +/// Entropy for the PKCE verifier, in bytes. +pub const PKCE_ENTROPY_BYTES: usize = 64; /// Build the authorization URL for the user to visit. pub fn build_auth_url(challenge: &str) -> String { @@ -144,34 +115,6 @@ pub fn refresh_access_token(refresh_token: &str) -> SyncResult { mod tests { use super::*; - #[test] - fn test_pkce_challenge_generation() { - let pkce = PkceChallenge::generate(); - - // Verifier should be base64url encoded 64 bytes = 86 chars - assert_eq!(pkce.verifier.len(), 86); - - // Challenge should be base64url encoded SHA256 hash = 43 chars - assert_eq!(pkce.challenge.len(), 43); - - // Challenge should be deterministic from verifier - let mut hasher = Sha256::new(); - hasher.update(pkce.verifier.as_bytes()); - let hash = hasher.finalize(); - let expected_challenge = URL_SAFE_NO_PAD.encode(hash); - assert_eq!(pkce.challenge, expected_challenge); - } - - #[test] - fn test_pkce_uniqueness() { - let pkce1 = PkceChallenge::generate(); - let pkce2 = PkceChallenge::generate(); - - // Each generation should produce unique values - assert_ne!(pkce1.verifier, pkce2.verifier); - assert_ne!(pkce1.challenge, pkce2.challenge); - } - #[test] fn test_auth_url_format() { let challenge = "test_challenge_123"; From be13a3922692e97134539e4bc92b1f2eaf70a348 Mon Sep 17 00:00:00 2001 From: Dustin Wyatt Date: Sat, 25 Jul 2026 19:09:14 -0500 Subject: [PATCH 02/14] #89: Add a credential store for grans's own Granola session Granola moved its data-encryption key into the macOS data-protection keychain, gated on its own code signature, so grans can no longer read the token the desktop app stored. It needs to hold its own. Stores a refresh token, a cached access token and its expiry as TOML at data_dir()/auth.toml, written via temp file and rename so an interrupted write cannot truncate the file, and chmod 0600 on unix. load/save take an explicit path with thin wrappers for the default location, so the round-trip is testable without touching the real data directory. valid_access_token takes `now` as a parameter for the same reason, and applies a 60 second skew so a command does not start with a token that expires mid-run. Claude-Session: https://claude.ai/code/session_012c8WU6fhQDaVekXJX5hGhH --- src/api/credentials.rs | 294 +++++++++++++++++++++++++++++++++++++++++ src/api/mod.rs | 1 + 2 files changed, 295 insertions(+) create mode 100644 src/api/credentials.rs diff --git a/src/api/credentials.rs b/src/api/credentials.rs new file mode 100644 index 0000000..af3f0ef --- /dev/null +++ b/src/api/credentials.rs @@ -0,0 +1,294 @@ +//! Storage for grans's own Granola credentials. +//! +//! Granola's desktop app keeps its data-encryption key in the macOS +//! data-protection keychain, gated on its own code signature, so grans cannot +//! read the app's stored session there. Instead grans holds its own refresh +//! token, obtained through the same PKCE login the desktop client uses. +//! +//! Persisted as TOML at `data_dir()/auth.toml`. + +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +use crate::platform::data_dir; + +/// Treat an access token expiring within this window as already expired, so a +/// long-running command does not start with a token that dies mid-flight. +const EXPIRY_SKEW_SECS: i64 = 60; + +/// grans's own Granola session. +/// +/// The refresh token is the durable credential and is always present; the +/// access token is a cache that may be absent or stale. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct GranolaCredentials { + /// Long-lived refresh token. Granola rotates this on every refresh. + pub refresh_token: String, + + /// Most recently issued access token, if one has been fetched. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub access_token: Option, + + /// Unix timestamp (seconds) at which `access_token` expires. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expires_at: Option, + + /// WorkOS session identifier, for `grans auth status`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_id: Option, +} + +impl GranolaCredentials { + /// Create credentials holding only a refresh token. + pub fn from_refresh_token(refresh_token: String) -> Self { + Self { + refresh_token, + access_token: None, + expires_at: None, + session_id: None, + } + } + + /// Return the access token if it is present and not near expiry. + /// + /// `now` is a Unix timestamp in seconds. A token with no recorded expiry + /// is treated as unusable, since we cannot tell a live one from a dead one. + pub fn valid_access_token(&self, now: i64) -> Option<&str> { + let expires_at = self.expires_at?; + if expires_at - EXPIRY_SKEW_SECS <= now { + return None; + } + self.access_token.as_deref() + } + + /// Load credentials from `path`, or `None` if the file does not exist. + pub fn load_from(path: &Path) -> Result> { + let content = match fs::read_to_string(path) { + Ok(content) => content, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => { + return Err(e).with_context(|| format!("Failed to read {}", path.display())) + } + }; + + toml::from_str(&content) + .map(Some) + .with_context(|| format!("Failed to parse credentials at {}", path.display())) + } + + /// Write credentials to `path`, replacing any existing file atomically. + pub fn save_to(&self, path: &Path) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create {}", parent.display()))?; + } + + let content = toml::to_string_pretty(self).context("Failed to serialize credentials")?; + + // Write to a temp file, tighten permissions, then rename into place so + // a crash mid-write cannot leave a truncated credential file behind. + let temp_path = path.with_extension("toml.tmp"); + fs::write(&temp_path, &content) + .with_context(|| format!("Failed to write {}", temp_path.display()))?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&temp_path, fs::Permissions::from_mode(0o600)) + .with_context(|| format!("Failed to set permissions on {}", temp_path.display()))?; + } + + fs::rename(&temp_path, path) + .with_context(|| format!("Failed to replace {}", path.display())) + } + + /// Load credentials from the default location. + pub fn load() -> Result> { + Self::load_from(&credentials_path()?) + } + + /// Save credentials to the default location. + pub fn save(&self) -> Result<()> { + self.save_to(&credentials_path()?) + } +} + +/// Remove the credential file at `path`. Succeeds if it is already gone. +pub fn delete_from(path: &Path) -> Result<()> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e).with_context(|| format!("Failed to remove {}", path.display())), + } +} + +/// Remove the credential file at the default location. +pub fn delete() -> Result<()> { + delete_from(&credentials_path()?) +} + +/// Path to grans's credential file. +pub fn credentials_path() -> Result { + Ok(data_dir()?.join("auth.toml")) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + const NOW: i64 = 1_800_000_000; + + fn creds() -> GranolaCredentials { + GranolaCredentials { + refresh_token: "refresh-abc".to_string(), + access_token: Some("access-xyz".to_string()), + expires_at: Some(NOW + 3600), + session_id: Some("session_01ABC".to_string()), + } + } + + #[test] + fn test_load_from_missing_file_is_none() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("auth.toml"); + + assert_eq!(GranolaCredentials::load_from(&path).unwrap(), None); + } + + #[test] + fn test_save_load_roundtrip() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("auth.toml"); + + creds().save_to(&path).unwrap(); + + assert_eq!(GranolaCredentials::load_from(&path).unwrap(), Some(creds())); + } + + #[test] + fn test_save_creates_missing_parent_directory() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("nested").join("deeper").join("auth.toml"); + + creds().save_to(&path).unwrap(); + + assert!(path.exists()); + } + + #[test] + fn test_save_replaces_existing_and_leaves_no_temp_file() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("auth.toml"); + + creds().save_to(&path).unwrap(); + let rotated = GranolaCredentials::from_refresh_token("refresh-rotated".to_string()); + rotated.save_to(&path).unwrap(); + + assert_eq!( + GranolaCredentials::load_from(&path).unwrap(), + Some(rotated) + ); + assert!(!path.with_extension("toml.tmp").exists()); + } + + #[test] + fn test_refresh_token_only_roundtrip() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("auth.toml"); + let bare = GranolaCredentials::from_refresh_token("refresh-only".to_string()); + + bare.save_to(&path).unwrap(); + + assert_eq!(GranolaCredentials::load_from(&path).unwrap(), Some(bare)); + } + + #[test] + fn test_load_from_malformed_file_errors() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("auth.toml"); + fs::write(&path, "this is not toml {{{").unwrap(); + + assert!(GranolaCredentials::load_from(&path).is_err()); + } + + #[test] + fn test_valid_access_token_when_fresh() { + assert_eq!(creds().valid_access_token(NOW), Some("access-xyz")); + } + + #[test] + fn test_valid_access_token_none_when_expired() { + let expired = GranolaCredentials { + expires_at: Some(NOW - 1), + ..creds() + }; + + assert_eq!(expired.valid_access_token(NOW), None); + } + + #[test] + fn test_valid_access_token_none_inside_skew_window() { + // Expires in 30s, which is inside the 60s skew margin. + let expiring = GranolaCredentials { + expires_at: Some(NOW + 30), + ..creds() + }; + + assert_eq!(expiring.valid_access_token(NOW), None); + } + + #[test] + fn test_valid_access_token_none_without_expiry() { + let no_expiry = GranolaCredentials { + expires_at: None, + ..creds() + }; + + assert_eq!(no_expiry.valid_access_token(NOW), None); + } + + #[test] + fn test_valid_access_token_none_without_token() { + let no_token = GranolaCredentials { + access_token: None, + ..creds() + }; + + assert_eq!(no_token.valid_access_token(NOW), None); + } + + #[test] + fn test_delete_removes_file() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("auth.toml"); + creds().save_to(&path).unwrap(); + + delete_from(&path).unwrap(); + + assert!(!path.exists()); + } + + #[test] + fn test_delete_missing_file_succeeds() { + let dir = TempDir::new().unwrap(); + + assert!(delete_from(&dir.path().join("auth.toml")).is_ok()); + } + + #[cfg(unix)] + #[test] + fn test_saved_file_is_owner_only() { + use std::os::unix::fs::PermissionsExt; + + let dir = TempDir::new().unwrap(); + let path = dir.path().join("auth.toml"); + creds().save_to(&path).unwrap(); + + let mode = fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600); + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs index f5ba308..37a6f72 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,5 +1,6 @@ mod auth; pub mod client; +pub mod credentials; mod token_store; pub mod types; From b6aa88876b04dff35a509f6939c79d2452a348dc Mon Sep 17 00:00:00 2001 From: Dustin Wyatt Date: Sat, 25 Jul 2026 19:10:52 -0500 Subject: [PATCH 03/14] #89: Centralize the Granola client identity behind an override The client version was hardcoded in two places, and the login flow needs a third. Granola's /v1/auth endpoint redirects to an upgrade page when the version is missing or too old, so this value will go stale and needs a workaround that does not require a grans release. Adds api::identity with the version (overridable via GRANS_GRANOLA_VERSION) and the platform name, and points the API client at it. The reported version moves 6.518.0 -> 7.441.6, matching a build observed working against the live auth endpoint; verified the data endpoints still work by syncing. Version precedence is resolved by a pure function so it is testable without mutating the environment, which is unsafe in edition 2024. Claude-Session: https://claude.ai/code/session_012c8WU6fhQDaVekXJX5hGhH --- src/api/client.rs | 12 ++++--- src/api/identity.rs | 80 +++++++++++++++++++++++++++++++++++++++++++++ src/api/mod.rs | 1 + 3 files changed, 89 insertions(+), 4 deletions(-) create mode 100644 src/api/identity.rs diff --git a/src/api/client.rs b/src/api/client.rs index e56d086..2445c11 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -19,7 +19,6 @@ use crate::models::{ const API_V1_URL: &str = "https://api.granola.ai/v1"; const API_V2_URL: &str = "https://api.granola.ai/v2"; const DEFAULT_TIMEOUT_SECS: u64 = 30; -const CLIENT_VERSION: &str = "6.518.0"; /// Safely slice a string at UTF-8 character boundaries. /// Returns a substring from `start` to `end` byte positions, adjusted to valid char boundaries. @@ -61,6 +60,7 @@ pub enum ApiError { pub struct ApiClient { token: String, + client_version: String, client: reqwest::blocking::Client, } @@ -71,7 +71,11 @@ impl ApiClient { .build() .map_err(|e| anyhow::anyhow!("Failed to create HTTP client: {}", e))?; - Ok(Self { token, client }) + Ok(Self { + token, + client_version: super::identity::client_version(), + client, + }) } /// Internal helper to make a POST request to the v1 API @@ -110,7 +114,7 @@ impl ApiClient { .post(url) .header("Authorization", format!("Bearer {}", self.token)) .header("Content-Type", "application/json") - .header("X-Client-Version", CLIENT_VERSION) + .header("X-Client-Version", &self.client_version) .json(body) .send() .map_err(|e| { @@ -211,7 +215,7 @@ impl ApiClient { .post(&url) .header("Authorization", format!("Bearer {}", self.token)) .header("Content-Type", "application/json") - .header("X-Client-Version", CLIENT_VERSION) + .header("X-Client-Version", &self.client_version) .json(&request_body) .send() .map_err(|e| { diff --git a/src/api/identity.rs b/src/api/identity.rs new file mode 100644 index 0000000..81bd726 --- /dev/null +++ b/src/api/identity.rs @@ -0,0 +1,80 @@ +//! The Granola desktop client identity grans presents to the Granola API. +//! +//! grans talks to Granola's internal API, which expects the desktop client's +//! version and platform. The `/v1/auth` endpoint is strict about it: omitting +//! the version, or sending one Granola considers too old, redirects to an +//! upgrade page instead of starting a login. + +use std::env; + +/// Environment variable that overrides the reported client version. +pub const VERSION_ENV_VAR: &str = "GRANS_GRANOLA_VERSION"; + +/// Version reported when nothing overrides it. +/// +/// This will eventually go stale as the desktop app moves on. When it does, +/// `VERSION_ENV_VAR` is the workaround that does not need a grans release. +const DEFAULT_VERSION: &str = "7.441.6"; + +/// Granola client version to report. +pub fn client_version() -> String { + resolve_version(env::var(VERSION_ENV_VAR).ok()) +} + +/// Choose between an override and the built-in default. +/// +/// Split from [`client_version`] so the precedence is testable without +/// mutating the environment, which is `unsafe` in edition 2024. +fn resolve_version(override_value: Option) -> String { + match override_value { + Some(value) if !value.trim().is_empty() => value.trim().to_string(), + _ => DEFAULT_VERSION.to_string(), + } +} + +/// Platform name to report. +/// +/// `macOS` is what Granola's own client sends and is confirmed against the +/// live endpoint; the other two follow the same capitalized-product-name +/// convention but have not been observed on the wire. +pub fn platform() -> &'static str { + if cfg!(target_os = "macos") { + "macOS" + } else if cfg!(target_os = "windows") { + "Windows" + } else { + "Linux" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_version_defaults_when_unset() { + assert_eq!(resolve_version(None), DEFAULT_VERSION); + } + + #[test] + fn test_version_override_wins() { + assert_eq!(resolve_version(Some("9.1.2".to_string())), "9.1.2"); + } + + #[test] + fn test_blank_override_falls_back_to_default() { + // An exported-but-empty variable should not send an empty version, + // which the auth endpoint treats as missing. + assert_eq!(resolve_version(Some(" ".to_string())), DEFAULT_VERSION); + } + + #[test] + fn test_override_is_trimmed() { + assert_eq!(resolve_version(Some(" 9.1.2\n".to_string())), "9.1.2"); + } + + #[test] + fn test_platform_is_reported_for_this_target() { + assert!(matches!(platform(), "macOS" | "Windows" | "Linux")); + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs index 37a6f72..910968a 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,6 +1,7 @@ mod auth; pub mod client; pub mod credentials; +pub mod identity; mod token_store; pub mod types; From 32cc53e0a3327362fda78d54355af5e36176435e Mon Sep 17 00:00:00 2001 From: Dustin Wyatt Date: Sat, 25 Jul 2026 19:13:08 -0500 Subject: [PATCH 04/14] #89: Add Granola's PKCE login and token refresh Replicates the desktop client's OAuth flow: build the /v1/auth URL, exchange the authorization code at workos-auth-complete (unauthenticated, which is what makes bootstrapping possible), and refresh against refresh-access-token. The callback lands on granola://login-complete, which both macOS and Windows route to Granola.app, and /v1/auth returns 403 for a loopback redirect parameter, so the code comes back by paste. parse_callback_code takes the whole callback URL or a bare code, and rejects a pasted WorkOS URL with an explanation rather than a failed exchange later. A preflight request with redirects disabled catches the one failure that is otherwise invisible: a stale client version redirects to Granola's upgrade page, which in a browser just looks like marketing. That case names GRANS_GRANOLA_VERSION as the fix. Response parsing tolerates tokens nested under workos_tokens, that key holding JSON-encoded text, or the fields at the top level, and refuses a response with no refresh token rather than storing a chain that cannot be continued. Claude-Session: https://claude.ai/code/session_012c8WU6fhQDaVekXJX5hGhH --- Cargo.lock | 32 ++- Cargo.toml | 2 + src/api/granola_auth.rs | 424 ++++++++++++++++++++++++++++++++++++++++ src/api/mod.rs | 1 + 4 files changed, 458 insertions(+), 1 deletion(-) create mode 100644 src/api/granola_auth.rs diff --git a/Cargo.lock b/Cargo.lock index 26ed478..173b5c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -859,11 +859,22 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + [[package]] name = "ghash" version = "0.5.1" @@ -910,6 +921,8 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "toml", + "url", + "uuid", "windows-sys 0.59.0", ] @@ -1797,6 +1810,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.8.5" @@ -2769,6 +2788,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "vcpkg" version = "0.2.15" diff --git a/Cargo.toml b/Cargo.toml index 3d570e6..5c865fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,8 @@ aes = { version = "0.8", default-features = false } sha1 = { version = "0.10", default-features = false } pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"] } cbc = "0.1" +url = "2.5.8" +uuid = { version = "1.24.0", features = ["v4"] } [dev-dependencies] assert_cmd = "2.1.2" diff --git a/src/api/granola_auth.rs b/src/api/granola_auth.rs new file mode 100644 index 0000000..fdc9111 --- /dev/null +++ b/src/api/granola_auth.rs @@ -0,0 +1,424 @@ +//! Granola's PKCE login and token refresh. +//! +//! Replicates the desktop client's own OAuth flow so grans can hold its own +//! session rather than scavenging the one Granola stored locally. See +//! [`crate::api::credentials`] for why that scavenging no longer works. +//! +//! The callback lands on `granola://login-complete?code=...`, which macOS and +//! Windows both route to Granola.app rather than to grans, so the code is +//! pasted back by hand instead of caught on a loopback listener. Granola's +//! `/v1/auth` rejects a loopback `redirect` parameter with a 403. + +use std::time::Duration; + +use anyhow::{anyhow, bail, Context, Result}; +use log::debug; +use serde::Deserialize; +use url::Url; + +use super::credentials::GranolaCredentials; +use super::identity; + +const AUTH_URL: &str = "https://api.granola.ai/v1/auth"; +const AUTH_COMPLETE_URL: &str = "https://api.granola.ai/v1/workos-auth-complete"; +const REFRESH_URL: &str = "https://api.granola.ai/v1/refresh-access-token"; + +/// Scheme Granola registers for its login callback. +const CALLBACK_SCHEME: &str = "granola"; + +/// Entropy for the PKCE verifier: 32 bytes is the 43-character verifier +/// Granola's own client sends. +pub const PKCE_ENTROPY_BYTES: usize = 32; + +const DEFAULT_TIMEOUT_SECS: u64 = 30; + +/// Identity provider to sign in with. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Provider { + Google, + Microsoft, +} + +impl Provider { + fn as_str(self) -> &'static str { + match self { + Provider::Google => "google", + Provider::Microsoft => "microsoft", + } + } +} + +/// Tokens as Granola's auth endpoints return them. +#[derive(Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct TokenSet { + pub access_token: String, + pub refresh_token: String, + /// Access token lifetime in seconds. + #[serde(default)] + pub expires_in: Option, + #[serde(default)] + pub session_id: Option, +} + +impl TokenSet { + /// Convert into storable credentials, dating the expiry from `now`. + /// + /// `previous_session_id` carries forward the session recorded at login + /// when a refresh response omits it. + pub fn into_credentials( + self, + now: i64, + previous_session_id: Option, + ) -> GranolaCredentials { + GranolaCredentials { + expires_at: self.expires_in.map(|lifetime| now + lifetime), + session_id: self.session_id.or(previous_session_id), + access_token: Some(self.access_token), + refresh_token: self.refresh_token, + } + } +} + +/// Build the URL that starts the login, and the click id that must accompany +/// the later code exchange. +pub fn build_auth_url(challenge: &str, provider: Provider) -> (String, String) { + let sign_in_click_id = uuid::Uuid::new_v4().to_string(); + + let mut url = Url::parse(AUTH_URL).expect("auth URL is a valid constant"); + url.query_pairs_mut() + .append_pair("dev", "false") + .append_pair("code_challenge", challenge) + .append_pair("platform", identity::platform()) + .append_pair("version", &identity::client_version()) + .append_pair("sign_in_click_id", &sign_in_click_id) + .append_pair("intent", "download") + .append_pair("provider", provider.as_str()); + + (url.into(), sign_in_click_id) +} + +/// Extract the authorization code from what the user pasted back. +/// +/// Accepts the whole `granola://login-complete?code=...` callback URL, or a +/// bare code for anyone who copied only that. +pub fn parse_callback_code(pasted: &str) -> Result { + let pasted = pasted.trim(); + if pasted.is_empty() { + bail!("No authorization code provided"); + } + + if !pasted.contains("://") { + if pasted.split_whitespace().count() > 1 { + bail!("Expected a single authorization code or a callback URL, got text with spaces"); + } + return Ok(pasted.to_string()); + } + + let url = Url::parse(pasted).context("Could not parse the pasted callback URL")?; + if url.scheme() != CALLBACK_SCHEME { + bail!( + "Expected a {}:// callback URL, got a {}:// URL", + CALLBACK_SCHEME, + url.scheme() + ); + } + + url.query_pairs() + .find(|(key, _)| key == "code") + .map(|(_, value)| value.into_owned()) + .filter(|code| !code.is_empty()) + .ok_or_else(|| anyhow!("The callback URL has no 'code' parameter")) +} + +/// Check that Granola will accept our reported client version before sending +/// the user to a browser. +/// +/// Granola redirects `/v1/auth` to its upgrade page when the version is stale, +/// which in a browser looks like a marketing page rather than an error. Only +/// that one recognized failure is fatal here; anything else is left to the +/// real flow so a server-side change does not block login. +pub fn check_client_version_accepted(auth_url: &str) -> Result<()> { + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS)) + .redirect(reqwest::redirect::Policy::none()) + .build() + .context("Failed to create HTTP client")?; + + let response = client + .get(auth_url) + .send() + .context("Failed to reach Granola's auth endpoint")?; + + let location = response + .headers() + .get(reqwest::header::LOCATION) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_string(); + debug!("auth preflight: {} -> {}", response.status(), location); + + if location.contains("upgrade-app") { + bail!( + "Granola rejected client version {} as out of date.\n\ + Set {}= and try again.", + identity::client_version(), + identity::VERSION_ENV_VAR + ); + } + + Ok(()) +} + +/// Exchange an authorization code for tokens. +/// +/// Unauthenticated, which is what makes bootstrapping a session possible. +pub fn exchange_code(code: &str, verifier: &str, sign_in_click_id: &str) -> Result { + let body = serde_json::json!({ + "code": code, + "codeVerifier": verifier, + "platform": identity::platform(), + "isDev": false, + "signInClickId": sign_in_click_id, + }); + + let response = post_json(AUTH_COMPLETE_URL, &body, None) + .context("Failed to exchange the authorization code")?; + + extract_token_set(&response).context( + "Granola's response to the code exchange did not contain the expected tokens", + ) +} + +/// Exchange a refresh token for a new token set. +/// +/// Granola rotates the refresh token on every call, so the response must be +/// persisted or the chain breaks. The access token identifies the caller and +/// may be expired; the refresh token in the body is the grant being validated. +pub fn refresh_tokens(access_token: Option<&str>, refresh_token: &str) -> Result { + let body = serde_json::json!({ "refresh_token": refresh_token }); + + let response = post_json(REFRESH_URL, &body, access_token) + .context("Failed to refresh the Granola access token")?; + + extract_token_set(&response) + .context("Granola's refresh response did not contain the expected tokens") +} + +/// POST JSON to a Granola auth endpoint and return the response body. +fn post_json( + url: &str, + body: &serde_json::Value, + bearer: Option<&str>, +) -> Result { + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS)) + .build() + .context("Failed to create HTTP client")?; + + let mut request = client + .post(url) + .header("Content-Type", "application/json") + .header("X-Client-Version", identity::client_version()) + .json(body); + + if let Some(token) = bearer { + request = request.header("Authorization", format!("Bearer {}", token)); + } + + let response = request.send().with_context(|| format!("POST {}", url))?; + + let status = response.status(); + let text = response.text().unwrap_or_default(); + debug!("POST {} -> {}", url, status); + + if !status.is_success() { + bail!("Granola returned HTTP {}: {}", status.as_u16(), text); + } + + Ok(text) +} + +/// Pull the token set out of an auth response. +/// +/// Granola nests these under `workos_tokens`, which it sometimes writes as a +/// JSON-encoded string rather than an object, and some responses carry the +/// fields at the top level instead. +fn extract_token_set(body: &str) -> Result { + let value: serde_json::Value = + serde_json::from_str(body).context("Response was not valid JSON")?; + + let tokens = match value.get("workos_tokens") { + Some(serde_json::Value::String(encoded)) => serde_json::from_str(encoded) + .context("workos_tokens was a string but not valid JSON")?, + Some(nested) => nested.clone(), + None => value, + }; + + serde_json::from_value(tokens).context("Response did not match the expected token shape") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_auth_url_carries_required_parameters() { + let (url, click_id) = build_auth_url("test-challenge", Provider::Google); + + assert!(url.starts_with(AUTH_URL)); + assert!(url.contains("code_challenge=test-challenge")); + assert!(url.contains("provider=google")); + assert!(url.contains("dev=false")); + assert!(url.contains("intent=download")); + // The version gate is the one parameter whose absence silently + // redirects to an upgrade page. + assert!(url.contains(&format!("version={}", identity::client_version()))); + assert!(url.contains(&format!("sign_in_click_id={}", click_id))); + } + + #[test] + fn test_auth_url_click_id_is_per_call() { + let (_, first) = build_auth_url("c", Provider::Google); + let (_, second) = build_auth_url("c", Provider::Google); + + assert_ne!(first, second); + } + + #[test] + fn test_auth_url_provider_selects_microsoft() { + let (url, _) = build_auth_url("c", Provider::Microsoft); + + assert!(url.contains("provider=microsoft")); + } + + #[test] + fn test_parse_callback_url() { + let code = parse_callback_code("granola://login-complete?code=abc123&sso=false").unwrap(); + + assert_eq!(code, "abc123"); + } + + #[test] + fn test_parse_callback_url_percent_decodes() { + let code = parse_callback_code("granola://login-complete?code=a%2Fb%2Bc").unwrap(); + + assert_eq!(code, "a/b+c"); + } + + #[test] + fn test_parse_callback_url_ignores_other_parameters() { + let code = + parse_callback_code("granola://login-complete?signInClickId=x&code=zzz&handoff=1") + .unwrap(); + + assert_eq!(code, "zzz"); + } + + #[test] + fn test_parse_bare_code() { + assert_eq!(parse_callback_code(" raw-code-42 ").unwrap(), "raw-code-42"); + } + + #[test] + fn test_parse_rejects_empty() { + assert!(parse_callback_code(" ").is_err()); + } + + #[test] + fn test_parse_rejects_url_without_code() { + let err = parse_callback_code("granola://login-complete?sso=false").unwrap_err(); + + assert!(err.to_string().contains("no 'code' parameter")); + } + + #[test] + fn test_parse_rejects_wrong_scheme() { + // A user who pasted the WorkOS URL from the address bar instead of the + // callback should be told what went wrong. + let err = parse_callback_code("https://api.workos.com/authorize?code=abc").unwrap_err(); + + assert!(err.to_string().contains("granola:// callback URL")); + } + + #[test] + fn test_parse_rejects_prose() { + assert!(parse_callback_code("I could not find the code").is_err()); + } + + #[test] + fn test_extract_token_set_from_nested_object() { + let body = r#"{"workos_tokens": { + "access_token": "at", "refresh_token": "rt", + "expires_in": 3600, "session_id": "sess_1" + }}"#; + + let tokens = extract_token_set(body).unwrap(); + + assert_eq!(tokens.access_token, "at"); + assert_eq!(tokens.refresh_token, "rt"); + assert_eq!(tokens.expires_in, Some(3600)); + assert_eq!(tokens.session_id, Some("sess_1".to_string())); + } + + #[test] + fn test_extract_token_set_from_double_encoded_string() { + // Granola writes workos_tokens as encoded JSON in its own token store, + // so the API is assumed capable of the same shape. + let body = + r#"{"workos_tokens": "{\"access_token\":\"at\",\"refresh_token\":\"rt\"}"}"#; + + let tokens = extract_token_set(body).unwrap(); + + assert_eq!(tokens.access_token, "at"); + assert_eq!(tokens.refresh_token, "rt"); + } + + #[test] + fn test_extract_token_set_from_top_level() { + let body = r#"{"access_token": "at", "refresh_token": "rt", "token_type": "Bearer"}"#; + + let tokens = extract_token_set(body).unwrap(); + + assert_eq!(tokens.access_token, "at"); + assert_eq!(tokens.expires_in, None); + } + + #[test] + fn test_extract_token_set_rejects_missing_refresh_token() { + // A response without a refresh token would strand the chain, so it + // must not be accepted as a successful login. + let body = r#"{"access_token": "at", "expires_in": 3600}"#; + + assert!(extract_token_set(body).is_err()); + } + + #[test] + fn test_into_credentials_dates_expiry_from_now() { + let tokens = TokenSet { + access_token: "at".to_string(), + refresh_token: "rt".to_string(), + expires_in: Some(3600), + session_id: Some("sess_1".to_string()), + }; + + let creds = tokens.into_credentials(1_000, None); + + assert_eq!(creds.expires_at, Some(4_600)); + assert_eq!(creds.session_id, Some("sess_1".to_string())); + } + + #[test] + fn test_into_credentials_keeps_prior_session_id() { + let tokens = TokenSet { + access_token: "at".to_string(), + refresh_token: "rt".to_string(), + expires_in: None, + session_id: None, + }; + + let creds = tokens.into_credentials(1_000, Some("sess_login".to_string())); + + assert_eq!(creds.session_id, Some("sess_login".to_string())); + assert_eq!(creds.expires_at, None); + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs index 910968a..086bbf5 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,6 +1,7 @@ mod auth; pub mod client; pub mod credentials; +pub mod granola_auth; pub mod identity; mod token_store; pub mod types; From 9848ef6f5f0096c9396b877eec0118b0b264bdd7 Mon Sep 17 00:00:00 2001 From: Dustin Wyatt Date: Sat, 25 Jul 2026 19:16:45 -0500 Subject: [PATCH 05/14] #89: Resolve tokens from grans's own credentials before Granola's store resolve_token now tries, in order: the caller's override, grans's stored credentials (refreshing when the access token has expired), then the token Granola's desktop app stored locally. The local store stays last rather than being removed, since it is still the only source on Windows and on older macOS builds for anyone who has not logged in. A failed refresh is reported, not quietly downgraded to the local store: if the user logged grans in, a broken chain is a real problem and the message says to run `grans auth login` again. Granola rotates the refresh token on every call, so two concurrent grans runs can race, with the loser holding a token the winner already consumed. Rather than declare the chain dead, re-read the file: if it moved on, the other run succeeded and its result is usable. An unchanged file means the failure is genuine and is not retried against the same dead token. GRANS_TOKEN is read once in main, at the boundary, and threaded down as a value, so no code below touches process-global state and the precedence stays testable. `grans admin token` now prints what the chain resolves rather than only what Granola stored. Claude-Session: https://claude.ai/code/session_012c8WU6fhQDaVekXJX5hGhH --- src/api/auth.rs | 289 ++++++++++++++++++++++++++++++++++++++++++++++-- src/api/mod.rs | 2 +- src/main.rs | 17 ++- 3 files changed, 294 insertions(+), 14 deletions(-) diff --git a/src/api/auth.rs b/src/api/auth.rs index 58e6b9f..540a8c1 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -2,10 +2,15 @@ use std::env; use std::path::{Path, PathBuf}; use anyhow::{bail, Context, Result}; +use chrono::Utc; use log::debug; use serde::Deserialize; -use super::token_store; +use super::credentials::{self, GranolaCredentials}; +use super::{granola_auth, token_store}; + +/// Environment variable supplying a token when `--token` is absent. +pub const TOKEN_ENV_VAR: &str = "GRANS_TOKEN"; /// Structure representing the relevant parts of Granola's supabase.json #[derive(Debug, Deserialize)] @@ -49,26 +54,135 @@ where } } -/// Resolve the authentication token: use the provided override, or fall back -/// to reading from Granola's supabase.json. +/// Pick the token override from the `--token` flag or the environment. +/// +/// Read at the CLI boundary and threaded downward as a value so nothing below +/// touches process-global state. An exported-but-empty environment variable +/// counts as absent; an empty `--token` is an explicit mistake and is left to +/// [`resolve_token`] to reject. +pub fn token_override(flag: Option<&str>, env_value: Option) -> Option { + if let Some(flag) = flag { + return Some(flag.to_string()); + } + + env_value.filter(|value| !value.trim().is_empty()) +} + +/// Resolve the authentication token. +/// +/// In order: the caller's override (`--token` or `GRANS_TOKEN`), then grans's +/// own stored credentials, refreshing them when the access token has expired, +/// then the token Granola's desktop app stored locally. +/// +/// That last step no longer works on current macOS builds, where Granola's +/// data-encryption key sits behind its own code signature, but it is still the +/// only source on Windows for anyone who has not run `grans auth login`. pub fn resolve_token(override_token: Option<&str>) -> Result { match override_token { Some(token) if token.is_empty() => { bail!("Provided --token value is empty") } Some(token) => { - debug!("Using provided --token override ({} chars)", token.len()); + debug!("Using provided token override ({} chars)", token.len()); Ok(token.to_string()) } - None => get_auth_token(), + None => stored_or_local_token(), + } +} + +/// Use grans's own credentials if it has any, otherwise Granola's local store. +fn stored_or_local_token() -> Result { + match GranolaCredentials::load()? { + Some(credentials) => { + debug!("Using grans's own stored credentials"); + token_from_credentials(credentials) + } + None => { + debug!("No stored credentials; reading Granola's local token store"); + get_auth_token() + } } } +/// Return the stored access token, refreshing it first if it has expired. +fn token_from_credentials(credentials: GranolaCredentials) -> Result { + let now = Utc::now().timestamp(); + + if let Some(token) = credentials.valid_access_token(now) { + return Ok(token.to_string()); + } + + debug!("Stored access token is missing or expired; refreshing"); + let path = credentials::credentials_path()?; + refresh_and_persist(credentials, now, &path, live_refresh) +} + +/// Ask Granola for a new token set. +fn live_refresh(credentials: &GranolaCredentials) -> Result { + granola_auth::refresh_tokens( + credentials.access_token.as_deref(), + &credentials.refresh_token, + ) +} + +/// Refresh the access token, tolerating a concurrent invocation that rotated +/// the refresh token first. +fn refresh_and_persist( + credentials: GranolaCredentials, + now: i64, + path: &Path, + refresh: impl Fn(&GranolaCredentials) -> Result, +) -> Result { + let error = match refresh_once(&credentials, now, path, &refresh) { + Ok(token) => return Ok(token), + Err(error) => error, + }; + + // Granola rotates the refresh token on every call, so another grans + // invocation refreshing at the same moment consumes the one we just sent. + // If what is on disk has moved on, that invocation succeeded and its + // result is usable; only a chain that has genuinely stalled is an error. + if let Some(current) = GranolaCredentials::load_from(path)? { + if current.refresh_token != credentials.refresh_token { + debug!("Refresh token was rotated concurrently; using the newer credentials"); + if let Some(token) = current.valid_access_token(now) { + return Ok(token.to_string()); + } + return refresh_once(¤t, now, path, &refresh); + } + } + + Err(error.context( + "Could not refresh grans's Granola session. Run `grans auth login` to sign in again.", + )) +} + +/// Exchange the refresh token for a new access token and persist the result. +fn refresh_once( + credentials: &GranolaCredentials, + now: i64, + path: &Path, + refresh: impl Fn(&GranolaCredentials) -> Result, +) -> Result { + let tokens = refresh(credentials)?; + let access_token = tokens.access_token.clone(); + + // Persist before returning: Granola has already retired the old refresh + // token, so handing back an access token without saving the rotation + // would strand the chain if the process died here. + tokens + .into_credentials(now, credentials.session_id.clone()) + .save_to(path)?; + + Ok(access_token) +} + /// Get the authentication token from Granola's local config. /// /// Prefers the encrypted `supabase.json.enc` store used by recent Granola -/// versions, falling back to the legacy plaintext `supabase.json`. -pub fn get_auth_token() -> Result { +/// versions, falling back to the legacy plaintext `supabase.json`. Reached +/// only when grans has no credentials of its own. +fn get_auth_token() -> Result { let dir = find_granola_dir()?; let json = read_token_json(&dir)?; extract_access_token(&json, &dir) @@ -318,10 +432,163 @@ mod tests { } #[test] - fn test_resolve_token_none_falls_back() { - // When no override is provided, resolve_token falls back to get_auth_token. - // On CI / machines without Granola this will error, but it should not panic. - let _ = resolve_token(None); + fn test_token_override_prefers_flag() { + let chosen = token_override(Some("from-flag"), Some("from-env".to_string())); + + assert_eq!(chosen, Some("from-flag".to_string())); + } + + #[test] + fn test_token_override_uses_env_without_flag() { + let chosen = token_override(None, Some("from-env".to_string())); + + assert_eq!(chosen, Some("from-env".to_string())); + } + + #[test] + fn test_token_override_treats_blank_env_as_absent() { + // An exported-but-empty GRANS_TOKEN should fall through to the + // credential chain rather than send an empty bearer token. + assert_eq!(token_override(None, Some(" ".to_string())), None); + } + + #[test] + fn test_token_override_keeps_empty_flag_for_rejection() { + // `--token ""` is a mistake worth reporting, so it must survive to + // resolve_token rather than silently falling through. + assert_eq!(token_override(Some(""), None), Some(String::new())); + assert!(resolve_token(Some("")).is_err()); + } + + #[test] + fn test_token_override_absent_without_flag_or_env() { + assert_eq!(token_override(None, None), None); + } + + // --- refresh chain --- + + fn stored(refresh_token: &str, access_token: Option<&str>, expires_at: Option) -> GranolaCredentials { + GranolaCredentials { + refresh_token: refresh_token.to_string(), + access_token: access_token.map(str::to_string), + expires_at, + session_id: Some("sess_original".to_string()), + } + } + + fn token_set(access: &str, refresh: &str) -> granola_auth::TokenSet { + granola_auth::TokenSet { + access_token: access.to_string(), + refresh_token: refresh.to_string(), + expires_in: Some(3600), + session_id: None, + } + } + + #[test] + fn test_refresh_persists_rotated_token_before_returning() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("auth.toml"); + let old = stored("refresh-old", None, None); + + let token = refresh_and_persist(old, 1_000, &path, |_| { + Ok(token_set("access-new", "refresh-new")) + }) + .unwrap(); + + assert_eq!(token, "access-new"); + let saved = GranolaCredentials::load_from(&path).unwrap().unwrap(); + assert_eq!(saved.refresh_token, "refresh-new"); + assert_eq!(saved.access_token, Some("access-new".to_string())); + assert_eq!(saved.expires_at, Some(4_600)); + } + + #[test] + fn test_refresh_carries_session_id_forward() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("auth.toml"); + + refresh_and_persist(stored("refresh-old", None, None), 1_000, &path, |_| { + Ok(token_set("access-new", "refresh-new")) + }) + .unwrap(); + + let saved = GranolaCredentials::load_from(&path).unwrap().unwrap(); + assert_eq!(saved.session_id, Some("sess_original".to_string())); + } + + #[test] + fn test_refresh_failure_reports_relogin() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("auth.toml"); + let creds = stored("refresh-dead", None, None); + creds.save_to(&path).unwrap(); + + let err = refresh_and_persist(creds, 1_000, &path, |_| bail!("token expired")) + .unwrap_err(); + + assert!(err.to_string().contains("grans auth login")); + } + + #[test] + fn test_refresh_uses_token_a_concurrent_run_already_fetched() { + // Another grans consumed our refresh token and wrote a fresh access + // token. Ours fails, but the chain is intact and its result is usable. + let dir = TempDir::new().unwrap(); + let path = dir.path().join("auth.toml"); + let ours = stored("refresh-old", None, None); + stored("refresh-rotated", Some("access-from-other-run"), Some(9_000)) + .save_to(&path) + .unwrap(); + + let token = refresh_and_persist(ours, 1_000, &path, |_| bail!("token already used")) + .unwrap(); + + assert_eq!(token, "access-from-other-run"); + } + + #[test] + fn test_refresh_retries_with_rotated_token_when_other_run_left_none_usable() { + // The concurrent run rotated the refresh token but its access token is + // already expired, so we refresh again with what it left behind. + let dir = TempDir::new().unwrap(); + let path = dir.path().join("auth.toml"); + let ours = stored("refresh-old", None, None); + stored("refresh-rotated", Some("stale"), Some(0)) + .save_to(&path) + .unwrap(); + + let token = refresh_and_persist(ours, 1_000, &path, |credentials| { + if credentials.refresh_token == "refresh-rotated" { + Ok(token_set("access-second-try", "refresh-newest")) + } else { + bail!("token already used") + } + }) + .unwrap(); + + assert_eq!(token, "access-second-try"); + let saved = GranolaCredentials::load_from(&path).unwrap().unwrap(); + assert_eq!(saved.refresh_token, "refresh-newest"); + } + + #[test] + fn test_refresh_does_not_retry_when_stored_token_is_unchanged() { + // Nothing rotated it, so the failure is real and must not be retried + // against the same dead token. + let dir = TempDir::new().unwrap(); + let path = dir.path().join("auth.toml"); + let creds = stored("refresh-same", None, None); + creds.save_to(&path).unwrap(); + let attempts = std::cell::Cell::new(0); + + let result = refresh_and_persist(creds, 1_000, &path, |_| { + attempts.set(attempts.get() + 1); + bail!("refresh rejected") + }); + + assert!(result.is_err()); + assert_eq!(attempts.get(), 1); } #[test] diff --git a/src/api/mod.rs b/src/api/mod.rs index 086bbf5..ba380b0 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -6,6 +6,6 @@ pub mod identity; mod token_store; pub mod types; -pub use auth::{get_auth_token, resolve_token}; +pub use auth::{resolve_token, token_override, TOKEN_ENV_VAR}; pub use client::{fetch_panels, fetch_transcript, ApiClient, ApiError}; pub use types::ApiPanel; diff --git a/src/main.rs b/src/main.rs index 9d3ac7f..b89171e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -39,6 +39,13 @@ fn main() -> Result<()> { let ctx = RunContext::from_args(cli.json, cli.no_color, cli.utc)?; + // Resolve the token override once, here at the boundary, so nothing below + // reads the environment for itself. + let token_override = api::token_override( + cli.token.as_deref(), + std::env::var(api::TOKEN_ENV_VAR).ok(), + ); + // Admin DB commands don't need a database connection if let Commands::Admin { action: AdminAction::Db { action }, @@ -60,7 +67,13 @@ fn main() -> Result<()> { // Sync command (from Granola API) if let Commands::Sync { action, dry_run } = &cli.command { let conn = get_connection(cli.db.as_deref())?; - commands::sync_granola::run(&conn, action, *dry_run, cli.token.as_deref(), ctx.output_mode)?; + commands::sync_granola::run( + &conn, + action, + *dry_run, + token_override.as_deref(), + ctx.output_mode, + )?; return Ok(()); } @@ -254,7 +267,7 @@ fn main() -> Result<()> { Commands::Admin { action } => match action { AdminAction::Db { .. } => unreachable!(), // Handled above AdminAction::Token { clipboard } => { - let token = api::get_auth_token()?; + let token = api::resolve_token(token_override.as_deref())?; if *clipboard { platform::copy_to_clipboard(&token)?; eprintln!("Token copied to clipboard."); From e7c066ab6de71f53c28482a094c9be23536f9611 Mon Sep 17 00:00:00 2001 From: Dustin Wyatt Date: Sat, 25 Jul 2026 19:29:36 -0500 Subject: [PATCH 06/14] #89: Add `grans auth` login, status and logout `grans auth login` runs Granola's PKCE flow in a browser and stores the resulting session. The callback is a granola:// URL that both macOS and Windows route to Granola.app, so the login prints instructions to cancel the app-open dialog and paste the URL back. That dialog is not just awkward: letting Granola open the link consumes a code bound to grans's PKCE verifier, which fails inside Granola and leaves grans holding a dead code with no visible cause. `--refresh-token-stdin` bootstraps from an existing refresh token without a browser, off the shell history and out of the process list. `grans auth status` reports presence and expiry only. It has a test asserting no token material reaches the output, since that is the kind of thing a later convenience edit quietly breaks. `grans auth logout` removes the local credentials and says plainly that the session stays alive in Granola until revoked there. The global --token help no longer claims the token comes from Granola's config, which stopped being the whole story when the chain grew. Claude-Session: https://claude.ai/code/session_012c8WU6fhQDaVekXJX5hGhH --- src/cli/args/mod.rs | 45 +++++++- src/commands/auth.rs | 254 +++++++++++++++++++++++++++++++++++++++++++ src/commands/mod.rs | 1 + src/main.rs | 7 ++ 4 files changed, 306 insertions(+), 1 deletion(-) create mode 100644 src/commands/auth.rs diff --git a/src/cli/args/mod.rs b/src/cli/args/mod.rs index 0d53006..22d0c6f 100644 --- a/src/cli/args/mod.rs +++ b/src/cli/args/mod.rs @@ -26,7 +26,11 @@ pub struct Cli { #[arg(long, global = true)] pub db: Option, - /// Use a specific API token instead of reading from Granola's config + /// Use a specific API token [env: GRANS_TOKEN] + /// + /// Without this, grans uses its own stored credentials (see + /// `grans auth login`), falling back to the token Granola's desktop app + /// stored locally. #[arg(long, global = true)] pub token: Option, @@ -250,6 +254,12 @@ pub enum Commands { action: DropboxAction, }, + /// Manage grans's Granola sign-in (login, status, logout) + Auth { + #[command(subcommand)] + action: AuthAction, + }, + // === Grouped Commands === /// Browse entities (people, calendars, templates, recipes) Browse { @@ -626,6 +636,39 @@ pub enum DbAction { List, } +// === Auth Subcommands === + +#[derive(Subcommand, Debug)] +pub enum AuthAction { + /// Sign in to Granola and store credentials for grans + /// + /// Opens a browser to Granola's login. The callback is a granola:// URL + /// that your operating system routes to the Granola app rather than to + /// grans, so cancel the dialog offering to open Granola and paste the URL + /// back here instead. + Login { + /// Identity provider to sign in with + #[arg(long, value_enum, default_value_t = AuthProvider::Google)] + provider: AuthProvider, + + /// Read an existing refresh token from stdin instead of signing in + /// + /// Keeps the token out of shell history and the process list. + #[arg(long)] + refresh_token_stdin: bool, + }, + /// Show whether grans has its own Granola session + Status, + /// Remove grans's stored credentials + Logout, +} + +#[derive(ValueEnum, Clone, Copy, Debug)] +pub enum AuthProvider { + Google, + Microsoft, +} + #[derive(Subcommand, Debug)] pub enum DropboxAction { /// Set up Dropbox authentication (one-time setup) diff --git a/src/commands/auth.rs b/src/commands/auth.rs new file mode 100644 index 0000000..981ebd5 --- /dev/null +++ b/src/commands/auth.rs @@ -0,0 +1,254 @@ +//! `grans auth` — manage grans's own Granola session. + +use std::io::{self, BufRead, Write}; + +use anyhow::{bail, Context, Result}; +use chrono::{FixedOffset, TimeZone, Utc}; + +use crate::api::credentials::{self, GranolaCredentials}; +use crate::api::granola_auth::{self, Provider}; +use crate::cli::args::{AuthAction, AuthProvider}; +use crate::pkce::PkceChallenge; + +impl From for Provider { + fn from(provider: AuthProvider) -> Self { + match provider { + AuthProvider::Google => Provider::Google, + AuthProvider::Microsoft => Provider::Microsoft, + } + } +} + +/// Run `grans auth` subcommands. +pub fn run(action: &AuthAction, tz: &FixedOffset) -> Result<()> { + match action { + AuthAction::Login { + provider, + refresh_token_stdin, + } => login(*provider, *refresh_token_stdin), + AuthAction::Status => status(tz), + AuthAction::Logout => logout(), + } +} + +fn login(provider: AuthProvider, refresh_token_stdin: bool) -> Result<()> { + if existing_session_kept()? { + return Ok(()); + } + + let credentials = if refresh_token_stdin { + credentials_from_stdin()? + } else { + credentials_from_browser_login(provider.into())? + }; + + credentials.save()?; + + println!("\nSigned in. grans now holds its own Granola session."); + println!("Run `grans sync` to fetch your meetings."); + Ok(()) +} + +/// Ask before replacing a session that already works. +/// +/// Returns true when the caller should stop, leaving the session alone. +fn existing_session_kept() -> Result { + if GranolaCredentials::load()?.is_none() { + return Ok(false); + } + + println!("grans already has a Granola session."); + print!("Sign in again? [y/N] "); + io::stdout().flush()?; + + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + + if input.trim().eq_ignore_ascii_case("y") { + return Ok(false); + } + + println!("Keeping the existing session."); + Ok(true) +} + +/// Bootstrap from a refresh token supplied on stdin. +/// +/// Reading it from stdin rather than an argument keeps the token out of shell +/// history and out of the process list. +fn credentials_from_stdin() -> Result { + let mut token = String::new(); + io::stdin() + .lock() + .read_line(&mut token) + .context("Failed to read the refresh token from stdin")?; + + let token = token.trim(); + if token.is_empty() { + bail!("No refresh token on stdin"); + } + + Ok(GranolaCredentials::from_refresh_token(token.to_string())) +} + +/// Run the browser login and exchange the pasted callback for tokens. +fn credentials_from_browser_login(provider: Provider) -> Result { + let pkce = PkceChallenge::generate(granola_auth::PKCE_ENTROPY_BYTES); + let (auth_url, sign_in_click_id) = granola_auth::build_auth_url(&pkce.challenge, provider); + + granola_auth::check_client_version_accepted(&auth_url)?; + + println!("\nOpening your browser to sign in to Granola..."); + println!("\nIf it doesn't open, visit this URL:"); + println!("{}\n", auth_url); + + if let Err(e) = open::that(&auth_url) { + eprintln!("Could not open browser: {}", e); + } + + print_callback_instructions(); + + print!("Paste the callback URL: "); + io::stdout().flush()?; + let mut pasted = String::new(); + io::stdin() + .lock() + .read_line(&mut pasted) + .context("Failed to read the callback URL from stdin")?; + + let code = granola_auth::parse_callback_code(&pasted)?; + + println!("Exchanging the code for tokens..."); + let tokens = granola_auth::exchange_code(&code, &pkce.verifier, &sign_in_click_id)?; + + Ok(tokens.into_credentials(Utc::now().timestamp(), None)) +} + +/// Explain how to get the callback URL out of the browser. +/// +/// The dialog matters: the authorization code is bound to the PKCE verifier +/// grans generated, so letting Granola.app open the link consumes the code +/// against a verifier it does not have. That fails inside Granola and leaves +/// grans with a dead code and no obvious cause. +fn print_callback_instructions() { + println!("After signing in, your browser will offer to open Granola."); + println!("Cancel that dialog. Copy the granola://login-complete?... URL instead."); + println!(); +} + +fn status(tz: &FixedOffset) -> Result<()> { + let Some(credentials) = GranolaCredentials::load()? else { + println!("Not signed in."); + println!("grans falls back to the token Granola's desktop app stored locally,"); + println!("which no longer works on current macOS builds. Run `grans auth login`."); + return Ok(()); + }; + + println!("Signed in."); + println!(" Credentials: {}", credentials::credentials_path()?.display()); + + if let Some(session_id) = &credentials.session_id { + println!(" Session: {}", session_id); + } + + println!(" Access token: {}", describe_expiry(&credentials, tz)); + + Ok(()) +} + +/// Describe the access token's freshness without revealing any of it. +fn describe_expiry(credentials: &GranolaCredentials, tz: &FixedOffset) -> String { + let now = Utc::now().timestamp(); + + match credentials.expires_at { + None if credentials.access_token.is_none() => { + "none stored, will be fetched on next use".to_string() + } + None => "stored, expiry unknown; will be refreshed on next use".to_string(), + Some(expires_at) => { + let when = tz + .timestamp_opt(expires_at, 0) + .single() + .map(|t| t.format("%Y-%m-%d %H:%M:%S").to_string()) + .unwrap_or_else(|| expires_at.to_string()); + + if credentials.valid_access_token(now).is_some() { + format!("valid until {}", when) + } else { + format!("expired at {}, will be refreshed on next use", when) + } + } + } +} + +fn logout() -> Result<()> { + if GranolaCredentials::load()?.is_none() { + println!("Not signed in; nothing to remove."); + return Ok(()); + } + + credentials::delete()?; + + println!("Removed grans's stored Granola credentials."); + println!("The session itself remains active in Granola until you revoke it there."); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tz() -> FixedOffset { + FixedOffset::east_opt(0).unwrap() + } + + fn with_expiry(expires_at: Option, access_token: Option<&str>) -> GranolaCredentials { + GranolaCredentials { + refresh_token: "rt".to_string(), + access_token: access_token.map(str::to_string), + expires_at, + session_id: None, + } + } + + #[test] + fn test_describe_expiry_for_valid_token() { + let future = Utc::now().timestamp() + 3600; + let described = describe_expiry(&with_expiry(Some(future), Some("at")), &tz()); + + assert!(described.starts_with("valid until ")); + } + + #[test] + fn test_describe_expiry_for_expired_token() { + let past = Utc::now().timestamp() - 3600; + let described = describe_expiry(&with_expiry(Some(past), Some("at")), &tz()); + + assert!(described.contains("expired at")); + assert!(described.contains("refreshed on next use")); + } + + #[test] + fn test_describe_expiry_without_any_access_token() { + let described = describe_expiry(&with_expiry(None, None), &tz()); + + assert!(described.contains("none stored")); + } + + #[test] + fn test_describe_expiry_without_known_expiry() { + let described = describe_expiry(&with_expiry(None, Some("at")), &tz()); + + assert!(described.contains("expiry unknown")); + } + + #[test] + fn test_describe_expiry_never_includes_token_material() { + let future = Utc::now().timestamp() + 3600; + let secret = "super-secret-access-token"; + + let described = describe_expiry(&with_expiry(Some(future), Some(secret)), &tz()); + + assert!(!described.contains(secret)); + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 499fdc8..b9bde85 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,3 +1,4 @@ +pub mod auth; pub mod benchmark; pub mod browse; pub mod calendars; diff --git a/src/main.rs b/src/main.rs index b89171e..e58f4f1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -64,6 +64,12 @@ fn main() -> Result<()> { return Ok(()); } + // Auth commands (login, status, logout) don't need a database + if let Commands::Auth { action } = &cli.command { + commands::auth::run(action, &ctx.tz)?; + return Ok(()); + } + // Sync command (from Granola API) if let Commands::Sync { action, dry_run } = &cli.command { let conn = get_connection(cli.db.as_deref())?; @@ -254,6 +260,7 @@ fn main() -> Result<()> { Commands::Benchmark { .. } => unreachable!(), // Handled above Commands::Dropbox { .. } => unreachable!(), // Handled above + Commands::Auth { .. } => unreachable!(), // Handled above Commands::Update { .. } => unreachable!(), // Handled above Commands::Sync { .. } => unreachable!(), // Handled above Commands::Embed { .. } => unreachable!(), // Handled above From 69c5ff8e1cd300a9f5628c7b73528ac94b6747cd Mon Sep 17 00:00:00 2001 From: Dustin Wyatt Date: Sat, 25 Jul 2026 19:32:03 -0500 Subject: [PATCH 07/14] #89: Document signing grans in, and correct the macOS claim The README told macOS users to expect a Keychain prompt and choose "Always Allow". That prompt cannot succeed on current builds: Granola's data-encryption key moved into the data-protection keychain behind its own code signature, so the instruction sent people looking for a dialog that will not appear. Replaces it with the resolution order, the `grans auth` commands, the paste step and why cancelling the app-open dialog matters, and the GRANS_GRANOLA_VERSION escape hatch for a rejected client version. Claude-Session: https://claude.ai/code/session_012c8WU6fhQDaVekXJX5hGhH --- README.md | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 51 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 4b298db..6be96f5 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ grans [OPTIONS] | Flag | Description | |------|-------------| | `--db ` | Use a specific database file instead of the default | -| `--token ` | Use a specific API token instead of reading from Granola's config | +| `--token ` | Use a specific API token (also read from `GRANS_TOKEN`) | | `--json` | Output as JSON | | `--no-color` | Disable colored output (human-readable format without ANSI codes) | | `--utc` | Display timestamps in UTC instead of local time | @@ -82,6 +82,11 @@ grans uses a task-centric CLI design. Common tasks are promoted to top-level com - `browse templates` - List/show panel templates - `browse recipes` - List/show recipes +**Auth Commands** (Granola sign-in): +- `auth login` - Sign in to Granola and store credentials for grans +- `auth status` - Show whether grans has its own session, and its expiry +- `auth logout` - Remove the stored credentials + **Admin Commands** (maintenance): - `admin db` - Database management (clear, info, list) - `admin token` - Print the current Granola API token @@ -120,21 +125,62 @@ grans sync panels --limit 10 # Fetch panels for up to 10 documents grans sync panels --retry # Retry previously failed panel fetches ``` -**Note:** Sync requires a Granola auth token. By default, it reads the token from Granola's local config, transparently decrypting the `supabase.json.enc` store that recent Granola versions use (falling back to the legacy plaintext `supabase.json`). You can also provide a token explicitly with `--token`: +**Note:** Sync requires a Granola auth token. grans looks for one in this order: + +1. `--token `, or the `GRANS_TOKEN` environment variable +2. grans's own stored credentials, from `grans auth login` +3. The token Granola's desktop app stored locally + +### Signing in ```bash -grans --token sync +grans auth login # Sign in and store credentials for grans +grans auth login --provider microsoft +grans auth status # Show whether grans has a session, and its expiry +grans auth logout # Remove the stored credentials ``` -On macOS, the decryption key lives in the login Keychain, so the first sync shows a Keychain access prompt ("grans wants to use your confidential information stored in 'Granola Safe Storage'"). Choose "Always Allow" to skip it on later runs, or pass `--token` to avoid reading the Keychain at all. +`grans auth login` opens your browser to Granola's login. When it finishes, +the browser offers to open the Granola app. **Cancel that dialog** and copy the +`granola://login-complete?...` URL instead, then paste it back into grans. The +authorization code is tied to grans's login attempt, so letting Granola open +the link consumes the code and the sign-in fails. + +This creates a session on your Granola account, separate from the desktop +app's. It appears in Granola's session list and you can revoke it there. +`grans auth logout` only removes the local copy. + +Once signed in, grans refreshes its own access token and does not need Granola +running, or installed. + +### Reading Granola's local token + +Without `grans auth login`, grans falls back to the token Granola's desktop app +stored, decrypting the `supabase.json.enc` store that recent versions use +(falling back to the legacy plaintext `supabase.json`). -To get the token from a machine with Granola installed: +**This no longer works on current macOS builds.** Granola moved its +data-encryption key into the macOS data-protection keychain, gated on Granola's +own code signature, so no other program can read it. Use `grans auth login` +instead. The fallback still works on Windows. + +To print whichever token grans resolves: ```bash grans admin token # Print to stdout grans admin token --clipboard # Copy to clipboard without printing ``` +### If login reports an out-of-date client + +grans identifies itself to Granola with a desktop client version, which Granola +rejects if it falls below their minimum. Override it without waiting for a +grans release: + +```bash +export GRANS_GRANOLA_VERSION=7.441.6 # your installed Granola version +``` + ### Search and Grep Two verbs query meeting content, with two different promises: From f9a23571a59dbae01a3aec9623a87db5b1ea69cf Mon Sep 17 00:00:00 2001 From: Dustin Wyatt Date: Sat, 25 Jul 2026 19:36:21 -0500 Subject: [PATCH 08/14] #89: Split reading Granola's local token store out of auth.rs auth.rs had grown to 601 lines holding two jobs: deciding which token grans uses, and knowing how to find and decrypt the one Granola's desktop app left on disk. The second is now api::local_store, leaving auth.rs as the resolution order and nothing else. This also matches how the two are expected to age. Local-store scavenging is a legacy fallback that macOS has already closed off and Windows may follow; the resolution chain is where new sources get added. Pure move, no behavior change: 821 tests pass before and after. Claude-Session: https://claude.ai/code/session_012c8WU6fhQDaVekXJX5hGhH --- src/api/auth.rs | 301 ++-------------------------------------- src/api/local_store.rs | 307 +++++++++++++++++++++++++++++++++++++++++ src/api/mod.rs | 1 + 3 files changed, 318 insertions(+), 291 deletions(-) create mode 100644 src/api/local_store.rs diff --git a/src/api/auth.rs b/src/api/auth.rs index 540a8c1..6a29c82 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -1,59 +1,21 @@ -use std::env; -use std::path::{Path, PathBuf}; +//! Deciding which token grans authenticates with. +//! +//! The sources themselves live elsewhere: [`super::credentials`] holds grans's +//! own session and [`super::local_store`] reads the one Granola's desktop app +//! stored. This module is the order they are tried in. -use anyhow::{bail, Context, Result}; +use std::path::Path; + +use anyhow::{bail, Result}; use chrono::Utc; use log::debug; -use serde::Deserialize; use super::credentials::{self, GranolaCredentials}; -use super::{granola_auth, token_store}; +use super::{granola_auth, local_store}; /// Environment variable supplying a token when `--token` is absent. pub const TOKEN_ENV_VAR: &str = "GRANS_TOKEN"; -/// Structure representing the relevant parts of Granola's supabase.json -#[derive(Debug, Deserialize)] -struct SupabaseConfig { - #[serde(default, deserialize_with = "deserialize_double_encoded_workos_tokens")] - workos_tokens: Option, -} - -#[derive(Debug, Deserialize)] -struct WorkosTokens { - #[serde(default)] - access_token: Option, -} - -/// Deserialize workos_tokens which may be either: -/// - A JSON object (WorkosTokens directly) -/// - A double-encoded JSON string containing WorkosTokens -fn deserialize_double_encoded_workos_tokens<'de, D>( - deserializer: D, -) -> std::result::Result, D::Error> -where - D: serde::Deserializer<'de>, -{ - use serde::de::Error; - - // First, try to deserialize as an untagged enum that accepts either - #[derive(Deserialize)] - #[serde(untagged)] - enum StringOrObject { - String(String), - Object(WorkosTokens), - } - - match Option::::deserialize(deserializer)? { - None => Ok(None), - Some(StringOrObject::Object(tokens)) => Ok(Some(tokens)), - Some(StringOrObject::String(s)) => { - // Double-encoded: parse the string as JSON - serde_json::from_str(&s).map(Some).map_err(D::Error::custom) - } - } -} - /// Pick the token override from the `--token` flag or the environment. /// /// Read at the CLI boundary and threaded downward as a value so nothing below @@ -99,7 +61,7 @@ fn stored_or_local_token() -> Result { } None => { debug!("No stored credentials; reading Granola's local token store"); - get_auth_token() + local_store::get_auth_token() } } } @@ -177,247 +139,11 @@ fn refresh_once( Ok(access_token) } -/// Get the authentication token from Granola's local config. -/// -/// Prefers the encrypted `supabase.json.enc` store used by recent Granola -/// versions, falling back to the legacy plaintext `supabase.json`. Reached -/// only when grans has no credentials of its own. -fn get_auth_token() -> Result { - let dir = find_granola_dir()?; - let json = read_token_json(&dir)?; - extract_access_token(&json, &dir) -} - -/// Read the raw token JSON from a Granola config directory, preferring the -/// encrypted store and falling back to the legacy plaintext file. -fn read_token_json(dir: &Path) -> Result { - let encrypted = dir.join("supabase.json.enc"); - if encrypted.exists() { - debug!("Reading encrypted token store at {}", encrypted.display()); - return token_store::decrypt_token_json(dir).with_context(|| { - format!("Failed to read encrypted Granola token store in {}", dir.display()) - }); - } - - let plaintext = dir.join("supabase.json"); - debug!("Reading plaintext token store at {}", plaintext.display()); - std::fs::read_to_string(&plaintext) - .with_context(|| format!("Failed to read {}", plaintext.display())) -} - -/// Parse the token JSON and extract a non-empty access token. -fn extract_access_token(json: &str, dir: &Path) -> Result { - let config: SupabaseConfig = serde_json::from_str(json) - .with_context(|| format!("Failed to parse Granola token JSON from {}", dir.display()))?; - - let token = config - .workos_tokens - .and_then(|t| t.access_token) - .ok_or_else(|| anyhow::anyhow!( - "No access token found in Granola config at {}. Please ensure you are logged into Granola.", - dir.display() - ))?; - - if token.is_empty() { - bail!("Access token is empty in Granola config at {}. Please re-login to Granola.", dir.display()); - } - - debug!("Loaded auth token ({} chars)", token.len()); - Ok(token) -} - -/// Find the Granola config directory in platform-specific locations. A -/// directory qualifies if it contains either token store file. -fn find_granola_dir() -> Result { - let candidates = granola_dir_candidates(); - debug!("Searching for Granola config in {} locations", candidates.len()); - - for candidate in &candidates { - debug!(" checking: {}", candidate.display()); - if candidate.join("supabase.json.enc").exists() - || candidate.join("supabase.json").exists() - { - debug!(" found: {}", candidate.display()); - return Ok(candidate.clone()); - } - } - - bail!( - "Could not find Granola auth config. Searched:\n{}\n\n\ - Please ensure Granola is installed and you are logged in.", - candidates - .iter() - .map(|p| format!(" - {}", p.display())) - .collect::>() - .join("\n") - ) -} - -/// Detect if running on WSL by checking /proc/version for Microsoft/WSL markers -fn is_wsl() -> bool { - if cfg!(target_os = "linux") { - if let Ok(version) = std::fs::read_to_string("/proc/version") { - let version_lower = version.to_lowercase(); - return version_lower.contains("microsoft") || version_lower.contains("wsl"); - } - } - false -} - -/// Get Windows username from WSL environment -fn wsl_windows_username() -> Option { - // Try to get Windows username via cmd.exe - if let Ok(output) = std::process::Command::new("cmd.exe") - .args(["/c", "echo %USERNAME%"]) - .output() - { - if let Ok(username) = String::from_utf8(output.stdout) { - let username = username.trim(); - if !username.is_empty() && username != "%USERNAME%" { - return Some(username.to_string()); - } - } - } - - // Fallback to WSL username - if let Ok(user) = env::var("USER") { - return Some(user); - } - - None -} - -/// Get Windows-side Granola config directory candidates when running on WSL -fn wsl_windows_granola_dirs() -> Option> { - let username = wsl_windows_username()?; - - Some(vec![ - // Windows AppData Roaming path via WSL mount - PathBuf::from(format!("/mnt/c/Users/{}/AppData/Roaming/Granola", username)), - // Also check Local AppData as a fallback - PathBuf::from(format!("/mnt/c/Users/{}/AppData/Local/Granola", username)), - ]) -} - -fn granola_dir_candidates() -> Vec { - let mut candidates = Vec::new(); - - // WSL: Check Windows paths first (higher priority) - if is_wsl() { - if let Some(windows_paths) = wsl_windows_granola_dirs() { - candidates.extend(windows_paths); - } - } - - if let Some(home) = dirs_home() { - // macOS - candidates.push(home.join("Library/Application Support/Granola")); - - // Linux / WSL fallback - candidates.push(home.join(".config/Granola")); - - // XDG - if let Ok(xdg) = env::var("XDG_CONFIG_HOME") { - candidates.push(PathBuf::from(xdg).join("Granola")); - } - } - - // Windows (native) - if let Ok(appdata) = env::var("APPDATA") { - candidates.push(PathBuf::from(appdata).join("Granola")); - } - - candidates -} - -fn dirs_home() -> Option { - env::var("HOME") - .ok() - .map(PathBuf::from) - .or_else(|| env::var("USERPROFILE").ok().map(PathBuf::from)) -} - #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; - #[test] - fn test_parse_supabase_config_with_token() { - let json = r#"{ - "workos_tokens": { - "access_token": "test-token-123" - } - }"#; - - let config: SupabaseConfig = serde_json::from_str(json).unwrap(); - assert_eq!( - config.workos_tokens.unwrap().access_token, - Some("test-token-123".to_string()) - ); - } - - #[test] - fn test_parse_supabase_config_empty() { - let json = r#"{}"#; - let config: SupabaseConfig = serde_json::from_str(json).unwrap(); - assert!(config.workos_tokens.is_none()); - } - - #[test] - fn test_parse_supabase_config_no_token() { - let json = r#"{"workos_tokens": {}}"#; - let config: SupabaseConfig = serde_json::from_str(json).unwrap(); - assert!(config.workos_tokens.unwrap().access_token.is_none()); - } - - #[test] - fn test_parse_supabase_config_double_encoded() { - // workos_tokens is a JSON string containing JSON (double-encoded) - let json = r#"{"workos_tokens": "{\"access_token\":\"double-encoded-token\",\"expires_in\":21599}"}"#; - let config: SupabaseConfig = serde_json::from_str(json).unwrap(); - assert_eq!( - config.workos_tokens.unwrap().access_token, - Some("double-encoded-token".to_string()) - ); - } - - #[test] - fn test_get_auth_token_from_file() { - let dir = TempDir::new().unwrap(); - let config_path = dir.path().join("supabase.json"); - - std::fs::write(&config_path, r#"{"workos_tokens": {"access_token": "my-secret-token"}}"#).unwrap(); - - // We can't easily test get_auth_token() directly since it uses platform paths, - // but we can test the parsing logic - let content = std::fs::read_to_string(&config_path).unwrap(); - let config: SupabaseConfig = serde_json::from_str(&content).unwrap(); - let token = config.workos_tokens.unwrap().access_token.unwrap(); - assert_eq!(token, "my-secret-token"); - } - - #[test] - fn test_is_wsl() { - // We can't guarantee the test environment, but we can verify - // the function doesn't panic - let _ = is_wsl(); - } - - #[test] - fn test_wsl_windows_granola_dirs() { - // Test that the function returns paths in the expected format - // Even if we can't determine the username, it should not panic - if let Some(candidates) = wsl_windows_granola_dirs() { - for path in &candidates { - let path_str = path.to_string_lossy(); - assert!( - path_str.contains("/mnt/c/Users/") && path_str.ends_with("Granola") - ); - } - } - } - #[test] fn test_resolve_token_uses_override() { let token = resolve_token(Some("my-override-token")).unwrap(); @@ -591,11 +317,4 @@ mod tests { assert_eq!(attempts.get(), 1); } - #[test] - fn test_granola_dir_candidates_no_panic() { - // Ensure granola_dir_candidates doesn't panic - let candidates = granola_dir_candidates(); - // Should return at least some candidates - assert!(!candidates.is_empty() || cfg!(target_os = "unknown")); - } } diff --git a/src/api/local_store.rs b/src/api/local_store.rs new file mode 100644 index 0000000..57e23d0 --- /dev/null +++ b/src/api/local_store.rs @@ -0,0 +1,307 @@ +//! Reading the auth token Granola's desktop app stored on this machine. +//! +//! This is grans's fallback when it has no session of its own. It no longer +//! works on current macOS builds, where Granola's data-encryption key sits in +//! the data-protection keychain behind Granola's code signature, but it is +//! still the only source on Windows for anyone who has not run +//! `grans auth login`. + +use std::env; +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context, Result}; +use log::debug; +use serde::Deserialize; + +use super::token_store; + +/// Structure representing the relevant parts of Granola's supabase.json +#[derive(Debug, Deserialize)] +struct SupabaseConfig { + #[serde(default, deserialize_with = "deserialize_double_encoded_workos_tokens")] + workos_tokens: Option, +} + +#[derive(Debug, Deserialize)] +struct WorkosTokens { + #[serde(default)] + access_token: Option, +} + +/// Deserialize workos_tokens which may be either: +/// - A JSON object (WorkosTokens directly) +/// - A double-encoded JSON string containing WorkosTokens +fn deserialize_double_encoded_workos_tokens<'de, D>( + deserializer: D, +) -> std::result::Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + use serde::de::Error; + + // First, try to deserialize as an untagged enum that accepts either + #[derive(Deserialize)] + #[serde(untagged)] + enum StringOrObject { + String(String), + Object(WorkosTokens), + } + + match Option::::deserialize(deserializer)? { + None => Ok(None), + Some(StringOrObject::Object(tokens)) => Ok(Some(tokens)), + Some(StringOrObject::String(s)) => { + // Double-encoded: parse the string as JSON + serde_json::from_str(&s).map(Some).map_err(D::Error::custom) + } + } +} + +/// Get the authentication token from Granola's local config. +/// +/// Prefers the encrypted `supabase.json.enc` store used by recent Granola +/// versions, falling back to the legacy plaintext `supabase.json`. Reached +/// only when grans has no credentials of its own. +pub fn get_auth_token() -> Result { + let dir = find_granola_dir()?; + let json = read_token_json(&dir)?; + extract_access_token(&json, &dir) +} + +/// Read the raw token JSON from a Granola config directory, preferring the +/// encrypted store and falling back to the legacy plaintext file. +fn read_token_json(dir: &Path) -> Result { + let encrypted = dir.join("supabase.json.enc"); + if encrypted.exists() { + debug!("Reading encrypted token store at {}", encrypted.display()); + return token_store::decrypt_token_json(dir).with_context(|| { + format!("Failed to read encrypted Granola token store in {}", dir.display()) + }); + } + + let plaintext = dir.join("supabase.json"); + debug!("Reading plaintext token store at {}", plaintext.display()); + std::fs::read_to_string(&plaintext) + .with_context(|| format!("Failed to read {}", plaintext.display())) +} + +/// Parse the token JSON and extract a non-empty access token. +fn extract_access_token(json: &str, dir: &Path) -> Result { + let config: SupabaseConfig = serde_json::from_str(json) + .with_context(|| format!("Failed to parse Granola token JSON from {}", dir.display()))?; + + let token = config + .workos_tokens + .and_then(|t| t.access_token) + .ok_or_else(|| anyhow::anyhow!( + "No access token found in Granola config at {}. Please ensure you are logged into Granola.", + dir.display() + ))?; + + if token.is_empty() { + bail!("Access token is empty in Granola config at {}. Please re-login to Granola.", dir.display()); + } + + debug!("Loaded auth token ({} chars)", token.len()); + Ok(token) +} + +/// Find the Granola config directory in platform-specific locations. A +/// directory qualifies if it contains either token store file. +fn find_granola_dir() -> Result { + let candidates = granola_dir_candidates(); + debug!("Searching for Granola config in {} locations", candidates.len()); + + for candidate in &candidates { + debug!(" checking: {}", candidate.display()); + if candidate.join("supabase.json.enc").exists() + || candidate.join("supabase.json").exists() + { + debug!(" found: {}", candidate.display()); + return Ok(candidate.clone()); + } + } + + bail!( + "Could not find Granola auth config. Searched:\n{}\n\n\ + Please ensure Granola is installed and you are logged in.", + candidates + .iter() + .map(|p| format!(" - {}", p.display())) + .collect::>() + .join("\n") + ) +} + +/// Detect if running on WSL by checking /proc/version for Microsoft/WSL markers +fn is_wsl() -> bool { + if cfg!(target_os = "linux") { + if let Ok(version) = std::fs::read_to_string("/proc/version") { + let version_lower = version.to_lowercase(); + return version_lower.contains("microsoft") || version_lower.contains("wsl"); + } + } + false +} + +/// Get Windows username from WSL environment +fn wsl_windows_username() -> Option { + // Try to get Windows username via cmd.exe + if let Ok(output) = std::process::Command::new("cmd.exe") + .args(["/c", "echo %USERNAME%"]) + .output() + { + if let Ok(username) = String::from_utf8(output.stdout) { + let username = username.trim(); + if !username.is_empty() && username != "%USERNAME%" { + return Some(username.to_string()); + } + } + } + + // Fallback to WSL username + if let Ok(user) = env::var("USER") { + return Some(user); + } + + None +} + +/// Get Windows-side Granola config directory candidates when running on WSL +fn wsl_windows_granola_dirs() -> Option> { + let username = wsl_windows_username()?; + + Some(vec![ + // Windows AppData Roaming path via WSL mount + PathBuf::from(format!("/mnt/c/Users/{}/AppData/Roaming/Granola", username)), + // Also check Local AppData as a fallback + PathBuf::from(format!("/mnt/c/Users/{}/AppData/Local/Granola", username)), + ]) +} + +fn granola_dir_candidates() -> Vec { + let mut candidates = Vec::new(); + + // WSL: Check Windows paths first (higher priority) + if is_wsl() { + if let Some(windows_paths) = wsl_windows_granola_dirs() { + candidates.extend(windows_paths); + } + } + + if let Some(home) = dirs_home() { + // macOS + candidates.push(home.join("Library/Application Support/Granola")); + + // Linux / WSL fallback + candidates.push(home.join(".config/Granola")); + + // XDG + if let Ok(xdg) = env::var("XDG_CONFIG_HOME") { + candidates.push(PathBuf::from(xdg).join("Granola")); + } + } + + // Windows (native) + if let Ok(appdata) = env::var("APPDATA") { + candidates.push(PathBuf::from(appdata).join("Granola")); + } + + candidates +} + +fn dirs_home() -> Option { + env::var("HOME") + .ok() + .map(PathBuf::from) + .or_else(|| env::var("USERPROFILE").ok().map(PathBuf::from)) +} +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_parse_supabase_config_with_token() { + let json = r#"{ + "workos_tokens": { + "access_token": "test-token-123" + } + }"#; + + let config: SupabaseConfig = serde_json::from_str(json).unwrap(); + assert_eq!( + config.workos_tokens.unwrap().access_token, + Some("test-token-123".to_string()) + ); + } + + #[test] + fn test_parse_supabase_config_empty() { + let json = r#"{}"#; + let config: SupabaseConfig = serde_json::from_str(json).unwrap(); + assert!(config.workos_tokens.is_none()); + } + + #[test] + fn test_parse_supabase_config_no_token() { + let json = r#"{"workos_tokens": {}}"#; + let config: SupabaseConfig = serde_json::from_str(json).unwrap(); + assert!(config.workos_tokens.unwrap().access_token.is_none()); + } + + #[test] + fn test_parse_supabase_config_double_encoded() { + // workos_tokens is a JSON string containing JSON (double-encoded) + let json = r#"{"workos_tokens": "{\"access_token\":\"double-encoded-token\",\"expires_in\":21599}"}"#; + let config: SupabaseConfig = serde_json::from_str(json).unwrap(); + assert_eq!( + config.workos_tokens.unwrap().access_token, + Some("double-encoded-token".to_string()) + ); + } + + #[test] + fn test_get_auth_token_from_file() { + let dir = TempDir::new().unwrap(); + let config_path = dir.path().join("supabase.json"); + + std::fs::write(&config_path, r#"{"workos_tokens": {"access_token": "my-secret-token"}}"#).unwrap(); + + // We can't easily test get_auth_token() directly since it uses platform paths, + // but we can test the parsing logic + let content = std::fs::read_to_string(&config_path).unwrap(); + let config: SupabaseConfig = serde_json::from_str(&content).unwrap(); + let token = config.workos_tokens.unwrap().access_token.unwrap(); + assert_eq!(token, "my-secret-token"); + } + + #[test] + fn test_is_wsl() { + // We can't guarantee the test environment, but we can verify + // the function doesn't panic + let _ = is_wsl(); + } + + #[test] + fn test_wsl_windows_granola_dirs() { + // Test that the function returns paths in the expected format + // Even if we can't determine the username, it should not panic + if let Some(candidates) = wsl_windows_granola_dirs() { + for path in &candidates { + let path_str = path.to_string_lossy(); + assert!( + path_str.contains("/mnt/c/Users/") && path_str.ends_with("Granola") + ); + } + } + } + + #[test] + fn test_granola_dir_candidates_no_panic() { + // Ensure granola_dir_candidates doesn't panic + let candidates = granola_dir_candidates(); + // Should return at least some candidates + assert!(!candidates.is_empty() || cfg!(target_os = "unknown")); + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs index ba380b0..57a5b09 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -3,6 +3,7 @@ pub mod client; pub mod credentials; pub mod granola_auth; pub mod identity; +pub mod local_store; mod token_store; pub mod types; From 8f67e5576e02f20f4c636be396d5e045cee3b05c Mon Sep 17 00:00:00 2001 From: Dustin Wyatt Date: Sat, 25 Jul 2026 20:54:09 -0500 Subject: [PATCH 09/14] #89: Accept the URL the login actually lands on The callback was assumed to be granola://login-complete?code=..., taken from issue #89's reading of the desktop client. The live flow ends somewhere else first: a https://www.granola.ai/app-redirect?code=... page that then hands off to the deep link. That page is what the browser shows and what the address bar offers to copy, and grans rejected it. Both URLs carry the same code, so parse_callback_code now takes the code parameter from any URL instead of enforcing a scheme. The likeliest mistake left is pasting the auth URL grans printed, which has a code_challenge but no code, so that error names the URL to copy instead. Instructions in the command, --help and the README described the deep link too, and now describe the address bar. Claude-Session: https://claude.ai/code/session_012c8WU6fhQDaVekXJX5hGhH --- README.md | 15 ++++++---- src/api/granola_auth.rs | 63 +++++++++++++++++++++++++++-------------- src/cli/args/mod.rs | 7 ++--- src/commands/auth.rs | 13 +++++---- 4 files changed, 63 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 6be96f5..64c91bd 100644 --- a/README.md +++ b/README.md @@ -140,11 +140,16 @@ grans auth status # Show whether grans has a session, and its expiry grans auth logout # Remove the stored credentials ``` -`grans auth login` opens your browser to Granola's login. When it finishes, -the browser offers to open the Granola app. **Cancel that dialog** and copy the -`granola://login-complete?...` URL instead, then paste it back into grans. The -authorization code is tied to grans's login attempt, so letting Granola open -the link consumes the code and the sign-in fails. +`grans auth login` opens your browser to Granola's login. When it finishes, you +land on a `granola.ai` page that offers to open the Granola app. **Cancel that +dialog**, then copy the URL from your address bar and paste it back into grans: + +``` +https://www.granola.ai/app-redirect?code=... +``` + +The authorization code is tied to grans's login attempt, so letting Granola +open the link hands the code to an app that cannot complete the sign-in. This creates a session on your Granola account, separate from the desktop app's. It appears in Granola's session list and you can revoke it there. diff --git a/src/api/granola_auth.rs b/src/api/granola_auth.rs index fdc9111..d8bcb12 100644 --- a/src/api/granola_auth.rs +++ b/src/api/granola_auth.rs @@ -4,10 +4,11 @@ //! session rather than scavenging the one Granola stored locally. See //! [`crate::api::credentials`] for why that scavenging no longer works. //! -//! The callback lands on `granola://login-complete?code=...`, which macOS and -//! Windows both route to Granola.app rather than to grans, so the code is -//! pasted back by hand instead of caught on a loopback listener. Granola's -//! `/v1/auth` rejects a loopback `redirect` parameter with a 403. +//! The login ends on a `granola.ai/app-redirect?code=...` page that hands off +//! to `granola://login-complete?code=...`, which the operating system routes to +//! Granola.app rather than to grans. So the code is pasted back by hand instead +//! of caught on a loopback listener: Granola's `/v1/auth` rejects a loopback +//! `redirect` parameter with a 403. use std::time::Duration; @@ -23,8 +24,9 @@ const AUTH_URL: &str = "https://api.granola.ai/v1/auth"; const AUTH_COMPLETE_URL: &str = "https://api.granola.ai/v1/workos-auth-complete"; const REFRESH_URL: &str = "https://api.granola.ai/v1/refresh-access-token"; -/// Scheme Granola registers for its login callback. -const CALLBACK_SCHEME: &str = "granola"; +/// Page the browser lands on when the login succeeds. Shown in error messages +/// so the user knows which URL to copy. +const REDIRECT_PAGE: &str = "https://www.granola.ai/app-redirect"; /// Entropy for the PKCE verifier: 32 bytes is the 43-character verifier /// Granola's own client sends. @@ -99,8 +101,10 @@ pub fn build_auth_url(challenge: &str, provider: Provider) -> (String, String) { /// Extract the authorization code from what the user pasted back. /// -/// Accepts the whole `granola://login-complete?code=...` callback URL, or a -/// bare code for anyone who copied only that. +/// The login hands the same code to two URLs: the `app-redirect` page the +/// browser shows, and the `granola://login-complete` deep link it triggers. +/// Either is a valid thing to copy, as is a bare code, so this takes the +/// `code` parameter from any URL rather than insisting on one scheme. pub fn parse_callback_code(pasted: &str) -> Result { let pasted = pasted.trim(); if pasted.is_empty() { @@ -115,19 +119,19 @@ pub fn parse_callback_code(pasted: &str) -> Result { } let url = Url::parse(pasted).context("Could not parse the pasted callback URL")?; - if url.scheme() != CALLBACK_SCHEME { - bail!( - "Expected a {}:// callback URL, got a {}:// URL", - CALLBACK_SCHEME, - url.scheme() - ); - } url.query_pairs() .find(|(key, _)| key == "code") .map(|(_, value)| value.into_owned()) .filter(|code| !code.is_empty()) - .ok_or_else(|| anyhow!("The callback URL has no 'code' parameter")) + .ok_or_else(|| { + anyhow!( + "That URL has no 'code' parameter.\n\ + Copy the URL your browser lands on after signing in; it looks like\n\ + {}?code=...", + REDIRECT_PAGE + ) + }) } /// Check that Granola will accept our reported client version before sending @@ -291,8 +295,21 @@ mod tests { assert!(url.contains("provider=microsoft")); } + #[test] + fn test_parse_app_redirect_url() { + // What the browser actually lands on, and what the address bar shows. + let pasted = "https://www.granola.ai/app-redirect?code=01KYE1X5RCRJE7VSWQ452PCK3C\ + &isDev=false&platform=windows&sso=false"; + + assert_eq!( + parse_callback_code(pasted).unwrap(), + "01KYE1X5RCRJE7VSWQ452PCK3C" + ); + } + #[test] fn test_parse_callback_url() { + // The deep link that page hands off to, for anyone who copies it. let code = parse_callback_code("granola://login-complete?code=abc123&sso=false").unwrap(); assert_eq!(code, "abc123"); @@ -332,12 +349,16 @@ mod tests { } #[test] - fn test_parse_rejects_wrong_scheme() { - // A user who pasted the WorkOS URL from the address bar instead of the - // callback should be told what went wrong. - let err = parse_callback_code("https://api.workos.com/authorize?code=abc").unwrap_err(); + fn test_parse_rejects_the_starting_auth_url() { + // Pasting the URL grans printed, rather than where it ends up, is the + // likeliest mistake. It carries a code_challenge but no code. + let err = parse_callback_code( + "https://api.granola.ai/v1/auth?dev=false&code_challenge=abc&provider=google", + ) + .unwrap_err(); - assert!(err.to_string().contains("granola:// callback URL")); + assert!(err.to_string().contains("no 'code' parameter")); + assert!(err.to_string().contains("app-redirect")); } #[test] diff --git a/src/cli/args/mod.rs b/src/cli/args/mod.rs index 22d0c6f..d7fc204 100644 --- a/src/cli/args/mod.rs +++ b/src/cli/args/mod.rs @@ -642,10 +642,9 @@ pub enum DbAction { pub enum AuthAction { /// Sign in to Granola and store credentials for grans /// - /// Opens a browser to Granola's login. The callback is a granola:// URL - /// that your operating system routes to the Granola app rather than to - /// grans, so cancel the dialog offering to open Granola and paste the URL - /// back here instead. + /// Opens a browser to Granola's login. It ends on a granola.ai page that + /// tries to hand off to the Granola app rather than to grans, so cancel + /// that dialog and paste the address bar URL back here instead. Login { /// Identity provider to sign in with #[arg(long, value_enum, default_value_t = AuthProvider::Google)] diff --git a/src/commands/auth.rs b/src/commands/auth.rs index 981ebd5..0ec2a43 100644 --- a/src/commands/auth.rs +++ b/src/commands/auth.rs @@ -127,12 +127,15 @@ fn credentials_from_browser_login(provider: Provider) -> Result Date: Sat, 25 Jul 2026 20:59:56 -0500 Subject: [PATCH 10/14] #89: Find the tokens in the exchange response, and say so when they are absent The code exchange returns 200 with a body that has no workos_tokens key and no top-level access_token, so both paths assumed from issue #89 missed. The failure said only "did not match the expected token shape", which is the least useful thing it could have said. Now the extraction looks for the object carrying an access_token/refresh_token pair, searching WorkOS-named keys first: a response can carry the identity provider's tokens alongside Granola's, and serde_json orders keys alphabetically, so "google_tokens" would otherwise win. Depth is bounded so a hostile or deep response cannot recurse away. When no pair is found, the error prints the response's shape: keys and value types, never values, since these bodies carry token material. The exchange also now echoes the callback's own platform, isDev, sso and signInClickId rather than grans's guesses at them. The callback restates the terms the login ran under, and the server's own values beat ours. Claude-Session: https://claude.ai/code/session_012c8WU6fhQDaVekXJX5hGhH --- src/api/granola_auth.rs | 278 ++++++++++++++++++++++++++++++++-------- src/commands/auth.rs | 4 +- 2 files changed, 229 insertions(+), 53 deletions(-) diff --git a/src/api/granola_auth.rs b/src/api/granola_auth.rs index d8bcb12..cb1d07d 100644 --- a/src/api/granola_auth.rs +++ b/src/api/granola_auth.rs @@ -99,13 +99,27 @@ pub fn build_auth_url(challenge: &str, provider: Provider) -> (String, String) { (url.into(), sign_in_click_id) } -/// Extract the authorization code from what the user pasted back. +/// What the login callback hands back. +/// +/// Beyond the code, the callback restates the terms the login ran under. +/// Echoing those back at the exchange keeps grans consistent with what +/// Granola's own client sends, rather than with what grans guessed. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Callback { + pub code: String, + pub platform: Option, + pub is_dev: Option, + pub sso: Option, + pub sign_in_click_id: Option, +} + +/// Read the callback the user pasted back. /// /// The login hands the same code to two URLs: the `app-redirect` page the /// browser shows, and the `granola://login-complete` deep link it triggers. /// Either is a valid thing to copy, as is a bare code, so this takes the -/// `code` parameter from any URL rather than insisting on one scheme. -pub fn parse_callback_code(pasted: &str) -> Result { +/// parameters from any URL rather than insisting on one scheme. +pub fn parse_callback(pasted: &str) -> Result { let pasted = pasted.trim(); if pasted.is_empty() { bail!("No authorization code provided"); @@ -115,23 +129,36 @@ pub fn parse_callback_code(pasted: &str) -> Result { if pasted.split_whitespace().count() > 1 { bail!("Expected a single authorization code or a callback URL, got text with spaces"); } - return Ok(pasted.to_string()); + return Ok(Callback { + code: pasted.to_string(), + ..Callback::default() + }); } let url = Url::parse(pasted).context("Could not parse the pasted callback URL")?; + let parameter = |name: &str| { + url.query_pairs() + .find(|(key, _)| key == name) + .map(|(_, value)| value.into_owned()) + .filter(|value| !value.is_empty()) + }; - url.query_pairs() - .find(|(key, _)| key == "code") - .map(|(_, value)| value.into_owned()) - .filter(|code| !code.is_empty()) - .ok_or_else(|| { - anyhow!( - "That URL has no 'code' parameter.\n\ - Copy the URL your browser lands on after signing in; it looks like\n\ - {}?code=...", - REDIRECT_PAGE - ) - }) + let code = parameter("code").ok_or_else(|| { + anyhow!( + "That URL has no 'code' parameter.\n\ + Copy the URL your browser lands on after signing in; it looks like\n\ + {}?code=...", + REDIRECT_PAGE + ) + })?; + + Ok(Callback { + code, + platform: parameter("platform"), + is_dev: parameter("isDev").and_then(|value| value.parse().ok()), + sso: parameter("sso").and_then(|value| value.parse().ok()), + sign_in_click_id: parameter("signInClickId"), + }) } /// Check that Granola will accept our reported client version before sending @@ -176,13 +203,20 @@ pub fn check_client_version_accepted(auth_url: &str) -> Result<()> { /// Exchange an authorization code for tokens. /// /// Unauthenticated, which is what makes bootstrapping a session possible. -pub fn exchange_code(code: &str, verifier: &str, sign_in_click_id: &str) -> Result { +/// `started_with` is the click id grans sent when it built the auth URL, used +/// only when the callback did not restate it. +pub fn exchange_code( + callback: &Callback, + verifier: &str, + started_with: &str, +) -> Result { let body = serde_json::json!({ - "code": code, + "code": callback.code, "codeVerifier": verifier, - "platform": identity::platform(), - "isDev": false, - "signInClickId": sign_in_click_id, + "platform": callback.platform.clone().unwrap_or_else(|| identity::platform().to_string()), + "isDev": callback.is_dev.unwrap_or(false), + "sso": callback.sso.unwrap_or(false), + "signInClickId": callback.sign_in_click_id.as_deref().unwrap_or(started_with), }); let response = post_json(AUTH_COMPLETE_URL, &body, None) @@ -242,23 +276,86 @@ fn post_json( Ok(text) } +/// How deep to look for the token object, and to describe a response. +const MAX_JSON_DEPTH: usize = 5; + /// Pull the token set out of an auth response. /// -/// Granola nests these under `workos_tokens`, which it sometimes writes as a -/// JSON-encoded string rather than an object, and some responses carry the -/// fields at the top level instead. +/// Granola's endpoints do not agree on where the tokens sit: some nest them +/// under `workos_tokens`, which is sometimes JSON-encoded text rather than an +/// object, and some return them at the top level. Rather than encode one path +/// per endpoint, find the object carrying the pair. fn extract_token_set(body: &str) -> Result { let value: serde_json::Value = serde_json::from_str(body).context("Response was not valid JSON")?; - let tokens = match value.get("workos_tokens") { - Some(serde_json::Value::String(encoded)) => serde_json::from_str(encoded) - .context("workos_tokens was a string but not valid JSON")?, - Some(nested) => nested.clone(), - None => value, - }; + let tokens = locate_tokens(&value, MAX_JSON_DEPTH).ok_or_else(|| { + anyhow!( + "No access_token/refresh_token pair in the response. Its shape was:\n{}", + describe_json_shape(&value, MAX_JSON_DEPTH) + ) + })?; + + serde_json::from_value(tokens.clone()).with_context(|| { + format!( + "Token object did not match the expected fields. Its shape was:\n{}", + describe_json_shape(&tokens, MAX_JSON_DEPTH) + ) + }) +} + +/// Find the object holding both tokens, descending into JSON-encoded strings. +/// +/// Keys naming WorkOS are searched first: a response may carry the identity +/// provider's tokens alongside Granola's, and those are not interchangeable. +fn locate_tokens(value: &serde_json::Value, depth: usize) -> Option { + if let Some(decoded) = decode_json_string(value) { + return locate_tokens(&decoded, depth.checked_sub(1)?); + } + + let object = value.as_object()?; + if object.contains_key("access_token") && object.contains_key("refresh_token") { + return Some(value.clone()); + } + + let depth = depth.checked_sub(1)?; + let mut keys: Vec<&String> = object.keys().collect(); + keys.sort_by_key(|key| !key.to_lowercase().contains("workos")); + + keys.into_iter() + .find_map(|key| locate_tokens(&object[key], depth)) +} - serde_json::from_value(tokens).context("Response did not match the expected token shape") +/// Parse a string value that itself contains JSON, as Granola writes +/// `workos_tokens` in its own token store. +fn decode_json_string(value: &serde_json::Value) -> Option { + serde_json::from_str(value.as_str()?).ok() +} + +/// Render a value's keys and types, never its values. +/// +/// Auth responses carry token material, so a shape is the most that can be +/// safely put in an error message. +fn describe_json_shape(value: &serde_json::Value, depth: usize) -> String { + match value { + serde_json::Value::Object(map) if depth == 0 => format!("{{ {} keys }}", map.len()), + serde_json::Value::Object(map) => { + let fields: Vec = map + .iter() + .map(|(key, nested)| format!("{}: {}", key, describe_json_shape(nested, depth - 1))) + .collect(); + format!("{{{}}}", fields.join(", ")) + } + serde_json::Value::Array(items) => match items.first() { + Some(_) if depth == 0 => format!("[{} items]", items.len()), + Some(first) => format!("[{}; {}]", describe_json_shape(first, depth - 1), items.len()), + None => "[]".to_string(), + }, + serde_json::Value::String(_) => "string".to_string(), + serde_json::Value::Number(_) => "number".to_string(), + serde_json::Value::Bool(_) => "bool".to_string(), + serde_json::Value::Null => "null".to_string(), + } } #[cfg(test)] @@ -298,52 +395,70 @@ mod tests { #[test] fn test_parse_app_redirect_url() { // What the browser actually lands on, and what the address bar shows. - let pasted = "https://www.granola.ai/app-redirect?code=01KYE1X5RCRJE7VSWQ452PCK3C\ - &isDev=false&platform=windows&sso=false"; - - assert_eq!( - parse_callback_code(pasted).unwrap(), - "01KYE1X5RCRJE7VSWQ452PCK3C" + let pasted = concat!( + "https://www.granola.ai/app-redirect?code=01KYE1X5RCRJE7VSWQ452PCK3C", + "&isDev=false&platform=windows&sso=false" ); + + let callback = parse_callback(pasted).unwrap(); + + assert_eq!(callback.code, "01KYE1X5RCRJE7VSWQ452PCK3C"); + assert_eq!(callback.platform, Some("windows".to_string())); + assert_eq!(callback.is_dev, Some(false)); + assert_eq!(callback.sso, Some(false)); } #[test] fn test_parse_callback_url() { // The deep link that page hands off to, for anyone who copies it. - let code = parse_callback_code("granola://login-complete?code=abc123&sso=false").unwrap(); + let callback = parse_callback("granola://login-complete?code=abc123&sso=false").unwrap(); - assert_eq!(code, "abc123"); + assert_eq!(callback.code, "abc123"); } #[test] fn test_parse_callback_url_percent_decodes() { - let code = parse_callback_code("granola://login-complete?code=a%2Fb%2Bc").unwrap(); + let callback = parse_callback("granola://login-complete?code=a%2Fb%2Bc").unwrap(); - assert_eq!(code, "a/b+c"); + assert_eq!(callback.code, "a/b+c"); } #[test] - fn test_parse_callback_url_ignores_other_parameters() { - let code = - parse_callback_code("granola://login-complete?signInClickId=x&code=zzz&handoff=1") + fn test_parse_callback_keeps_the_click_id_it_was_given() { + let callback = + parse_callback("granola://login-complete?signInClickId=click-7&code=zzz&handoff=1") .unwrap(); - assert_eq!(code, "zzz"); + assert_eq!(callback.code, "zzz"); + assert_eq!(callback.sign_in_click_id, Some("click-7".to_string())); + } + + #[test] + fn test_parse_bare_code_leaves_terms_unstated() { + let callback = parse_callback(" raw-code-42 ").unwrap(); + + assert_eq!(callback.code, "raw-code-42"); + assert_eq!(callback.platform, None); + assert_eq!(callback.sign_in_click_id, None); } #[test] - fn test_parse_bare_code() { - assert_eq!(parse_callback_code(" raw-code-42 ").unwrap(), "raw-code-42"); + fn test_parse_ignores_unparseable_flags() { + // A malformed boolean should not fail the login; the exchange falls + // back to grans's own value. + let callback = parse_callback("granola://login-complete?code=c&sso=maybe").unwrap(); + + assert_eq!(callback.sso, None); } #[test] fn test_parse_rejects_empty() { - assert!(parse_callback_code(" ").is_err()); + assert!(parse_callback(" ").is_err()); } #[test] fn test_parse_rejects_url_without_code() { - let err = parse_callback_code("granola://login-complete?sso=false").unwrap_err(); + let err = parse_callback("granola://login-complete?sso=false").unwrap_err(); assert!(err.to_string().contains("no 'code' parameter")); } @@ -352,7 +467,7 @@ mod tests { fn test_parse_rejects_the_starting_auth_url() { // Pasting the URL grans printed, rather than where it ends up, is the // likeliest mistake. It carries a code_challenge but no code. - let err = parse_callback_code( + let err = parse_callback( "https://api.granola.ai/v1/auth?dev=false&code_challenge=abc&provider=google", ) .unwrap_err(); @@ -363,7 +478,7 @@ mod tests { #[test] fn test_parse_rejects_prose() { - assert!(parse_callback_code("I could not find the code").is_err()); + assert!(parse_callback("I could not find the code").is_err()); } #[test] @@ -413,6 +528,67 @@ mod tests { assert!(extract_token_set(body).is_err()); } + #[test] + fn test_extract_token_set_from_an_unexpected_key() { + let body = r#"{"user": {"id": "u1"}, + "session": {"tokens": {"access_token": "at", "refresh_token": "rt"}}}"#; + + let tokens = extract_token_set(body).unwrap(); + + assert_eq!(tokens.access_token, "at"); + } + + #[test] + fn test_extract_token_set_prefers_workos_over_provider_tokens() { + // Google's tokens can ride along in the same response and are not + // interchangeable with Granola's. Alphabetically google sorts first, + // so this would pick the wrong pair without the preference. + let body = r#"{ + "google_tokens": {"access_token": "google-at", "refresh_token": "google-rt"}, + "workos_tokens": {"access_token": "workos-at", "refresh_token": "workos-rt"} + }"#; + + let tokens = extract_token_set(body).unwrap(); + + assert_eq!(tokens.access_token, "workos-at"); + } + + #[test] + fn test_missing_tokens_error_describes_shape_without_values() { + let body = r#"{"user": {"email": "someone@example.com"}, "count": 2}"#; + + let message = extract_token_set(body).unwrap_err().to_string(); + + assert!(message.contains("user: {email: string}")); + assert!(message.contains("count: number")); + assert!(!message.contains("someone@example.com")); + } + + #[test] + fn test_describe_json_shape_stops_at_max_depth() { + let value: serde_json::Value = + serde_json::from_str(r#"{"a": {"b": {"c": "deep"}}}"#).unwrap(); + + let described = describe_json_shape(&value, 1); + + assert!(described.contains("a: { 1 keys }")); + assert!(!described.contains("deep")); + } + + #[test] + fn test_locate_tokens_gives_up_rather_than_recursing_forever() { + // Deeply nested junk must not blow the stack looking for tokens. + let mut body = String::new(); + for _ in 0..50 { + body.push_str(r#"{"nested": "#); + } + body.push_str("null"); + body.push_str(&"}".repeat(50)); + let value: serde_json::Value = serde_json::from_str(&body).unwrap(); + + assert!(locate_tokens(&value, MAX_JSON_DEPTH).is_none()); + } + #[test] fn test_into_credentials_dates_expiry_from_now() { let tokens = TokenSet { diff --git a/src/commands/auth.rs b/src/commands/auth.rs index 0ec2a43..bd0d254 100644 --- a/src/commands/auth.rs +++ b/src/commands/auth.rs @@ -116,10 +116,10 @@ fn credentials_from_browser_login(provider: Provider) -> Result Date: Sat, 25 Jul 2026 21:05:07 -0500 Subject: [PATCH 11/14] #89: Correct the comments claiming Granola rotates refresh tokens Issue #89 warned that refresh-access-token returns a rotated refresh token which must be persisted or the chain breaks, and the comments stated that as fact. A live refresh returns the same refresh token with a new access token and expiry. The code already persisted whatever the response carried, so nothing changes behaviorally, and the concurrent-rotation handling stays: it costs one file read on a path that already failed, and covers Granola turning rotation on later. Only the comments were wrong. Claude-Session: https://claude.ai/code/session_012c8WU6fhQDaVekXJX5hGhH --- src/api/auth.rs | 15 ++++++++------- src/api/credentials.rs | 2 +- src/api/granola_auth.rs | 9 ++++++--- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/api/auth.rs b/src/api/auth.rs index 6a29c82..250cf63 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -100,10 +100,11 @@ fn refresh_and_persist( Err(error) => error, }; - // Granola rotates the refresh token on every call, so another grans - // invocation refreshing at the same moment consumes the one we just sent. - // If what is on disk has moved on, that invocation succeeded and its - // result is usable; only a chain that has genuinely stalled is an error. + // Granola currently returns the refresh token unchanged, but if it ever + // rotates, a concurrent grans invocation refreshing at the same moment + // would consume the token we just sent. If what is on disk has moved on, + // that invocation succeeded and its result is usable; only a chain that + // has genuinely stalled is an error. if let Some(current) = GranolaCredentials::load_from(path)? { if current.refresh_token != credentials.refresh_token { debug!("Refresh token was rotated concurrently; using the newer credentials"); @@ -129,9 +130,9 @@ fn refresh_once( let tokens = refresh(credentials)?; let access_token = tokens.access_token.clone(); - // Persist before returning: Granola has already retired the old refresh - // token, so handing back an access token without saving the rotation - // would strand the chain if the process died here. + // Persist before returning. The refresh token comes back unchanged today, + // but if Granola starts rotating it, handing back an access token without + // saving what replaced it would strand the chain if the process died here. tokens .into_credentials(now, credentials.session_id.clone()) .save_to(path)?; diff --git a/src/api/credentials.rs b/src/api/credentials.rs index af3f0ef..8dbdc33 100644 --- a/src/api/credentials.rs +++ b/src/api/credentials.rs @@ -25,7 +25,7 @@ const EXPIRY_SKEW_SECS: i64 = 60; /// access token is a cache that may be absent or stale. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] pub struct GranolaCredentials { - /// Long-lived refresh token. Granola rotates this on every refresh. + /// Long-lived refresh token, rewritten from every refresh response. pub refresh_token: String, /// Most recently issued access token, if one has been fetched. diff --git a/src/api/granola_auth.rs b/src/api/granola_auth.rs index cb1d07d..84befa4 100644 --- a/src/api/granola_auth.rs +++ b/src/api/granola_auth.rs @@ -229,9 +229,12 @@ pub fn exchange_code( /// Exchange a refresh token for a new token set. /// -/// Granola rotates the refresh token on every call, so the response must be -/// persisted or the chain breaks. The access token identifies the caller and -/// may be expired; the refresh token in the body is the grant being validated. +/// The response carries a refresh token, which Granola has been observed +/// returning unchanged rather than rotating. Callers persist whatever comes +/// back regardless, so a future rotation cannot strand the chain. +/// +/// The access token identifies the caller and may be expired; the refresh +/// token in the body is the grant being validated. pub fn refresh_tokens(access_token: Option<&str>, refresh_token: &str) -> Result { let body = serde_json::json!({ "refresh_token": refresh_token }); From 1b20f486ca62de29a51bd9a79c68dfad5020ad27 Mon Sep 17 00:00:00 2001 From: Dustin Wyatt Date: Sat, 25 Jul 2026 21:06:07 -0500 Subject: [PATCH 12/14] #89: Describe the api/ auth modules in CLAUDE.md The architecture map still said auth.rs reads the token from Granola's supabase.json, which is now four modules with a resolution order between them. Claude-Session: https://claude.ai/code/session_012c8WU6fhQDaVekXJX5hGhH --- CLAUDE.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index fdc3c66..fa4f7ba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,7 +58,11 @@ CLI (main.rs, cli/) → Commands (commands/) → DB queries (db/) → SQLite - **cli/**: Clap derive definitions and `RunContext` (output mode) - **commands/**: Dispatch to db/ queries or api/ calls, select output formatter - **api/**: Granola API client and authentication - - `auth.rs`: Reads auth token from Granola's `supabase.json` config + - `auth.rs`: Token resolution order (`--token`/`GRANS_TOKEN`, stored credentials, local store) + - `credentials.rs`: grans's own Granola session, stored in `auth.toml` + - `granola_auth.rs`: Granola PKCE login and token refresh + - `local_store.rs`: Reads the token Granola's desktop app stored (legacy fallback) + - `identity.rs`: Client version/platform reported to Granola (`GRANS_GRANOLA_VERSION`) - `client.rs`: HTTP client for Granola API endpoints - `types.rs`: API request/response wrappers (domain types live in `models.rs`) - **db/**: SQLite queries, FTS5 search, upsert logic From 34b57afd7700cd83d1764bdbd7fab07c6286a231 Mon Sep 17 00:00:00 2001 From: Dustin Wyatt Date: Sat, 25 Jul 2026 21:15:24 -0500 Subject: [PATCH 13/14] #89: Create the credential file private, rather than tightening it after save_to wrote the refresh token with fs::write, which creates the file at the umask default, then chmod'd it to 0600. Between those two calls the token was readable by anyone who could traverse the data directory, which is not itself 0700. The permissions now come from the open. Any leftover temp file is removed first, since the mode applies only when the open creates the file and reusing one would silently keep its old permissions, and create_new then refuses to write through anything that appears in between. Windows is unchanged and still stores the token unprotected: the file inherits the directory ACL. Moving it into the platform keychain is worth a follow-up. Found by the security review on #90. Claude-Session: https://claude.ai/code/session_012c8WU6fhQDaVekXJX5hGhH --- src/api/credentials.rs | 81 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 71 insertions(+), 10 deletions(-) diff --git a/src/api/credentials.rs b/src/api/credentials.rs index 8dbdc33..84499ce 100644 --- a/src/api/credentials.rs +++ b/src/api/credentials.rs @@ -8,6 +8,7 @@ //! Persisted as TOML at `data_dir()/auth.toml`. use std::fs; +use std::io::Write; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; @@ -88,18 +89,14 @@ impl GranolaCredentials { let content = toml::to_string_pretty(self).context("Failed to serialize credentials")?; - // Write to a temp file, tighten permissions, then rename into place so - // a crash mid-write cannot leave a truncated credential file behind. + // Write to a temp file and rename into place, so a crash mid-write + // cannot leave a truncated credential file behind. let temp_path = path.with_extension("toml.tmp"); - fs::write(&temp_path, &content) + let mut file = create_private_file(&temp_path) + .with_context(|| format!("Failed to create {}", temp_path.display()))?; + file.write_all(content.as_bytes()) .with_context(|| format!("Failed to write {}", temp_path.display()))?; - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(&temp_path, fs::Permissions::from_mode(0o600)) - .with_context(|| format!("Failed to set permissions on {}", temp_path.display()))?; - } + drop(file); fs::rename(&temp_path, path) .with_context(|| format!("Failed to replace {}", path.display())) @@ -116,6 +113,36 @@ impl GranolaCredentials { } } +/// Create a file readable only by its owner. +/// +/// The permissions are set at creation rather than tightened afterward: +/// writing the refresh token first and calling `chmod` second leaves a window +/// where another local user can read it. On Windows the file inherits the +/// directory's ACL, which is why the refresh token is not protected there. +fn create_private_file(path: &Path) -> std::io::Result { + // Clear anything an interrupted run left behind: the mode below applies + // only when the open creates the file, so reusing one would silently keep + // whatever permissions it already had. + match fs::remove_file(path) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e), + } + + let mut options = fs::OpenOptions::new(); + // create_new refuses to write through a file, or symlink, that appeared + // between the remove and the open. + options.write(true).create_new(true); + + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + + options.open(path) +} + /// Remove the credential file at `path`. Succeeds if it is already gone. pub fn delete_from(path: &Path) -> Result<()> { match fs::remove_file(path) { @@ -291,4 +318,38 @@ mod tests { let mode = fs::metadata(&path).unwrap().permissions().mode(); assert_eq!(mode & 0o777, 0o600); } + + #[cfg(unix)] + #[test] + fn test_private_file_is_owner_only_from_creation() { + // The permissions must come from the open, not from a later chmod: + // between the two, the refresh token would be readable by anyone who + // can traverse the directory. + use std::os::unix::fs::PermissionsExt; + + let dir = TempDir::new().unwrap(); + let path = dir.path().join("fresh.tmp"); + + let file = create_private_file(&path).unwrap(); + let mode = file.metadata().unwrap().permissions().mode(); + + assert_eq!(mode & 0o777, 0o600); + } + + #[cfg(unix)] + #[test] + fn test_private_file_tightens_permissions_on_reuse() { + // A temp file left behind by an earlier run with loose permissions + // must not be inherited as-is. + use std::os::unix::fs::PermissionsExt; + + let dir = TempDir::new().unwrap(); + let path = dir.path().join("stale.tmp"); + fs::write(&path, "old").unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap(); + + let file = create_private_file(&path).unwrap(); + + assert_eq!(file.metadata().unwrap().permissions().mode() & 0o777, 0o600); + } } From 0368b323953e9ce2405c1f019ca21b916cb7b436 Mon Sep 17 00:00:00 2001 From: Dustin Wyatt Date: Sat, 25 Jul 2026 21:33:36 -0500 Subject: [PATCH 14/14] #89: Keep the refresh token in the platform keychain This PR is what gives grans a long-lived secret. Before it, grans read an access token out of Granola's encrypted store on each run and kept nothing. Writing a refresh token to a plaintext file instead is a step down from what it replaced: a refresh token mints access tokens until the session is revoked, and unlike Granola's DPAPI-wrapped store, a copy of the file taken from a backup or disk image works anywhere. So the session now goes in the platform keychain: Windows Credential Manager, the macOS Keychain, or the Secret Service on Linux. Storage moves behind a CredentialStore with the file as a second backend, which also gives the refresh chain a seam its tests use instead of a path. The file is not gone, because a headless Linux box or a bare WSL install has no keychain and failing there would break setups that work today. That fallback is announced at login and in `grans auth status`, and says plainly what it does and does not protect. When a keychain does become available, the next command moves the credentials in and deletes the file. Keychain reachability is settled by reading, not by constructing an entry: a missing Secret Service or a locked keychain fails only on access. Verified on Windows: an existing auth.toml migrated into Credential Manager, the file was removed, the session survived, and sync continued to work. Claude-Session: https://claude.ai/code/session_012c8WU6fhQDaVekXJX5hGhH --- CLAUDE.md | 3 +- Cargo.lock | 657 ++++++++++++++++++++++++++++++++++-- Cargo.toml | 1 + README.md | 21 ++ src/api/auth.rs | 81 ++--- src/api/credential_store.rs | 392 +++++++++++++++++++++ src/api/credentials.rs | 237 +------------ src/api/mod.rs | 1 + src/commands/auth.rs | 45 ++- 9 files changed, 1134 insertions(+), 304 deletions(-) create mode 100644 src/api/credential_store.rs diff --git a/CLAUDE.md b/CLAUDE.md index fa4f7ba..dcbc58e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,7 +59,8 @@ CLI (main.rs, cli/) → Commands (commands/) → DB queries (db/) → SQLite - **commands/**: Dispatch to db/ queries or api/ calls, select output formatter - **api/**: Granola API client and authentication - `auth.rs`: Token resolution order (`--token`/`GRANS_TOKEN`, stored credentials, local store) - - `credentials.rs`: grans's own Granola session, stored in `auth.toml` + - `credentials.rs`: The `GranolaCredentials` type (refresh token, access token, expiry) + - `credential_store.rs`: Where they live: platform keychain, falling back to a `0600` `auth.toml` - `granola_auth.rs`: Granola PKCE login and token refresh - `local_store.rs`: Reads the token Granola's desktop app stored (legacy fallback) - `identity.rs`: Client version/platform reported to Granola (`GRANS_GRANOLA_VERSION`) diff --git a/Cargo.lock b/Cargo.lock index 173b5c7..6c0f392 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -137,6 +137,17 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "apple-native-keyring-store" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "797f94b6a53d7d10b56dc18290e0d40a2158352f108bb4ff32350825081a9f29" +dependencies = [ + "keyring-core", + "log", + "security-framework", +] + [[package]] name = "assert_cmd" version = "2.1.2" @@ -152,6 +163,30 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + [[package]] name = "async-compression" version = "0.4.37" @@ -164,6 +199,113 @@ dependencies = [ "tokio", ] +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -212,6 +354,19 @@ dependencies = [ "generic-array", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "bstr" version = "1.12.1" @@ -336,7 +491,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -393,6 +548,15 @@ version = "0.4.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "console" version = "0.15.11" @@ -506,7 +670,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.114", ] [[package]] @@ -517,7 +681,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -547,7 +711,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -557,7 +721,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn", + "syn 2.0.114", ] [[package]] @@ -627,7 +791,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -642,6 +806,33 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "env_filter" version = "0.1.4" @@ -685,6 +876,27 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6" +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -787,6 +999,19 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.31" @@ -795,7 +1020,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -902,6 +1127,7 @@ dependencies = [ "env_logger", "fastembed", "indicatif", + "keyring", "log", "open", "ort", @@ -954,6 +1180,18 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hf-hub" version = "0.4.3" @@ -974,6 +1212,15 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + [[package]] name = "hmac" version = "0.12.1" @@ -1321,6 +1568,27 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keyring" +version = "4.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0298a59b384c540e408a600c8b375a09b49c3f97debc080e2c30675d79a6368a" +dependencies = [ + "apple-native-keyring-store", + "keyring-core", + "windows-native-keyring-store", + "zbus-secret-service-keyring-store", +] + +[[package]] +name = "keyring-core" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb1e621458ca9c51aa110bd0339d4751a056b9576bf1253aee1aa560dda0fc9d" +dependencies = [ + "log", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1416,6 +1684,15 @@ version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -1462,7 +1739,7 @@ checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -1496,6 +1773,30 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" +[[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.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-complex" version = "0.4.6" @@ -1514,6 +1815,27 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "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" @@ -1586,6 +1908,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "ort" version = "2.0.0-rc.11" @@ -1610,6 +1942,12 @@ dependencies = [ "ureq 3.2.0", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "paste" version = "1.0.15" @@ -1650,12 +1988,37 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "polyval" version = "0.6.2" @@ -1731,6 +2094,15 @@ dependencies = [ "termtree", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.13+spec-1.1.0", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -2132,6 +2504,25 @@ dependencies = [ "serde_json", ] +[[package]] +name = "secret-service" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a62d7f86047af0077255a29494136b9aaaf697c76ff70b8e49cded4e2623c14" +dependencies = [ + "aes", + "cbc", + "futures-util", + "generic-array", + "getrandom 0.2.17", + "hkdf", + "num", + "once_cell", + "serde", + "sha2", + "zbus", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -2193,7 +2584,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -2209,6 +2600,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -2258,6 +2660,16 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.8" @@ -2365,6 +2777,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -2382,7 +2805,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -2430,7 +2853,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -2441,7 +2864,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -2547,8 +2970,8 @@ checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", "serde_spanned", - "toml_datetime", - "toml_edit", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", ] [[package]] @@ -2560,6 +2983,15 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.22.27" @@ -2569,9 +3001,30 @@ dependencies = [ "indexmap", "serde", "serde_spanned", - "toml_datetime", + "toml_datetime 0.6.11", "toml_write", - "winnow", + "winnow 0.7.14", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.4", ] [[package]] @@ -2637,9 +3090,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -2661,6 +3126,17 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unicode-ident" version = "1.0.22" @@ -2796,6 +3272,7 @@ checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" dependencies = [ "getrandom 0.4.3", "js-sys", + "serde_core", "wasm-bindgen", ] @@ -2899,7 +3376,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.114", "wasm-bindgen-shared", ] @@ -3006,7 +3483,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -3017,7 +3494,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -3026,6 +3503,19 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-native-keyring-store" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8" +dependencies = [ + "byteorder", + "keyring-core", + "regex", + "windows-sys 0.61.2", + "zeroize", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -3284,6 +3774,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -3315,10 +3814,82 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", "synstructure", ] +[[package]] +name = "zbus" +version = "5.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe18fb60dc696039e738717b76eaea21e7a4489bbb1885020b43c94236d7e98a" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus-secret-service-keyring-store" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ccede190ba363386a24e8021c7f3848393976609ec9f5d1f8c6c09ef37075b4" +dependencies = [ + "keyring-core", + "secret-service", + "zbus", +] + +[[package]] +name = "zbus_macros" +version = "5.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.114", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + [[package]] name = "zerocopy" version = "0.8.33" @@ -3336,7 +3907,7 @@ checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -3356,7 +3927,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", "synstructure", ] @@ -3396,7 +3967,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -3404,3 +3975,43 @@ name = "zmij" version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65" + +[[package]] +name = "zvariant" +version = "5.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.4", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.114", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.114", + "winnow 1.0.4", +] diff --git a/Cargo.toml b/Cargo.toml index 5c865fd..d8acec3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"] } cbc = "0.1" url = "2.5.8" uuid = { version = "1.24.0", features = ["v4"] } +keyring = { version = "4.1.5", features = ["apple-native-keyring-store"] } [dev-dependencies] assert_cmd = "2.1.2" diff --git a/README.md b/README.md index 64c91bd..7351963 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,27 @@ app's. It appears in Granola's session list and you can revoke it there. Once signed in, grans refreshes its own access token and does not need Granola running, or installed. +### Where credentials are stored + +grans stores its session in the platform keychain: Windows Credential Manager, +the macOS Keychain, or the Secret Service on Linux. `grans auth status` names +the one in use. + +The refresh token is the part worth protecting. It mints new access tokens +until the session is revoked, so unlike the six-hour access token it stays +valuable. + +Where no keychain is reachable (a headless Linux box, some WSL setups), grans +falls back to `data_dir()/auth.toml`, written `0600`, and warns you at login +and in `grans auth status`. That keeps other local users out, but the token is +unencrypted: anyone with a copy of that file, from a backup or a disk image, +holds a working session until you revoke it. If a keychain later becomes +available, the next grans command moves the credentials into it and deletes +the file. + +On macOS the keychain item is tied to the binary that created it, so a rebuilt +or self-updated grans may ask for keychain access again. + ### Reading Granola's local token Without `grans auth login`, grans falls back to the token Granola's desktop app diff --git a/src/api/auth.rs b/src/api/auth.rs index 250cf63..804f4b9 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -1,16 +1,15 @@ //! Deciding which token grans authenticates with. //! -//! The sources themselves live elsewhere: [`super::credentials`] holds grans's -//! own session and [`super::local_store`] reads the one Granola's desktop app -//! stored. This module is the order they are tried in. - -use std::path::Path; +//! The sources themselves live elsewhere: [`super::credential_store`] holds +//! grans's own session and [`super::local_store`] reads the one Granola's desktop +//! app stored. This module is the order they are tried in. use anyhow::{bail, Result}; use chrono::Utc; use log::debug; -use super::credentials::{self, GranolaCredentials}; +use super::credential_store::CredentialStore; +use super::credentials::GranolaCredentials; use super::{granola_auth, local_store}; /// Environment variable supplying a token when `--token` is absent. @@ -54,10 +53,12 @@ pub fn resolve_token(override_token: Option<&str>) -> Result { /// Use grans's own credentials if it has any, otherwise Granola's local store. fn stored_or_local_token() -> Result { - match GranolaCredentials::load()? { + let store = CredentialStore::open()?; + + match store.load()? { Some(credentials) => { debug!("Using grans's own stored credentials"); - token_from_credentials(credentials) + token_from_credentials(credentials, &store) } None => { debug!("No stored credentials; reading Granola's local token store"); @@ -67,7 +68,10 @@ fn stored_or_local_token() -> Result { } /// Return the stored access token, refreshing it first if it has expired. -fn token_from_credentials(credentials: GranolaCredentials) -> Result { +fn token_from_credentials( + credentials: GranolaCredentials, + store: &CredentialStore, +) -> Result { let now = Utc::now().timestamp(); if let Some(token) = credentials.valid_access_token(now) { @@ -75,8 +79,7 @@ fn token_from_credentials(credentials: GranolaCredentials) -> Result { } debug!("Stored access token is missing or expired; refreshing"); - let path = credentials::credentials_path()?; - refresh_and_persist(credentials, now, &path, live_refresh) + refresh_and_persist(credentials, now, store, live_refresh) } /// Ask Granola for a new token set. @@ -92,10 +95,10 @@ fn live_refresh(credentials: &GranolaCredentials) -> Result Result, ) -> Result { - let error = match refresh_once(&credentials, now, path, &refresh) { + let error = match refresh_once(&credentials, now, store, &refresh) { Ok(token) => return Ok(token), Err(error) => error, }; @@ -105,13 +108,13 @@ fn refresh_and_persist( // would consume the token we just sent. If what is on disk has moved on, // that invocation succeeded and its result is usable; only a chain that // has genuinely stalled is an error. - if let Some(current) = GranolaCredentials::load_from(path)? { + if let Some(current) = store.load()? { if current.refresh_token != credentials.refresh_token { debug!("Refresh token was rotated concurrently; using the newer credentials"); if let Some(token) = current.valid_access_token(now) { return Ok(token.to_string()); } - return refresh_once(¤t, now, path, &refresh); + return refresh_once(¤t, now, store, &refresh); } } @@ -124,7 +127,7 @@ fn refresh_and_persist( fn refresh_once( credentials: &GranolaCredentials, now: i64, - path: &Path, + store: &CredentialStore, refresh: impl Fn(&GranolaCredentials) -> Result, ) -> Result { let tokens = refresh(credentials)?; @@ -133,9 +136,7 @@ fn refresh_once( // Persist before returning. The refresh token comes back unchanged today, // but if Granola starts rotating it, handing back an access token without // saving what replaced it would strand the chain if the process died here. - tokens - .into_credentials(now, credentials.session_id.clone()) - .save_to(path)?; + store.save(&tokens.into_credentials(now, credentials.session_id.clone()))?; Ok(access_token) } @@ -215,16 +216,16 @@ mod tests { #[test] fn test_refresh_persists_rotated_token_before_returning() { let dir = TempDir::new().unwrap(); - let path = dir.path().join("auth.toml"); + let store = CredentialStore::file(dir.path().join("auth.toml")); let old = stored("refresh-old", None, None); - let token = refresh_and_persist(old, 1_000, &path, |_| { + let token = refresh_and_persist(old, 1_000, &store, |_| { Ok(token_set("access-new", "refresh-new")) }) .unwrap(); assert_eq!(token, "access-new"); - let saved = GranolaCredentials::load_from(&path).unwrap().unwrap(); + let saved = store.load().unwrap().unwrap(); assert_eq!(saved.refresh_token, "refresh-new"); assert_eq!(saved.access_token, Some("access-new".to_string())); assert_eq!(saved.expires_at, Some(4_600)); @@ -233,25 +234,25 @@ mod tests { #[test] fn test_refresh_carries_session_id_forward() { let dir = TempDir::new().unwrap(); - let path = dir.path().join("auth.toml"); + let store = CredentialStore::file(dir.path().join("auth.toml")); - refresh_and_persist(stored("refresh-old", None, None), 1_000, &path, |_| { + refresh_and_persist(stored("refresh-old", None, None), 1_000, &store, |_| { Ok(token_set("access-new", "refresh-new")) }) .unwrap(); - let saved = GranolaCredentials::load_from(&path).unwrap().unwrap(); + let saved = store.load().unwrap().unwrap(); assert_eq!(saved.session_id, Some("sess_original".to_string())); } #[test] fn test_refresh_failure_reports_relogin() { let dir = TempDir::new().unwrap(); - let path = dir.path().join("auth.toml"); + let store = CredentialStore::file(dir.path().join("auth.toml")); let creds = stored("refresh-dead", None, None); - creds.save_to(&path).unwrap(); + store.save(&creds).unwrap(); - let err = refresh_and_persist(creds, 1_000, &path, |_| bail!("token expired")) + let err = refresh_and_persist(creds, 1_000, &store, |_| bail!("token expired")) .unwrap_err(); assert!(err.to_string().contains("grans auth login")); @@ -262,13 +263,13 @@ mod tests { // Another grans consumed our refresh token and wrote a fresh access // token. Ours fails, but the chain is intact and its result is usable. let dir = TempDir::new().unwrap(); - let path = dir.path().join("auth.toml"); + let store = CredentialStore::file(dir.path().join("auth.toml")); let ours = stored("refresh-old", None, None); - stored("refresh-rotated", Some("access-from-other-run"), Some(9_000)) - .save_to(&path) + store + .save(&stored("refresh-rotated", Some("access-from-other-run"), Some(9_000))) .unwrap(); - let token = refresh_and_persist(ours, 1_000, &path, |_| bail!("token already used")) + let token = refresh_and_persist(ours, 1_000, &store, |_| bail!("token already used")) .unwrap(); assert_eq!(token, "access-from-other-run"); @@ -279,13 +280,13 @@ mod tests { // The concurrent run rotated the refresh token but its access token is // already expired, so we refresh again with what it left behind. let dir = TempDir::new().unwrap(); - let path = dir.path().join("auth.toml"); + let store = CredentialStore::file(dir.path().join("auth.toml")); let ours = stored("refresh-old", None, None); - stored("refresh-rotated", Some("stale"), Some(0)) - .save_to(&path) + store + .save(&stored("refresh-rotated", Some("stale"), Some(0))) .unwrap(); - let token = refresh_and_persist(ours, 1_000, &path, |credentials| { + let token = refresh_and_persist(ours, 1_000, &store, |credentials| { if credentials.refresh_token == "refresh-rotated" { Ok(token_set("access-second-try", "refresh-newest")) } else { @@ -295,7 +296,7 @@ mod tests { .unwrap(); assert_eq!(token, "access-second-try"); - let saved = GranolaCredentials::load_from(&path).unwrap().unwrap(); + let saved = store.load().unwrap().unwrap(); assert_eq!(saved.refresh_token, "refresh-newest"); } @@ -304,12 +305,12 @@ mod tests { // Nothing rotated it, so the failure is real and must not be retried // against the same dead token. let dir = TempDir::new().unwrap(); - let path = dir.path().join("auth.toml"); + let store = CredentialStore::file(dir.path().join("auth.toml")); let creds = stored("refresh-same", None, None); - creds.save_to(&path).unwrap(); + store.save(&creds).unwrap(); let attempts = std::cell::Cell::new(0); - let result = refresh_and_persist(creds, 1_000, &path, |_| { + let result = refresh_and_persist(creds, 1_000, &store, |_| { attempts.set(attempts.get() + 1); bail!("refresh rejected") }); diff --git a/src/api/credential_store.rs b/src/api/credential_store.rs new file mode 100644 index 0000000..ab0a950 --- /dev/null +++ b/src/api/credential_store.rs @@ -0,0 +1,392 @@ +//! Where grans keeps its Granola credentials. +//! +//! The refresh token is durable: it mints access tokens until the session is +//! revoked, so it belongs in the platform keychain rather than on disk. Where +//! no keychain is reachable, it falls back to a `0600` TOML file at +//! `data_dir()/auth.toml`, which keeps other local users out but leaves the +//! token readable in a backup or a copy of the disk. Callers tell the user +//! when that fallback is in use. + +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use log::debug; + +use super::credentials::GranolaCredentials; +use crate::platform::data_dir; + +/// Keychain service name grans stores its session under. +const KEYCHAIN_SERVICE: &str = "grans"; + +/// Keychain account name within that service. +const KEYCHAIN_ACCOUNT: &str = "granola-session"; + +/// Where grans keeps its Granola credentials. +pub enum CredentialStore { + /// The platform keychain, which keeps the refresh token out of the + /// filesystem entirely. + Keychain(Box), + /// A `0600` TOML file, for machines with no reachable keychain. + File(PathBuf), +} + +impl CredentialStore { + /// Open the best available store, moving file-stored credentials into the + /// keychain the first time one is available. + pub fn open() -> Result { + let store = match reachable_keychain() { + Some(entry) => Self::Keychain(Box::new(entry)), + None => Self::File(credentials_path()?), + }; + + store.absorb_credentials_file()?; + Ok(store) + } + + /// A store backed by a specific file. Used for the fallback and by tests. + pub fn file(path: PathBuf) -> Self { + Self::File(path) + } + + pub fn load(&self) -> Result> { + match self { + Self::File(path) => read_file(path), + Self::Keychain(entry) => match entry.get_password() { + Ok(json) => serde_json::from_str(&json) + .map(Some) + .context("Failed to parse credentials from the keychain"), + Err(keyring::Error::NoEntry) => Ok(None), + Err(e) => Err(e).context("Failed to read credentials from the keychain"), + }, + } + } + + pub fn save(&self, credentials: &GranolaCredentials) -> Result<()> { + match self { + Self::File(path) => write_file(credentials, path), + Self::Keychain(entry) => { + let json = serde_json::to_string(credentials) + .context("Failed to serialize credentials")?; + entry + .set_password(&json) + .context("Failed to store credentials in the keychain") + } + } + } + + pub fn delete(&self) -> Result<()> { + match self { + Self::File(path) => delete_file(path), + Self::Keychain(entry) => match entry.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(e) => Err(e).context("Failed to remove credentials from the keychain"), + }, + } + } + + /// Whether the refresh token is protected by the platform keychain. + pub fn is_keychain(&self) -> bool { + matches!(self, Self::Keychain(_)) + } + + /// Name this store for `grans auth status`. + pub fn describe(&self) -> String { + match self { + Self::Keychain(_) => format!("{} ({})", keychain_name(), KEYCHAIN_SERVICE), + Self::File(path) => path.display().to_string(), + } + } + + /// Move credentials out of the fallback file once a keychain is available. + /// + /// The file is removed only after the keychain write succeeds, so a + /// failure here leaves the existing credentials usable. + fn absorb_credentials_file(&self) -> Result<()> { + let Self::Keychain(_) = self else { + return Ok(()); + }; + + let path = credentials_path()?; + let Some(credentials) = read_file(&path)? else { + return Ok(()); + }; + + debug!("Moving credentials from {} into the keychain", path.display()); + self.save(&credentials)?; + delete_file(&path) + } +} + +/// The keychain, if one can actually be read from. +/// +/// Constructing an entry is not proof: a Linux box with no Secret Service, or +/// a locked keychain, fails only when read. So this reads, and treats "no such +/// entry" as a working keychain that is simply empty. +fn reachable_keychain() -> Option { + let entry = keyring::Entry::new(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT) + .inspect_err(|e| debug!("No keychain available: {}", e)) + .ok()?; + + match entry.get_password() { + Ok(_) | Err(keyring::Error::NoEntry) => Some(entry), + Err(e) => { + debug!("Keychain present but unreadable: {}", e); + None + } + } +} + +/// What the platform calls its keychain. +fn keychain_name() -> &'static str { + if cfg!(target_os = "macos") { + "macOS Keychain" + } else if cfg!(target_os = "windows") { + "Windows Credential Manager" + } else { + "Secret Service" + } +} + +/// Read credentials from `path`, or `None` if the file does not exist. +fn read_file(path: &Path) -> Result> { + let content = match fs::read_to_string(path) { + Ok(content) => content, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(e).with_context(|| format!("Failed to read {}", path.display())), + }; + + toml::from_str(&content) + .map(Some) + .with_context(|| format!("Failed to parse credentials at {}", path.display())) +} + +/// Write credentials to `path`, replacing any existing file atomically. +fn write_file(credentials: &GranolaCredentials, path: &Path) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create {}", parent.display()))?; + } + + let content = toml::to_string_pretty(credentials).context("Failed to serialize credentials")?; + + // Write to a temp file and rename into place, so a crash mid-write + // cannot leave a truncated credential file behind. + let temp_path = path.with_extension("toml.tmp"); + let mut file = create_private_file(&temp_path) + .with_context(|| format!("Failed to create {}", temp_path.display()))?; + file.write_all(content.as_bytes()) + .with_context(|| format!("Failed to write {}", temp_path.display()))?; + drop(file); + + fs::rename(&temp_path, path).with_context(|| format!("Failed to replace {}", path.display())) +} + +/// Create a file readable only by its owner. +/// +/// The permissions are set at creation rather than tightened afterward: +/// writing the refresh token first and calling `chmod` second leaves a window +/// where another local user can read it. On Windows the file inherits the +/// directory's ACL, which is why the fallback is worth warning about there. +fn create_private_file(path: &Path) -> std::io::Result { + // Clear anything an interrupted run left behind: the mode below applies + // only when the open creates the file, so reusing one would silently keep + // whatever permissions it already had. + match fs::remove_file(path) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e), + } + + let mut options = fs::OpenOptions::new(); + // create_new refuses to write through a file, or symlink, that appeared + // between the remove and the open. + options.write(true).create_new(true); + + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + + options.open(path) +} + +/// Remove the credential file at `path`. Succeeds if it is already gone. +fn delete_file(path: &Path) -> Result<()> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e).with_context(|| format!("Failed to remove {}", path.display())), + } +} + +/// Path to the fallback credential file. +fn credentials_path() -> Result { + Ok(data_dir()?.join("auth.toml")) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn creds() -> GranolaCredentials { + GranolaCredentials { + refresh_token: "refresh-abc".to_string(), + access_token: Some("access-xyz".to_string()), + expires_at: Some(1_800_003_600), + session_id: Some("session_01ABC".to_string()), + } + } + + fn file_store(dir: &TempDir) -> CredentialStore { + CredentialStore::file(dir.path().join("auth.toml")) + } + + #[test] + fn test_load_missing_file_is_none() { + let dir = TempDir::new().unwrap(); + + assert_eq!(file_store(&dir).load().unwrap(), None); + } + + #[test] + fn test_save_load_roundtrip() { + let dir = TempDir::new().unwrap(); + let store = file_store(&dir); + + store.save(&creds()).unwrap(); + + assert_eq!(store.load().unwrap(), Some(creds())); + } + + #[test] + fn test_refresh_token_only_roundtrip() { + let dir = TempDir::new().unwrap(); + let store = file_store(&dir); + let bare = GranolaCredentials::from_refresh_token("refresh-only".to_string()); + + store.save(&bare).unwrap(); + + assert_eq!(store.load().unwrap(), Some(bare)); + } + + #[test] + fn test_save_creates_missing_parent_directory() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("nested").join("deeper").join("auth.toml"); + + CredentialStore::file(path.clone()).save(&creds()).unwrap(); + + assert!(path.exists()); + } + + #[test] + fn test_save_replaces_existing_and_leaves_no_temp_file() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("auth.toml"); + let store = CredentialStore::file(path.clone()); + + store.save(&creds()).unwrap(); + let rotated = GranolaCredentials::from_refresh_token("refresh-rotated".to_string()); + store.save(&rotated).unwrap(); + + assert_eq!(store.load().unwrap(), Some(rotated)); + assert!(!path.with_extension("toml.tmp").exists()); + } + + #[test] + fn test_load_malformed_file_errors() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("auth.toml"); + fs::write(&path, "this is not toml {{{").unwrap(); + + assert!(CredentialStore::file(path).load().is_err()); + } + + #[test] + fn test_delete_removes_credentials() { + let dir = TempDir::new().unwrap(); + let store = file_store(&dir); + store.save(&creds()).unwrap(); + + store.delete().unwrap(); + + assert_eq!(store.load().unwrap(), None); + } + + #[test] + fn test_delete_is_idempotent() { + let dir = TempDir::new().unwrap(); + + assert!(file_store(&dir).delete().is_ok()); + } + + #[test] + fn test_file_store_is_not_reported_as_keychain() { + let dir = TempDir::new().unwrap(); + let store = file_store(&dir); + + assert!(!store.is_keychain()); + assert!(store.describe().contains("auth.toml")); + } + + #[test] + fn test_credentials_survive_json_roundtrip() { + // The keychain holds one JSON string rather than the TOML the file + // backend writes, so the same struct has to survive both. + let json = serde_json::to_string(&creds()).unwrap(); + + assert_eq!( + serde_json::from_str::(&json).unwrap(), + creds() + ); + } + + #[cfg(unix)] + #[test] + fn test_saved_file_is_owner_only() { + use std::os::unix::fs::PermissionsExt; + + let dir = TempDir::new().unwrap(); + let path = dir.path().join("auth.toml"); + CredentialStore::file(path.clone()).save(&creds()).unwrap(); + + let mode = fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600); + } + + #[cfg(unix)] + #[test] + fn test_private_file_is_owner_only_from_creation() { + // The permissions must come from the open, not from a later chmod: + // between the two, the refresh token would be readable by anyone who + // can traverse the directory. + use std::os::unix::fs::PermissionsExt; + + let dir = TempDir::new().unwrap(); + let file = create_private_file(&dir.path().join("fresh.tmp")).unwrap(); + + let mode = file.metadata().unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600); + } + + #[cfg(unix)] + #[test] + fn test_private_file_tightens_permissions_on_reuse() { + // A temp file left behind by an earlier run with loose permissions + // must not be inherited as-is. + use std::os::unix::fs::PermissionsExt; + + let dir = TempDir::new().unwrap(); + let path = dir.path().join("stale.tmp"); + fs::write(&path, "old").unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap(); + + let file = create_private_file(&path).unwrap(); + + assert_eq!(file.metadata().unwrap().permissions().mode() & 0o777, 0o600); + } +} diff --git a/src/api/credentials.rs b/src/api/credentials.rs index 84499ce..f343209 100644 --- a/src/api/credentials.rs +++ b/src/api/credentials.rs @@ -1,21 +1,14 @@ -//! Storage for grans's own Granola credentials. +//! grans's own Granola session. //! //! Granola's desktop app keeps its data-encryption key in the macOS //! data-protection keychain, gated on its own code signature, so grans cannot //! read the app's stored session there. Instead grans holds its own refresh //! token, obtained through the same PKCE login the desktop client uses. //! -//! Persisted as TOML at `data_dir()/auth.toml`. +//! Where that lives is [`super::credential_store`]'s problem. -use std::fs; -use std::io::Write; -use std::path::{Path, PathBuf}; - -use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; -use crate::platform::data_dir; - /// Treat an access token expiring within this window as already expired, so a /// long-running command does not start with a token that dies mid-flight. const EXPIRY_SKEW_SECS: i64 = 60; @@ -64,108 +57,11 @@ impl GranolaCredentials { } self.access_token.as_deref() } - - /// Load credentials from `path`, or `None` if the file does not exist. - pub fn load_from(path: &Path) -> Result> { - let content = match fs::read_to_string(path) { - Ok(content) => content, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(e) => { - return Err(e).with_context(|| format!("Failed to read {}", path.display())) - } - }; - - toml::from_str(&content) - .map(Some) - .with_context(|| format!("Failed to parse credentials at {}", path.display())) - } - - /// Write credentials to `path`, replacing any existing file atomically. - pub fn save_to(&self, path: &Path) -> Result<()> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("Failed to create {}", parent.display()))?; - } - - let content = toml::to_string_pretty(self).context("Failed to serialize credentials")?; - - // Write to a temp file and rename into place, so a crash mid-write - // cannot leave a truncated credential file behind. - let temp_path = path.with_extension("toml.tmp"); - let mut file = create_private_file(&temp_path) - .with_context(|| format!("Failed to create {}", temp_path.display()))?; - file.write_all(content.as_bytes()) - .with_context(|| format!("Failed to write {}", temp_path.display()))?; - drop(file); - - fs::rename(&temp_path, path) - .with_context(|| format!("Failed to replace {}", path.display())) - } - - /// Load credentials from the default location. - pub fn load() -> Result> { - Self::load_from(&credentials_path()?) - } - - /// Save credentials to the default location. - pub fn save(&self) -> Result<()> { - self.save_to(&credentials_path()?) - } -} - -/// Create a file readable only by its owner. -/// -/// The permissions are set at creation rather than tightened afterward: -/// writing the refresh token first and calling `chmod` second leaves a window -/// where another local user can read it. On Windows the file inherits the -/// directory's ACL, which is why the refresh token is not protected there. -fn create_private_file(path: &Path) -> std::io::Result { - // Clear anything an interrupted run left behind: the mode below applies - // only when the open creates the file, so reusing one would silently keep - // whatever permissions it already had. - match fs::remove_file(path) { - Ok(()) => {} - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => return Err(e), - } - - let mut options = fs::OpenOptions::new(); - // create_new refuses to write through a file, or symlink, that appeared - // between the remove and the open. - options.write(true).create_new(true); - - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.mode(0o600); - } - - options.open(path) -} - -/// Remove the credential file at `path`. Succeeds if it is already gone. -pub fn delete_from(path: &Path) -> Result<()> { - match fs::remove_file(path) { - Ok(()) => Ok(()), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(e) => Err(e).with_context(|| format!("Failed to remove {}", path.display())), - } -} - -/// Remove the credential file at the default location. -pub fn delete() -> Result<()> { - delete_from(&credentials_path()?) -} - -/// Path to grans's credential file. -pub fn credentials_path() -> Result { - Ok(data_dir()?.join("auth.toml")) } #[cfg(test)] mod tests { use super::*; - use tempfile::TempDir; const NOW: i64 = 1_800_000_000; @@ -178,70 +74,6 @@ mod tests { } } - #[test] - fn test_load_from_missing_file_is_none() { - let dir = TempDir::new().unwrap(); - let path = dir.path().join("auth.toml"); - - assert_eq!(GranolaCredentials::load_from(&path).unwrap(), None); - } - - #[test] - fn test_save_load_roundtrip() { - let dir = TempDir::new().unwrap(); - let path = dir.path().join("auth.toml"); - - creds().save_to(&path).unwrap(); - - assert_eq!(GranolaCredentials::load_from(&path).unwrap(), Some(creds())); - } - - #[test] - fn test_save_creates_missing_parent_directory() { - let dir = TempDir::new().unwrap(); - let path = dir.path().join("nested").join("deeper").join("auth.toml"); - - creds().save_to(&path).unwrap(); - - assert!(path.exists()); - } - - #[test] - fn test_save_replaces_existing_and_leaves_no_temp_file() { - let dir = TempDir::new().unwrap(); - let path = dir.path().join("auth.toml"); - - creds().save_to(&path).unwrap(); - let rotated = GranolaCredentials::from_refresh_token("refresh-rotated".to_string()); - rotated.save_to(&path).unwrap(); - - assert_eq!( - GranolaCredentials::load_from(&path).unwrap(), - Some(rotated) - ); - assert!(!path.with_extension("toml.tmp").exists()); - } - - #[test] - fn test_refresh_token_only_roundtrip() { - let dir = TempDir::new().unwrap(); - let path = dir.path().join("auth.toml"); - let bare = GranolaCredentials::from_refresh_token("refresh-only".to_string()); - - bare.save_to(&path).unwrap(); - - assert_eq!(GranolaCredentials::load_from(&path).unwrap(), Some(bare)); - } - - #[test] - fn test_load_from_malformed_file_errors() { - let dir = TempDir::new().unwrap(); - let path = dir.path().join("auth.toml"); - fs::write(&path, "this is not toml {{{").unwrap(); - - assert!(GranolaCredentials::load_from(&path).is_err()); - } - #[test] fn test_valid_access_token_when_fresh() { assert_eq!(creds().valid_access_token(NOW), Some("access-xyz")); @@ -289,67 +121,10 @@ mod tests { } #[test] - fn test_delete_removes_file() { - let dir = TempDir::new().unwrap(); - let path = dir.path().join("auth.toml"); - creds().save_to(&path).unwrap(); - - delete_from(&path).unwrap(); - - assert!(!path.exists()); - } - - #[test] - fn test_delete_missing_file_succeeds() { - let dir = TempDir::new().unwrap(); - - assert!(delete_from(&dir.path().join("auth.toml")).is_ok()); - } - - #[cfg(unix)] - #[test] - fn test_saved_file_is_owner_only() { - use std::os::unix::fs::PermissionsExt; - - let dir = TempDir::new().unwrap(); - let path = dir.path().join("auth.toml"); - creds().save_to(&path).unwrap(); - - let mode = fs::metadata(&path).unwrap().permissions().mode(); - assert_eq!(mode & 0o777, 0o600); - } - - #[cfg(unix)] - #[test] - fn test_private_file_is_owner_only_from_creation() { - // The permissions must come from the open, not from a later chmod: - // between the two, the refresh token would be readable by anyone who - // can traverse the directory. - use std::os::unix::fs::PermissionsExt; - - let dir = TempDir::new().unwrap(); - let path = dir.path().join("fresh.tmp"); - - let file = create_private_file(&path).unwrap(); - let mode = file.metadata().unwrap().permissions().mode(); - - assert_eq!(mode & 0o777, 0o600); - } - - #[cfg(unix)] - #[test] - fn test_private_file_tightens_permissions_on_reuse() { - // A temp file left behind by an earlier run with loose permissions - // must not be inherited as-is. - use std::os::unix::fs::PermissionsExt; - - let dir = TempDir::new().unwrap(); - let path = dir.path().join("stale.tmp"); - fs::write(&path, "old").unwrap(); - fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap(); - - let file = create_private_file(&path).unwrap(); + fn test_from_refresh_token_has_nothing_else() { + let bare = GranolaCredentials::from_refresh_token("rt".to_string()); - assert_eq!(file.metadata().unwrap().permissions().mode() & 0o777, 0o600); + assert_eq!(bare.refresh_token, "rt"); + assert_eq!(bare.valid_access_token(NOW), None); } } diff --git a/src/api/mod.rs b/src/api/mod.rs index 57a5b09..f264865 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,5 +1,6 @@ mod auth; pub mod client; +pub mod credential_store; pub mod credentials; pub mod granola_auth; pub mod identity; diff --git a/src/commands/auth.rs b/src/commands/auth.rs index bd0d254..a411b4e 100644 --- a/src/commands/auth.rs +++ b/src/commands/auth.rs @@ -5,7 +5,8 @@ use std::io::{self, BufRead, Write}; use anyhow::{bail, Context, Result}; use chrono::{FixedOffset, TimeZone, Utc}; -use crate::api::credentials::{self, GranolaCredentials}; +use crate::api::credential_store::CredentialStore; +use crate::api::credentials::GranolaCredentials; use crate::api::granola_auth::{self, Provider}; use crate::cli::args::{AuthAction, AuthProvider}; use crate::pkce::PkceChallenge; @@ -32,7 +33,9 @@ pub fn run(action: &AuthAction, tz: &FixedOffset) -> Result<()> { } fn login(provider: AuthProvider, refresh_token_stdin: bool) -> Result<()> { - if existing_session_kept()? { + let store = CredentialStore::open()?; + + if existing_session_kept(&store)? { return Ok(()); } @@ -42,18 +45,34 @@ fn login(provider: AuthProvider, refresh_token_stdin: bool) -> Result<()> { credentials_from_browser_login(provider.into())? }; - credentials.save()?; + store.save(&credentials)?; println!("\nSigned in. grans now holds its own Granola session."); + println!(" Credentials: {}", store.describe()); + if !store.is_keychain() { + print_no_keychain_warning(); + } println!("Run `grans sync` to fetch your meetings."); Ok(()) } +/// Warn that the refresh token is only as protected as the file holding it. +/// +/// The refresh token mints access tokens until the session is revoked, so a +/// copy of that file from a backup or disk image is a live credential. +fn print_no_keychain_warning() { + eprintln!(); + eprintln!("Warning: no keychain was reachable, so the refresh token is stored"); + eprintln!("unencrypted. File permissions keep other local users out, but anyone"); + eprintln!("who obtains a copy of the file can use the session until it is revoked."); + eprintln!(); +} + /// Ask before replacing a session that already works. /// /// Returns true when the caller should stop, leaving the session alone. -fn existing_session_kept() -> Result { - if GranolaCredentials::load()?.is_none() { +fn existing_session_kept(store: &CredentialStore) -> Result { + if store.load()?.is_none() { return Ok(false); } @@ -140,7 +159,9 @@ fn print_callback_instructions() { } fn status(tz: &FixedOffset) -> Result<()> { - let Some(credentials) = GranolaCredentials::load()? else { + let store = CredentialStore::open()?; + + let Some(credentials) = store.load()? else { println!("Not signed in."); println!("grans falls back to the token Granola's desktop app stored locally,"); println!("which no longer works on current macOS builds. Run `grans auth login`."); @@ -148,7 +169,7 @@ fn status(tz: &FixedOffset) -> Result<()> { }; println!("Signed in."); - println!(" Credentials: {}", credentials::credentials_path()?.display()); + println!(" Credentials: {}", store.describe()); if let Some(session_id) = &credentials.session_id { println!(" Session: {}", session_id); @@ -156,6 +177,10 @@ fn status(tz: &FixedOffset) -> Result<()> { println!(" Access token: {}", describe_expiry(&credentials, tz)); + if !store.is_keychain() { + print_no_keychain_warning(); + } + Ok(()) } @@ -185,12 +210,14 @@ fn describe_expiry(credentials: &GranolaCredentials, tz: &FixedOffset) -> String } fn logout() -> Result<()> { - if GranolaCredentials::load()?.is_none() { + let store = CredentialStore::open()?; + + if store.load()?.is_none() { println!("Not signed in; nothing to remove."); return Ok(()); } - credentials::delete()?; + store.delete()?; println!("Removed grans's stored Granola credentials."); println!("The session itself remains active in Granola until you revoke it there.");