Skip to content

#89: grans owns its own Granola credentials#90

Merged
dmwyatt merged 14 commits into
mainfrom
feat/89-grans-credentials
Jul 26, 2026
Merged

#89: grans owns its own Granola credentials#90
dmwyatt merged 14 commits into
mainfrom
feat/89-grans-credentials

Conversation

@dmwyatt

@dmwyatt dmwyatt commented Jul 26, 2026

Copy link
Copy Markdown
Owner

Closes #89.

Granola moved its data-encryption key into the macOS data-protection keychain, gated on its own code signature, so grans sync can 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

grans auth login              # Sign in via Granola's PKCE flow
grans auth login --provider microsoft
grans auth status             # Where credentials live, and expiry; never token material
grans auth logout             # Remove the local credentials

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:

  1. --token, or GRANS_TOKEN
  2. grans's own credentials, refreshed when the access token has expired
  3. The token Granola's desktop app stored locally

A 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 login again.

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 status names:

$ grans auth status
Signed in.
  Credentials: Windows Credential Manager (grans)
  Session:     session_01KYE2HB66G210V6XK4W4SY8VY
  Access token: valid until 2026-07-26 03:03:54

An earlier revision of this branch wrote it to a 0600 TOML 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 on https://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 the code parameter 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_token pair, searching WorkOS-named keys first, because a response can carry the identity provider's tokens alongside Granola's and serde_json orders 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 does 0.0.1, but 6.518.0 still passes. A preflight request with redirects disabled catches that before a browser opens, since in a browser it just looks like a marketing page:

$ GRANS_GRANOLA_VERSION=0.0.1 grans auth login
Error: Granola rejected client version 0.0.1 as out of date.
Set GRANS_GRANOLA_VERSION=<current Granola version> and try again.

Verified on Windows

Signed in through the browser, then:

$ grans sync people -v
[DEBUG grans::api::auth] Using grans's own stored credentials
[grans] Fetched 1 people

$ grans sync people -v          # after forcing the stored token to expire
[DEBUG grans::api::auth] Stored access token is missing or expired; refreshing
[grans] Fetched 1 people

$ grans auth status -v          # with credentials still in the pre-keychain file
[DEBUG grans::api::credentials] Moving credentials from ...\auth.toml into the keychain

The migration removed the file, the session survived, and Windows lists the entry as LegacyGeneric:target=granola-session.grans.

Also verified: --refresh-token-stdin bootstrap; a dead refresh token surfacing Could not refresh grans's Granola session. Run 'grans auth login' to sign in again.; the local-store fallback still working after auth logout, so the Windows path has not regressed; and both --provider values mapping server-side to GoogleOAuth and MicrosoftOAuth.

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.rs reached 601 lines, so reading Granola's local token store moved to src/api/local_store.rs; credentials.rs then reached 528, so storage moved to credential_store.rs, leaving the credential type separate from where it is kept. Both were pure moves with the suite passing on either side.
  • PKCE generation moved out of the Dropbox-specific OAuth module into src/pkce.rs, parameterized by entropy: Dropbox keeps 64 bytes, Granola sends 32 to match its client.
  • The client version was hardcoded in two places and needed a third, so it is now src/api/identity.rs, overridable via GRANS_GRANOLA_VERSION. Moving 6.518.0 to 7.441.6 was checked against the data endpoints by syncing.
  • The credential file is created with 0600 from 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.
  • No file lock around refresh. The failure it prevents needs two concurrent grans runs, is loud, and is recoverable by re-login. Easy to add if anyone hits it.
  • src/sync/config.rs stores 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

dmwyatt added 12 commits July 25, 2026 19:07
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

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown

Claude finished @dmwyatt's task in 2m 51s —— View job


Code Review

  • Gather context (read diff, CLAUDE.md, changed files)
  • Review for bugs and code quality issues
  • Post findings

