#89: grans owns its own Granola credentials#90
Conversation
The Granola login flow needs the same RFC 7636 primitive the Dropbox flow already has, so move it out of the Dropbox-specific OAuth module rather than duplicating it. Verifier entropy becomes a parameter: Dropbox keeps its 64 bytes (86 characters) via PKCE_ENTROPY_BYTES, and Granola will ask for 32 (43 characters) to match what its desktop client sends. Claude-Session: https://claude.ai/code/session_012c8WU6fhQDaVekXJX5hGhH
Granola moved its data-encryption key into the macOS data-protection keychain, gated on its own code signature, so grans can no longer read the token the desktop app stored. It needs to hold its own. Stores a refresh token, a cached access token and its expiry as TOML at data_dir()/auth.toml, written via temp file and rename so an interrupted write cannot truncate the file, and chmod 0600 on unix. load/save take an explicit path with thin wrappers for the default location, so the round-trip is testable without touching the real data directory. valid_access_token takes `now` as a parameter for the same reason, and applies a 60 second skew so a command does not start with a token that expires mid-run. Claude-Session: https://claude.ai/code/session_012c8WU6fhQDaVekXJX5hGhH
The client version was hardcoded in two places, and the login flow needs a third. Granola's /v1/auth endpoint redirects to an upgrade page when the version is missing or too old, so this value will go stale and needs a workaround that does not require a grans release. Adds api::identity with the version (overridable via GRANS_GRANOLA_VERSION) and the platform name, and points the API client at it. The reported version moves 6.518.0 -> 7.441.6, matching a build observed working against the live auth endpoint; verified the data endpoints still work by syncing. Version precedence is resolved by a pure function so it is testable without mutating the environment, which is unsafe in edition 2024. Claude-Session: https://claude.ai/code/session_012c8WU6fhQDaVekXJX5hGhH
Replicates the desktop client's OAuth flow: build the /v1/auth URL, exchange the authorization code at workos-auth-complete (unauthenticated, which is what makes bootstrapping possible), and refresh against refresh-access-token. The callback lands on granola://login-complete, which both macOS and Windows route to Granola.app, and /v1/auth returns 403 for a loopback redirect parameter, so the code comes back by paste. parse_callback_code takes the whole callback URL or a bare code, and rejects a pasted WorkOS URL with an explanation rather than a failed exchange later. A preflight request with redirects disabled catches the one failure that is otherwise invisible: a stale client version redirects to Granola's upgrade page, which in a browser just looks like marketing. That case names GRANS_GRANOLA_VERSION as the fix. Response parsing tolerates tokens nested under workos_tokens, that key holding JSON-encoded text, or the fields at the top level, and refuses a response with no refresh token rather than storing a chain that cannot be continued. Claude-Session: https://claude.ai/code/session_012c8WU6fhQDaVekXJX5hGhH
resolve_token now tries, in order: the caller's override, grans's stored credentials (refreshing when the access token has expired), then the token Granola's desktop app stored locally. The local store stays last rather than being removed, since it is still the only source on Windows and on older macOS builds for anyone who has not logged in. A failed refresh is reported, not quietly downgraded to the local store: if the user logged grans in, a broken chain is a real problem and the message says to run `grans auth login` again. Granola rotates the refresh token on every call, so two concurrent grans runs can race, with the loser holding a token the winner already consumed. Rather than declare the chain dead, re-read the file: if it moved on, the other run succeeded and its result is usable. An unchanged file means the failure is genuine and is not retried against the same dead token. GRANS_TOKEN is read once in main, at the boundary, and threaded down as a value, so no code below touches process-global state and the precedence stays testable. `grans admin token` now prints what the chain resolves rather than only what Granola stored. Claude-Session: https://claude.ai/code/session_012c8WU6fhQDaVekXJX5hGhH
`grans auth login` runs Granola's PKCE flow in a browser and stores the resulting session. The callback is a granola:// URL that both macOS and Windows route to Granola.app, so the login prints instructions to cancel the app-open dialog and paste the URL back. That dialog is not just awkward: letting Granola open the link consumes a code bound to grans's PKCE verifier, which fails inside Granola and leaves grans holding a dead code with no visible cause. `--refresh-token-stdin` bootstraps from an existing refresh token without a browser, off the shell history and out of the process list. `grans auth status` reports presence and expiry only. It has a test asserting no token material reaches the output, since that is the kind of thing a later convenience edit quietly breaks. `grans auth logout` removes the local credentials and says plainly that the session stays alive in Granola until revoked there. The global --token help no longer claims the token comes from Granola's config, which stopped being the whole story when the chain grew. Claude-Session: https://claude.ai/code/session_012c8WU6fhQDaVekXJX5hGhH
The README told macOS users to expect a Keychain prompt and choose "Always Allow". That prompt cannot succeed on current builds: Granola's data-encryption key moved into the data-protection keychain behind its own code signature, so the instruction sent people looking for a dialog that will not appear. Replaces it with the resolution order, the `grans auth` commands, the paste step and why cancelling the app-open dialog matters, and the GRANS_GRANOLA_VERSION escape hatch for a rejected client version. Claude-Session: https://claude.ai/code/session_012c8WU6fhQDaVekXJX5hGhH
auth.rs had grown to 601 lines holding two jobs: deciding which token grans uses, and knowing how to find and decrypt the one Granola's desktop app left on disk. The second is now api::local_store, leaving auth.rs as the resolution order and nothing else. This also matches how the two are expected to age. Local-store scavenging is a legacy fallback that macOS has already closed off and Windows may follow; the resolution chain is where new sources get added. Pure move, no behavior change: 821 tests pass before and after. Claude-Session: https://claude.ai/code/session_012c8WU6fhQDaVekXJX5hGhH
The callback was assumed to be granola://login-complete?code=..., taken from issue #89's reading of the desktop client. The live flow ends somewhere else first: a https://www.granola.ai/app-redirect?code=... page that then hands off to the deep link. That page is what the browser shows and what the address bar offers to copy, and grans rejected it. Both URLs carry the same code, so parse_callback_code now takes the code parameter from any URL instead of enforcing a scheme. The likeliest mistake left is pasting the auth URL grans printed, which has a code_challenge but no code, so that error names the URL to copy instead. Instructions in the command, --help and the README described the deep link too, and now describe the address bar. Claude-Session: https://claude.ai/code/session_012c8WU6fhQDaVekXJX5hGhH
…re absent The code exchange returns 200 with a body that has no workos_tokens key and no top-level access_token, so both paths assumed from issue #89 missed. The failure said only "did not match the expected token shape", which is the least useful thing it could have said. Now the extraction looks for the object carrying an access_token/refresh_token pair, searching WorkOS-named keys first: a response can carry the identity provider's tokens alongside Granola's, and serde_json orders keys alphabetically, so "google_tokens" would otherwise win. Depth is bounded so a hostile or deep response cannot recurse away. When no pair is found, the error prints the response's shape: keys and value types, never values, since these bodies carry token material. The exchange also now echoes the callback's own platform, isDev, sso and signInClickId rather than grans's guesses at them. The callback restates the terms the login ran under, and the server's own values beat ours. Claude-Session: https://claude.ai/code/session_012c8WU6fhQDaVekXJX5hGhH
Issue #89 warned that refresh-access-token returns a rotated refresh token which must be persisted or the chain breaks, and the comments stated that as fact. A live refresh returns the same refresh token with a new access token and expiry. The code already persisted whatever the response carried, so nothing changes behaviorally, and the concurrent-rotation handling stays: it costs one file read on a path that already failed, and covers Granola turning rotation on later. Only the comments were wrong. Claude-Session: https://claude.ai/code/session_012c8WU6fhQDaVekXJX5hGhH
The architecture map still said auth.rs reads the token from Granola's supabase.json, which is now four modules with a resolution order between them. Claude-Session: https://claude.ai/code/session_012c8WU6fhQDaVekXJX5hGhH
|
Claude finished @dmwyatt's task in 2m 51s —— View job Code Review
Reviewed the full diff: Particular attention went to the highest-risk logic: the token-resolution/refresh chain and its concurrent-rotation handling in Note: I was unable to run Code review: no issues found. |
|
Claude finished @dmwyatt's task in 2m 29s —— View job Security Review complete
FindingsPosted 1 inline comment (scored ≥75 confidence):
Everything else reviewed — response-body inclusion in |
| let temp_path = path.with_extension("toml.tmp"); | ||
| fs::write(&temp_path, &content) | ||
| .with_context(|| format!("Failed to write {}", temp_path.display()))?; | ||
|
|
||
| #[cfg(unix)] | ||
| { | ||
| use std::os::unix::fs::PermissionsExt; | ||
| fs::set_permissions(&temp_path, fs::Permissions::from_mode(0o600)) | ||
| .with_context(|| format!("Failed to set permissions on {}", temp_path.display()))?; | ||
| } |
There was a problem hiding this comment.
Refresh-token file briefly written with world/group-readable permissions (TOCTOU).
fs::write creates auth.toml.tmp with the process's default (umask-derived) mode — typically 0644 — and 0600 is applied only after the content, including the plaintext refresh token, is already on disk. Any other local user able to traverse data_dir() (commonly ~/.local/share/grans, not 0700 by default) can read the token during that window, e.g. by watching for the file with inotify and reading it before the chmod lands.
Fix by creating the file with the restrictive mode from the start instead of tightening it after the fact:
| let temp_path = path.with_extension("toml.tmp"); | |
| fs::write(&temp_path, &content) | |
| .with_context(|| format!("Failed to write {}", temp_path.display()))?; | |
| #[cfg(unix)] | |
| { | |
| use std::os::unix::fs::PermissionsExt; | |
| fs::set_permissions(&temp_path, fs::Permissions::from_mode(0o600)) | |
| .with_context(|| format!("Failed to set permissions on {}", temp_path.display()))?; | |
| } | |
| let temp_path = path.with_extension("toml.tmp"); | |
| #[cfg(unix)] | |
| { | |
| use std::io::Write as _; | |
| use std::os::unix::fs::OpenOptionsExt; | |
| let mut file = fs::OpenOptions::new() | |
| .write(true) | |
| .create(true) | |
| .truncate(true) | |
| .mode(0o600) | |
| .open(&temp_path) | |
| .with_context(|| format!("Failed to open {}", temp_path.display()))?; | |
| file.write_all(content.as_bytes()) | |
| .with_context(|| format!("Failed to write {}", temp_path.display()))?; | |
| } | |
| #[cfg(not(unix))] | |
| fs::write(&temp_path, &content) | |
| .with_context(|| format!("Failed to write {}", temp_path.display()))?; |
save_to wrote the refresh token with fs::write, which creates the file at the umask default, then chmod'd it to 0600. Between those two calls the token was readable by anyone who could traverse the data directory, which is not itself 0700. The permissions now come from the open. Any leftover temp file is removed first, since the mode applies only when the open creates the file and reusing one would silently keep its old permissions, and create_new then refuses to write through anything that appears in between. Windows is unchanged and still stores the token unprotected: the file inherits the directory ACL. Moving it into the platform keychain is worth a follow-up. Found by the security review on #90. Claude-Session: https://claude.ai/code/session_012c8WU6fhQDaVekXJX5hGhH
|
Claude finished @dmwyatt's task in 1m 38s —— View job Security Review complete
FindingsThe previously flagged TOCTOU issue is fixed. Commit Reviewed the rest of the diff ( Security review: no issues found in this PR. |
|
Claude finished @dmwyatt's task in 1m 41s —— View job Code Review
Re-reviewed the full diff against Traced the recursion/depth-limit logic in Code review: no issues found. |
|
Fixed in 34b57af. The permissions now come from the Writing the test for it surfaced a second hole in the same fix: Three unix-only tests cover it, confirmed running on CI (the dev machine here is Windows, where they compile out): Two things worth recording rather than fixing here:
On Windows the file inherits the directory ACL and the refresh token sits there unprotected, which |
This PR is what gives grans a long-lived secret. Before it, grans read an access token out of Granola's encrypted store on each run and kept nothing. Writing a refresh token to a plaintext file instead is a step down from what it replaced: a refresh token mints access tokens until the session is revoked, and unlike Granola's DPAPI-wrapped store, a copy of the file taken from a backup or disk image works anywhere. So the session now goes in the platform keychain: Windows Credential Manager, the macOS Keychain, or the Secret Service on Linux. Storage moves behind a CredentialStore with the file as a second backend, which also gives the refresh chain a seam its tests use instead of a path. The file is not gone, because a headless Linux box or a bare WSL install has no keychain and failing there would break setups that work today. That fallback is announced at login and in `grans auth status`, and says plainly what it does and does not protect. When a keychain does become available, the next command moves the credentials in and deletes the file. Keychain reachability is settled by reading, not by constructing an entry: a missing Secret Service or a locked keychain fails only on access. Verified on Windows: an existing auth.toml migrated into Credential Manager, the file was removed, the session survived, and sync continued to work. Claude-Session: https://claude.ai/code/session_012c8WU6fhQDaVekXJX5hGhH
|
Claude finished @dmwyatt's task in 2m 17s —— View job Security Review complete
This push adds one new commit on top of what was already reviewed ( Checked specifically:
No new findings at or above the 75-confidence bar for this delta. Security review: no issues found in this PR. |
|
Claude finished @dmwyatt's task in 2m 43s —— View job Code Review
Reviewed the commit added since the last review pass, Particular attention went to:
Code review: no issues found. |
Closes #89.
Granola moved its data-encryption key into the macOS data-protection keychain, gated on its own code signature, so
grans synccan no longer read the token the desktop app stored. No amount of better decryption fixes that. grans now holds its own session.What this adds
Token resolution order, with the local store kept last rather than removed, since it is still the only source on Windows and older macOS builds for anyone who has not signed in:
--token, orGRANS_TOKENA failed refresh is reported, not quietly downgraded to the local store. If you signed grans in, a broken chain is a real problem and the message says to run
grans auth loginagain.Where the credentials live
This PR is what gives grans a long-lived secret. Before it, grans read an access token out of Granola's encrypted store on each run and kept nothing of its own. A refresh token is a bigger deal than the six-hour access token it replaces: it mints new access tokens until the session is revoked.
So it goes in the platform keychain, which
grans auth statusnames:An earlier revision of this branch wrote it to a
0600TOML file instead. That was a step down from what it replaced: unlike Granola's DPAPI-wrapped store, a plaintext file copied from a backup or disk image is a working credential anywhere.The file remains as a fallback, because a headless Linux box or a bare WSL install has no keychain and failing there would break setups that work today. It is announced at login and in
auth status, and says what it does and does not protect. When a keychain does become available, the next command moves the credentials into it and deletes the file.Keychain reachability is settled by reading, not by constructing an entry: a missing Secret Service or a locked keychain fails only on access.
On macOS the keychain item is bound to the binary that created it, so a rebuilt or self-updated grans may prompt for access again.
What the live API actually does
Four things in #89 turned out differently once tested against the real endpoints. All are now covered by tests.
The callback is not a
granola://URL. The login ends onhttps://www.granola.ai/app-redirect?code=..., which then hands off to the deep link. That page is what the browser shows and what the address bar offers to copy. Both carry the same code, so parsing takes thecodeparameter from any URL instead of enforcing a scheme.The exchange response nests the tokens somewhere neither assumed path found. Extraction now looks for the object carrying an
access_token/refresh_tokenpair, searching WorkOS-named keys first, because a response can carry the identity provider's tokens alongside Granola's andserde_jsonorders keys alphabetically. When nothing matches, the error prints the response's shape, keys and value types only, never values.Refresh does not rotate the refresh token. #89 warned that it does and that failing to persist the rotation breaks the chain. A live refresh returns the same refresh token with a new access token and expiry. The code persists whatever comes back either way, so this changed only comments. The concurrent-rotation handling stays: one store read on a path that already failed, covering Granola turning rotation on later.
The version gate is about a minimum, not just presence. No version redirects to
granola.ai/upgrade-app, and so does0.0.1, but6.518.0still passes. A preflight request with redirects disabled catches that before a browser opens, since in a browser it just looks like a marketing page:Verified on Windows
Signed in through the browser, then:
The migration removed the file, the session survived, and Windows lists the entry as
LegacyGeneric:target=granola-session.grans.Also verified:
--refresh-token-stdinbootstrap; a dead refresh token surfacingCould not refresh grans's Granola session. Run 'grans auth login' to sign in again.; the local-store fallback still working afterauth logout, so the Windows path has not regressed; and both--providervalues mapping server-side toGoogleOAuthandMicrosoftOAuth.Not verified: macOS, which is the platform the P0 is about. Nothing in the login flow is platform-specific, but that claim needs a real run there. Completing a Microsoft sign-in end to end, which needs a Microsoft-linked account; everything after the redirect is the same code path Google exercised. And a refresh against the keychain backend specifically, which is proven by composition, since the refresh logic is tested against the file backend and
store.save()on the keychain is exercised by login and migration.Notes
src/api/auth.rsreached 601 lines, so reading Granola's local token store moved tosrc/api/local_store.rs;credentials.rsthen reached 528, so storage moved tocredential_store.rs, leaving the credential type separate from where it is kept. Both were pure moves with the suite passing on either side.src/pkce.rs, parameterized by entropy: Dropbox keeps 64 bytes, Granola sends 32 to match its client.src/api/identity.rs, overridable viaGRANS_GRANOLA_VERSION. Moving 6.518.0 to 7.441.6 was checked against the data endpoints by syncing.0600from the open rather than chmod'd afterward, which closed a window where the token was briefly world-readable. Found by the security review on this PR.src/sync/config.rsstores the Dropbox refresh token with the write-then-chmod pattern this PR replaced, and in plaintext. Untouched here to keep the diff honest, but it deserves the same treatment.831 unit tests and 67 integration tests pass. No screenshots: every change here is terminal text, which reads better as the code blocks above.
https://claude.ai/code/session_012c8WU6fhQDaVekXJX5hGhH