diff --git a/CLAUDE.md b/CLAUDE.md index fdc3c66..dcbc58e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,7 +58,12 @@ 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`: 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`) - `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 diff --git a/Cargo.lock b/Cargo.lock index 26ed478..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]] @@ -859,11 +1084,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" @@ -891,6 +1127,7 @@ dependencies = [ "env_logger", "fastembed", "indicatif", + "keyring", "log", "open", "ort", @@ -910,6 +1147,8 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "toml", + "url", + "uuid", "windows-sys 0.59.0", ] @@ -941,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" @@ -961,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" @@ -1308,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" @@ -1403,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" @@ -1449,7 +1739,7 @@ checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -1483,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" @@ -1501,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" @@ -1573,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" @@ -1597,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" @@ -1637,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" @@ -1718,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" @@ -1797,6 +2182,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" @@ -2113,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" @@ -2174,7 +2584,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -2190,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" @@ -2239,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" @@ -2346,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" @@ -2363,7 +2805,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -2411,7 +2853,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -2422,7 +2864,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -2528,8 +2970,8 @@ checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", "serde_spanned", - "toml_datetime", - "toml_edit", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", ] [[package]] @@ -2541,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" @@ -2550,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]] @@ -2618,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" @@ -2642,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" @@ -2769,6 +3264,18 @@ 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", + "serde_core", + "wasm-bindgen", +] + [[package]] name = "vcpkg" version = "0.2.15" @@ -2869,7 +3376,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.114", "wasm-bindgen-shared", ] @@ -2976,7 +3483,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -2987,7 +3494,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -2996,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" @@ -3254,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" @@ -3285,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" @@ -3306,7 +3907,7 @@ checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -3326,7 +3927,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", "synstructure", ] @@ -3366,7 +3967,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -3374,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 3d570e6..d8acec3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,9 @@ 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"] } +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 4b298db..7351963 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,88 @@ 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 +``` + +`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. +`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. + +### Where credentials are stored -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 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. -To get the token from a machine with Granola installed: +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 +stored, decrypting the `supabase.json.enc` store that recent versions use +(falling back to the legacy plaintext `supabase.json`). + +**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: diff --git a/src/api/auth.rs b/src/api/auth.rs index 58e6b9f..804f4b9 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -1,226 +1,144 @@ -use std::env; -use std::path::{Path, PathBuf}; - -use anyhow::{bail, Context, Result}; +//! Deciding which token grans authenticates with. +//! +//! 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 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, -} +use super::credential_store::CredentialStore; +use super::credentials::GranolaCredentials; +use super::{granola_auth, local_store}; -#[derive(Debug, Deserialize)] -struct WorkosTokens { - #[serde(default)] - access_token: Option, -} +/// Environment variable supplying a token when `--token` is absent. +pub const TOKEN_ENV_VAR: &str = "GRANS_TOKEN"; -/// 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), +/// 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()); } - 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) - } - } + env_value.filter(|value| !value.trim().is_empty()) } -/// Resolve the authentication token: use the provided override, or fall back -/// to reading from Granola's supabase.json. +/// 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(), - } -} - -/// 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 { - 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()) - }); + None => stored_or_local_token(), } - - 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) -} +/// Use grans's own credentials if it has any, otherwise Granola's local store. +fn stored_or_local_token() -> Result { + let store = CredentialStore::open()?; -/// 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()); + match store.load()? { + Some(credentials) => { + debug!("Using grans's own stored credentials"); + token_from_credentials(credentials, &store) } - } - - 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"); + None => { + debug!("No stored credentials; reading Granola's local token store"); + local_store::get_auth_token() } } - 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()); - } - } - } +/// Return the stored access token, refreshing it first if it has expired. +fn token_from_credentials( + credentials: GranolaCredentials, + store: &CredentialStore, +) -> Result { + let now = Utc::now().timestamp(); - // Fallback to WSL username - if let Ok(user) = env::var("USER") { - return Some(user); + if let Some(token) = credentials.valid_access_token(now) { + return Ok(token.to_string()); } - None + debug!("Stored access token is missing or expired; refreshing"); + refresh_and_persist(credentials, now, store, live_refresh) } -/// 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)), - ]) +/// 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, + ) } -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")); +/// Refresh the access token, tolerating a concurrent invocation that rotated +/// the refresh token first. +fn refresh_and_persist( + credentials: GranolaCredentials, + now: i64, + store: &CredentialStore, + refresh: impl Fn(&GranolaCredentials) -> Result, +) -> Result { + let error = match refresh_once(&credentials, now, store, &refresh) { + Ok(token) => return Ok(token), + Err(error) => 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) = 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, store, &refresh); } } - // Windows (native) - if let Ok(appdata) = env::var("APPDATA") { - candidates.push(PathBuf::from(appdata).join("Granola")); - } - - candidates + Err(error.context( + "Could not refresh grans's Granola session. Run `grans auth login` to sign in again.", + )) } -fn dirs_home() -> Option { - env::var("HOME") - .ok() - .map(PathBuf::from) - .or_else(|| env::var("USERPROFILE").ok().map(PathBuf::from)) +/// Exchange the refresh token for a new access token and persist the result. +fn refresh_once( + credentials: &GranolaCredentials, + now: i64, + store: &CredentialStore, + refresh: impl Fn(&GranolaCredentials) -> Result, +) -> Result { + let tokens = refresh(credentials)?; + let access_token = tokens.access_token.clone(); + + // 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. + store.save(&tokens.into_credentials(now, credentials.session_id.clone()))?; + + Ok(access_token) } #[cfg(test)] @@ -229,106 +147,176 @@ mod tests { use tempfile::TempDir; #[test] - fn test_parse_supabase_config_with_token() { - let json = r#"{ - "workos_tokens": { - "access_token": "test-token-123" - } - }"#; + fn test_resolve_token_uses_override() { + let token = resolve_token(Some("my-override-token")).unwrap(); + assert_eq!(token, "my-override-token"); + } - 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_resolve_token_rejects_empty_override() { + let result = resolve_token(Some("")); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("empty")); } #[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()); + 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_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()); + 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_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()) - ); + 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_get_auth_token_from_file() { - let dir = TempDir::new().unwrap(); - let config_path = dir.path().join("supabase.json"); + 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 --- - std::fs::write(&config_path, r#"{"workos_tokens": {"access_token": "my-secret-token"}}"#).unwrap(); + 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()), + } + } - // 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"); + 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_is_wsl() { - // We can't guarantee the test environment, but we can verify - // the function doesn't panic - let _ = is_wsl(); + fn test_refresh_persists_rotated_token_before_returning() { + let dir = TempDir::new().unwrap(); + let store = CredentialStore::file(dir.path().join("auth.toml")); + let old = stored("refresh-old", None, None); + + let token = refresh_and_persist(old, 1_000, &store, |_| { + Ok(token_set("access-new", "refresh-new")) + }) + .unwrap(); + + assert_eq!(token, "access-new"); + 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)); } #[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") - ); - } - } + fn test_refresh_carries_session_id_forward() { + let dir = TempDir::new().unwrap(); + let store = CredentialStore::file(dir.path().join("auth.toml")); + + refresh_and_persist(stored("refresh-old", None, None), 1_000, &store, |_| { + Ok(token_set("access-new", "refresh-new")) + }) + .unwrap(); + + let saved = store.load().unwrap().unwrap(); + assert_eq!(saved.session_id, Some("sess_original".to_string())); } #[test] - fn test_resolve_token_uses_override() { - let token = resolve_token(Some("my-override-token")).unwrap(); - assert_eq!(token, "my-override-token"); + fn test_refresh_failure_reports_relogin() { + let dir = TempDir::new().unwrap(); + let store = CredentialStore::file(dir.path().join("auth.toml")); + let creds = stored("refresh-dead", None, None); + store.save(&creds).unwrap(); + + let err = refresh_and_persist(creds, 1_000, &store, |_| bail!("token expired")) + .unwrap_err(); + + assert!(err.to_string().contains("grans auth login")); } #[test] - fn test_resolve_token_rejects_empty_override() { - let result = resolve_token(Some("")); - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("empty")); + 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 store = CredentialStore::file(dir.path().join("auth.toml")); + let ours = stored("refresh-old", None, None); + store + .save(&stored("refresh-rotated", Some("access-from-other-run"), Some(9_000))) + .unwrap(); + + let token = refresh_and_persist(ours, 1_000, &store, |_| bail!("token already used")) + .unwrap(); + + assert_eq!(token, "access-from-other-run"); } #[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_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 store = CredentialStore::file(dir.path().join("auth.toml")); + let ours = stored("refresh-old", None, None); + store + .save(&stored("refresh-rotated", Some("stale"), Some(0))) + .unwrap(); + + 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 { + bail!("token already used") + } + }) + .unwrap(); + + assert_eq!(token, "access-second-try"); + let saved = store.load().unwrap().unwrap(); + assert_eq!(saved.refresh_token, "refresh-newest"); } #[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")); + 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 store = CredentialStore::file(dir.path().join("auth.toml")); + let creds = stored("refresh-same", None, None); + store.save(&creds).unwrap(); + let attempts = std::cell::Cell::new(0); + + let result = refresh_and_persist(creds, 1_000, &store, |_| { + attempts.set(attempts.get() + 1); + bail!("refresh rejected") + }); + + assert!(result.is_err()); + assert_eq!(attempts.get(), 1); } + } 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/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 new file mode 100644 index 0000000..f343209 --- /dev/null +++ b/src/api/credentials.rs @@ -0,0 +1,130 @@ +//! 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. +//! +//! Where that lives is [`super::credential_store`]'s problem. + +use serde::{Deserialize, Serialize}; + +/// 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, rewritten from every refresh response. + 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() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + 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_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_from_refresh_token_has_nothing_else() { + let bare = GranolaCredentials::from_refresh_token("rt".to_string()); + + assert_eq!(bare.refresh_token, "rt"); + assert_eq!(bare.valid_access_token(NOW), None); + } +} diff --git a/src/api/granola_auth.rs b/src/api/granola_auth.rs new file mode 100644 index 0000000..84befa4 --- /dev/null +++ b/src/api/granola_auth.rs @@ -0,0 +1,624 @@ +//! 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 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; + +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"; + +/// 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. +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) +} + +/// 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 +/// 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"); + } + + 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(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()) + }; + + 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 +/// 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. +/// `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": callback.code, + "codeVerifier": verifier, + "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) + .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. +/// +/// 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 }); + + 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) +} + +/// 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'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 = 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)) +} + +/// 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)] +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_app_redirect_url() { + // What the browser actually lands on, and what the address bar shows. + 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 callback = parse_callback("granola://login-complete?code=abc123&sso=false").unwrap(); + + assert_eq!(callback.code, "abc123"); + } + + #[test] + fn test_parse_callback_url_percent_decodes() { + let callback = parse_callback("granola://login-complete?code=a%2Fb%2Bc").unwrap(); + + assert_eq!(callback.code, "a/b+c"); + } + + #[test] + 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!(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_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(" ").is_err()); + } + + #[test] + fn test_parse_rejects_url_without_code() { + let err = parse_callback("granola://login-complete?sso=false").unwrap_err(); + + assert!(err.to_string().contains("no 'code' parameter")); + } + + #[test] + 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( + "https://api.granola.ai/v1/auth?dev=false&code_challenge=abc&provider=google", + ) + .unwrap_err(); + + assert!(err.to_string().contains("no 'code' parameter")); + assert!(err.to_string().contains("app-redirect")); + } + + #[test] + fn test_parse_rejects_prose() { + assert!(parse_callback("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_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 { + 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/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/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 f5ba308..f264865 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,8 +1,13 @@ mod auth; pub mod client; +pub mod credential_store; +pub mod credentials; +pub mod granola_auth; +pub mod identity; +pub mod local_store; 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/cli/args/mod.rs b/src/cli/args/mod.rs index 0d53006..d7fc204 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,38 @@ 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. 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)] + 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..a411b4e --- /dev/null +++ b/src/commands/auth.rs @@ -0,0 +1,284 @@ +//! `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::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; + +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<()> { + let store = CredentialStore::open()?; + + if existing_session_kept(&store)? { + return Ok(()); + } + + let credentials = if refresh_token_stdin { + credentials_from_stdin()? + } else { + credentials_from_browser_login(provider.into())? + }; + + 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(store: &CredentialStore) -> Result { + if store.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 callback = granola_auth::parse_callback(&pasted)?; + + println!("Exchanging the code for tokens..."); + let tokens = granola_auth::exchange_code(&callback, &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 hands the code to a +/// verifier it does not have. That fails inside Granola and can leave grans +/// with a spent code and no obvious cause. +fn print_callback_instructions() { + println!("After signing in you land on a granola.ai page, which offers to open"); + println!("the Granola app. Cancel that dialog, then copy the URL from your"); + println!("browser's address bar. It looks like:"); + println!(); + println!(" https://www.granola.ai/app-redirect?code=..."); + println!(); +} + +fn status(tz: &FixedOffset) -> Result<()> { + 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`."); + return Ok(()); + }; + + println!("Signed in."); + println!(" Credentials: {}", store.describe()); + + if let Some(session_id) = &credentials.session_id { + println!(" Session: {}", session_id); + } + + println!(" Access token: {}", describe_expiry(&credentials, tz)); + + if !store.is_keychain() { + print_no_keychain_warning(); + } + + 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<()> { + let store = CredentialStore::open()?; + + if store.load()?.is_none() { + println!("Not signed in; nothing to remove."); + return Ok(()); + } + + store.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/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..e58f4f1 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; @@ -38,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 }, @@ -56,10 +64,22 @@ 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())?; - 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(()); } @@ -240,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 @@ -253,7 +274,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."); 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";