Reviewed the full diff: src/api/granola_auth.rs, src/api/credentials.rs, src/api/local_store.rs, src/api/auth.rs, src/commands/auth.rs, src/pkce.rs, src/api/identity.rs, src/cli/args/mod.rs, src/main.rs, src/api/client.rs, src/commands/sync.rs, src/sync/oauth.rs, plus the CLAUDE.md/README doc updates.

Particular attention went to the highest-risk logic: the token-resolution/refresh chain and its concurrent-rotation handling in src/api/auth.rs, the JSON token-extraction search (locate_tokens/extract_token_set) in src/api/granola_auth.rs, the atomic credential file write in src/api/credentials.rs, and the local_store.rs split (confirmed a pure move via git show 8f67e55/f9a2357 — no behavioral change).

Note: I was unable to run cargo check/cargo test in this sandbox (the Bash tool required interactive approval I don't have here), so I relied on manual tracing rather than compiling. Given the PR author reports 828 unit + 67 integration tests passing, and my read of the logic (recursion depth guards, expiry-skew handling, concurrent-refresh fallback, credentials round-tripping) lines up with that, I don't have reason to doubt it.

Code review: no issues found.

@claude

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown

Claude finished @dmwyatt's task in 2m 29s —— View job


Security Review complete

  • Gather context (diff, changed files)
  • Review auth token handling (supabase.json, local_store.rs, credentials.rs, granola_auth.rs)
  • Review SQL/FTS5 query construction — no db/ changes in this PR, not applicable
  • Review command injection surfaces — none introduced (WSL cmd.exe call in local_store.rs is a pure move, no new user input)
  • Review unsafe Rust blocks — none new; existing unsafe in token_store.rs/update/mod.rs/main.rs untouched by this PR
  • Review path traversal in file/db path resolution — data_dir() unchanged, out of scope
  • Review sensitive data in logs/errors/panics — token values are consistently kept out of debug!/error output (only lengths/expiry logged); describe_expiry has a test asserting no token leakage
  • Review new/changed Cargo dependencies — url, uuid added; no known advisories, no new unsafe/native code surface
  • Post findings

Findings

Posted 1 inline comment (scored ≥75 confidence):

src/api/credentials.rs (lines 93–102) — refresh-token file briefly world/group-readable before permissions are tightened. save_to writes the plaintext refresh token to auth.toml.tmp with fs::write (default umask-derived mode, typically 0644), then calls fs::set_permissions(..., 0o600) afterward. Between those two calls the token sits on disk in a directory (data_dir()) that isn't 0700 by default, so another local user able to traverse it and watching for the file (e.g. via inotify) could read the refresh token before the chmod lands. Suggested fix: open the temp file with OpenOptions::mode(0o600) on unix so it's created with the restrictive permissions from the start, instead of tightening them after content is written.

Everything else reviewed — response-body inclusion in granola_auth::post_json error messages, Debug-derived credential structs, the CLI --token/GRANS_TOKEN resolution, the PKCE implementation, and the local-store fallback — looked sound or below the confidence bar for this review's scope (either not attacker-reachable, or already mitigated, e.g. errors describe JSON shape only, never values).

Comment thread src/api/credentials.rs Outdated
Comment on lines +93 to +102
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()))?;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
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()))?;

Fix this →

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

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown

Claude finished @dmwyatt's task in 1m 38s —— View job


Security Review complete

  • Gather context (diff, changed files)
  • Review auth token handling (supabase.json, local_store.rs, credentials.rs, granola_auth.rs, auth.rs)
  • Review SQL/FTS5 query construction — no db/ changes in this PR, not applicable
  • Review command injection surfaces — none introduced (WSL cmd.exe call in local_store.rs is a pure move, no new user input)
  • Review unsafe Rust blocks — none new in this diff
  • Review path traversal in file/db path resolution — unchanged, out of scope
  • Review sensitive data in logs/errors/panics — token values consistently kept out of debug!/error output; GranolaCredentials is never logged via {:?}
  • Review new/changed Cargo dependencies — url, uuid (already assessed, no advisories)
  • Post findings

