Skip to content

daemon: auto-refresh OAuth token via Claude Code CLI#32

Open
tobby168 wants to merge 1 commit into
HermannBjorgvin:activity-streamfrom
tobby168:pr/daemon-oauth-refresh
Open

daemon: auto-refresh OAuth token via Claude Code CLI#32
tobby168 wants to merge 1 commit into
HermannBjorgvin:activity-streamfrom
tobby168:pr/daemon-oauth-refresh

Conversation

@tobby168

Copy link
Copy Markdown
Contributor

Follow-up from #22 (third item under "Known follow-ups" in the description).

What

The daemon currently reads accessToken from the macOS Keychain / ~/.claude/.credentials.json on every poll, but has no refresh path. After ~8h the cached token expires, every poll starts hitting 401, and the user has to manually run claude in a terminal to renew it.

This PR piggybacks on Claude Code CLI's own OAuth flow rather than reverse-engineering Anthropic's token endpoint:

  • Also pull expiresAt out of the credentials blob (both keychain and file shapes).
  • When the cached accessToken is within 120s of expiry (or already past), spawn claude -p ping and wait up to 60s. The CLI auto-refreshes and writes the new token back to the same store.
  • Re-read the store, log the new expiry, return the fresh token to the existing poll_api call site.
  • Cost: ~1 cheap message per refresh (~every 8h on a healthy token).

Why this approach (vs direct refreshToken use)

  • Daemon already requires Claude CLI to be installed — it's how the keychain entry gets populated in the first place. Zero new dependency.
  • No client_id / OAuth secret to embed or reverse-engineer from the CLI bundle.
  • Adapts automatically if Anthropic changes their OAuth endpoints — the CLI does.
  • Trade-off: tiny per-refresh API cost. Worth it for not maintaining a parallel OAuth implementation.

Failure modes (all fail-open — the daemon never crashes)

  • CLI not on PATH → log once, return stale token, let the API call surface the 401 normally.
  • CLI hangs past 60s → kill, log, return stale token.
  • CLI exits cleanly but expiry didn't advance → log warning, return what was re-read.
  • Raw-token credential shape with no expiresAt → never trigger proactive refresh (callers tolerate the 401).

Test plan

  • Existing read_token()read_credentials() parser still handles all 4 credential shapes (direct, nested under claudeAiOauth, regex-fallback, raw).
  • python3 -c 'import ast; ast.parse(open("daemon/claude_usage_daemon.py").read())' clean.
  • On macOS with claude on PATH and a near-expiry token: [HH:MM:SS] Token expires in 87s; triggering CLI refresh[HH:MM:SS] Token refreshed; new expiry in +8.0h → subsequent polls succeed.
  • With claude removed from PATH: refresh attempt logs "claude CLI not found on PATH; cannot refresh token", returns stale token, next API call returns 401 as before (no regression vs current behavior).

Notes

🤖 Generated with Claude Code

The daemon was reading accessToken from Keychain on every poll but had no
refresh path, so after ~8h the cached token expired and every poll started
hitting 401 until the user manually ran claude in a terminal. Piggyback on
the CLI's own OAuth flow: when expiresAt is within 2 minutes (or already
past), spawn `claude -p ping`, wait up to 60s, then re-read Keychain to
pick up the freshly-persisted token. Costs ~1 message per refresh.
@tobby168

Copy link
Copy Markdown
Contributor Author

Hey @HermannBjorgvin — when you have a moment, would love a review on this one. It's the OAuth-refresh follow-up I called out in the "Known follow-ups" section of #22. Self-contained daemon-only change (~140 lines on claude_usage_daemon.py); piggybacks on Claude Code CLI's own refresh flow rather than rolling our own OAuth, so no new dependency and it auto-adapts to upstream changes. No rush.

@HermannBjorgvin

Copy link
Copy Markdown
Owner

I think we should still leave the refreshing of the token to the user's Claude Code, feels like too much of an unexpected intervention to manage this on behalf of people.

What about just looking at the token expiry time and schedule a re-read of the credentials.json file to see if the access token has updated if we end up with expired credentials?

If the access token expires without refreshing I think we should just stop displaying data until the token is updated, maybe poll it every ~15 minutes or so?

@tobby168

Copy link
Copy Markdown
Contributor Author

I think we should still leave the refreshing of the token to the user's Claude Code

Hear you on the "unexpected intervention" framing, and I want to gently push back because I think the trade-off lands the other way for this case.

The cost of passive polling is more user-visible than it sounds. When the token expires and the user isn't actively using Claude Code, the device freezes on stale data — Activity stops updating, Usage bars stop moving. There's no on-device signal that anything's wrong, it just looks wrong. That state can persist for hours, sometimes overnight or over a weekend. For a desk-side monitor whose entire job is "show me current state at a glance," that's a meaningful failure mode.

The active refresh just sidesteps it — the user never sees the broken state at all.

On the intervention concern, I'd argue the daemon is already pretty intervention-y by design:

Spawning claude -p ping once per ~8h is a smaller delta than any of those, and it's bounded — ~$0.0001/day, well below the noise floor on any plan.

Middle ground if you'd prefer a guarantee: make it opt-out via env var (CLAWD_TOKEN_REFRESH=0 to disable), default on. Anyone who wants the passive behavior flips one variable. I can also add a one-line note to the README + install.sh so there's no surprise. The PR already logs the refresh explicitly when it fires.

Happy to add the opt-out + docs if that lands it. But I'd really prefer not to lose the active refresh path entirely — it's the kind of "this just works" detail that's the whole point of having a dedicated device.

WDYT?

@HermannBjorgvin

Copy link
Copy Markdown
Owner

The access token has a lifetime of something like ~8 hours, at least on my machine that's the time left until it expires.

Thing is the usage window is 5 hours and every call to claude -p opens a new usage window, I personally like to schedule my usage windows so that I can decide at what time during my work day it restarts, so I can schedule two five hour windows either side of my lunch.

That would only leave us sending an API request for a new access token an updating the credentials.json file as far as I can see and that makes me uncomfortable about breaking it for people in the future, we'd need to have a json schema guard around it to make sure we were never messing something up for users.

That's actually a feature idea that I have had for the meter is the ability to schedule a small call to claude -p to start a session when you want for example daily at 7.30am, meaning it would finish the 5 hour window at 12.30pm and you would be good for another 5 hours until 17.30pm.

If the concern is stale data I would suggest just showing 0% instead of --- and hiding the "resets in" text while not receiving data, the end result should be the same that you would start receiving data as soon as the user opens claude code or desktop and starts a usage cycle. Unless I am missing something?

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.

2 participants