Findings

The previously flagged TOCTOU issue is fixed. Commit 34b57af ("Create the credential file private, rather than tightening it after") rewrote src/api/credentials.rs's save_to/create_private_file: the temp file is now opened with OpenOptions::new().write(true).create_new(true).mode(0o600) on unix, so the restrictive permissions apply at creation rather than being applied via a later chmod. create_new also means a pre-existing file or symlink at the temp path (planted between the remove_file cleanup and the open) causes the open to fail rather than being written through. New tests (test_private_file_is_owner_only_from_creation, test_private_file_tightens_permissions_on_reuse) cover this directly. No remaining gap here.

Reviewed the rest of the diff (granola_auth.rs's token-location search and error-shape-only reporting, local_store.rs, auth.rs's refresh/persist chain, commands/auth.rs, identity.rs, pkce.rs using OsRng, client.rs) — nothing else met the ≥75 confidence bar for a concrete, in-scope attack scenario.

Security review: no issues found in this PR.

@claude

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown

Claude finished @dmwyatt's task in 1m 41s —— View job


Code Review

  • Gather context (read diff, CLAUDE.md, changed files)
  • Review for bugs and code quality issues
  • Post findings

Re-reviewed the full diff against origin/main, with particular attention to the commits added since the last pass: the app-redirect callback parsing (parse_callback), the rewritten token-extraction search (locate_tokens/extract_token_set/describe_json_shape) and its depth-bounded recursion, the refresh-token-rotation comment corrections in auth.rs, and the create_private_file fix in src/api/credentials.rs that addresses the TOCTOU finding from the earlier security review.

Traced the recursion/depth-limit logic in locate_tokens and describe_json_shape by hand (including the depth-0 edge cases) and didn't find an off-by-one or early-return bug; the WorkOS-key-preference sort and the JSON-string-decoding path both match their test coverage. The refresh_and_persist/refresh_once concurrent-rotation handling in src/api/auth.rs is unchanged in behavior, only comments were corrected there. create_private_file's remove-then-create_new sequence correctly refuses to follow a symlink planted between the two calls.

Code review: no issues found.

@dmwyatt

dmwyatt commented Jul 26, 2026

Copy link
Copy Markdown
Owner Author

Fixed in 34b57af.

The permissions now come from the open rather than a later chmod, via OpenOptions::mode(0o600) on unix.

Writing the test for it surfaced a second hole in the same fix: OpenOptions::mode applies only when the open creates the file, so a temp file left behind by an interrupted run would have been reused with whatever permissions it already had, and the token written into it. So the leftover is removed first, and create_new(true) then refuses to write through anything, including a symlink, that appears between the remove and the open.

Three unix-only tests cover it, confirmed running on CI (the dev machine here is Windows, where they compile out):

test api::credentials::tests::test_private_file_is_owner_only_from_creation ... ok
test api::credentials::tests::test_private_file_tightens_permissions_on_reuse ... ok
test api::credentials::tests::test_saved_file_is_owner_only ... ok

Two things worth recording rather than fixing here:

src/sync/config.rs stores the Dropbox refresh token with the same write-then-chmod pattern this replaced. It is untouched by this PR, so I have left it alone rather than widening the diff, but it is the same class of bug for the same class of secret and deserves the same fix.

On Windows the file inherits the directory ACL and the refresh token sits there unprotected, which 0600 never addressed either. The real fix is the platform keychain, already noted in the PR description as a follow-up: grans would own that item, so the entitlement problem that blocked reading Granola's does not apply.

https://claude.ai/code/session_012c8WU6fhQDaVekXJX5hGhH

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

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown

Claude finished @dmwyatt's task in 2m 17s —— View job


Security Review complete

  • Gather context (diff since last review, changed files)
  • Review new commit 0368b32 — "Keep the refresh token in the platform keychain"
  • Post findings

This push adds one new commit on top of what was already reviewed (34b57af, which fixed the earlier TOCTOU finding): src/api/credential_store.rs (new CredentialStore abstraction with Keychain/File backends) and the corresponding wiring in src/api/auth.rs and src/commands/auth.rs.

Checked specifically:

  • create_private_file (the previously-fixed TOCTOU logic) moved unchanged into credential_store.rs's file backend — create_new(true) + mode(0o600) still applies at creation, not after the fact, and the fallback path is exercised by the existing test_private_file_* unit tests.
  • Keychain writes (entry.set_password) go straight through the OS-native credential store (Windows Credential Manager / macOS Keychain / Secret Service via zbus-secret-service-keyring-store), bypassing the filesystem entirely — confirmed all three backends are actually compiled in via Cargo.lock (apple-native-keyring-store, windows-native-keyring-store, zbus-secret-service-keyring-store all appear as keyring's resolved dependencies), so this isn't a case of the Cargo feature flag only covering macOS while claiming cross-platform support.
  • absorb_credentials_file (migrates an existing plaintext auth.toml into the keychain) only deletes the file after the keychain save() succeeds, so a failed migration can't strand or lose the credentials.
  • No token material in output: describe(), the login/status/logout paths, and print_no_keychain_warning() only ever print the store's description (a path or "macOS Keychain (grans)"), never token values. reachable_keychain()'s error logging includes only the keyring::Error, not the secret.

No new findings at or above the 75-confidence bar for this delta.

Security review: no issues found in this PR.

@claude

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown

Claude finished @dmwyatt's task in 2m 43s —— View job


Code Review

  • Gather context (diff since last review, CLAUDE.md, changed files)
  • Review new commits for bugs and code quality issues
  • Post findings

Reviewed the commit added since the last review pass, 0368b32 ("Keep the refresh token in the platform keychain"), which moves credential storage behind a new CredentialStore abstraction (src/api/credential_store.rs) with the platform keychain (keyring crate) as primary and the 0600 file as fallback, and the resulting changes to src/api/auth.rs, src/api/credentials.rs (now just the GranolaCredentials struct), src/commands/auth.rs, src/api/mod.rs, Cargo.toml, and the docs.

Particular attention went to:

  • CredentialStore::open/reachable_keychain/absorb_credentials_file — the "test by reading, not constructing" reachability check, and the migrate-then-delete sequencing into the keychain (file only removed after the keychain write succeeds).
  • create_private_file in credential_store.rs — confirmed the TOCTOU fix from the prior security review round survived this refactor unchanged (remove-then-create_new-with-mode(0o600), still covered by the three unix-only tests).
  • The refresh/persist chain in auth.rs (refresh_and_persist/refresh_once/token_from_credentials) — traced the concurrent-rotation retry path by hand; logic and tests are consistent (one nitpick below the confidence bar: a second failed retry after a detected concurrent rotation returns the raw error without the "run grans auth login" hint, but this needs two concurrent refreshes racing and the retry also failing — too narrow to flag).
  • Cargo.lock for the new keyring dependency — confirmed windows-native-keyring-store and zbus-secret-service-keyring-store are both pulled in alongside apple-native-keyring-store, so the "Windows Credential Manager / Secret Service" claims in the docs match what's actually compiled in, not just the macOS backend named in Cargo.toml.
  • src/pkce.rs and src/sync/oauth.rs — confirmed the PKCE extraction is a clean move (Dropbox keeps 64 bytes of entropy, tests moved over intact).

Code review: no issues found.

@dmwyatt
dmwyatt merged commit 40933f4 into main Jul 26, 2026
5 checks passed
@dmwyatt
dmwyatt deleted the feat/89-grans-credentials branch July 26, 2026 14:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

macOS: Granola DEK is entitlement-gated; grans must own its own credentials

1 participant