Skip to content

✨ Add Claude OAuth token discovery chain (closes #102)#103

Merged
monkut merged 2 commits into
mainfrom
feature/102-claude-oauth-token-discovery-chain
Apr 30, 2026
Merged

✨ Add Claude OAuth token discovery chain (closes #102)#103
monkut merged 2 commits into
mainfrom
feature/102-claude-oauth-token-discovery-chain

Conversation

@monkut

@monkut monkut commented Apr 30, 2026

Copy link
Copy Markdown
Owner

Summary

Resolves #102 — askcc now resolves CLAUDE_CODE_OAUTH_TOKEN via an explicit discovery chain in ClaudeRunner.run() instead of inheriting whatever the parent process happens to provide. The first non-empty source wins; the chosen source is logged at INFO level so failures are diagnosable from the run log alone.

Discovery chain

  1. CLAUDE_CODE_OAUTH_TOKEN env var — current behavior (no log when used; no regression for working installs).
  2. CLAUDE_OAUTH_TOKEN_FILE env var → read the file at that path.
  3. ~/.tokens/.claude-oauth-token — conventional headless token file.
  4. ${XDG_CONFIG_HOME:-~/.config}/claude/oauth-token — XDG fallback.
  5. ~/.claude/.credentials.json — last-resort parse of claudeAiOauth.accessToken (logs WARNING about staleness because Claude Code refreshes in RAM and may not write back).

If none of the sources produce a non-empty token, askcc raises OAuthTokenNotFoundError, the CLI logs an error listing every checked location, and exits non-zero before invoking claude — replacing the previous opaque 401 with a clear actionable error.

Edge cases handled

  • PermissionError on a token file → WARNING + continue chain.
  • Malformed ~/.claude/.credentials.json (or missing claudeAiOauth.accessToken) → WARNING + continue chain.
  • Trailing whitespace/newline in token files → .strip()-ed before truthiness check.

Files changed

  • askcc/runners.pyOAuthTokenNotFoundError, _resolve_oauth_token(), _read_token_file(), _read_credentials_json(), _xdg_token_path(); wired into ClaudeRunner.run() immediately after the existing env.pop("CLAUDECODE", ...).
  • askcc/cli.py — wraps runner.run(...) in a try/except for OAuthTokenNotFoundError (preserves existing tempfile-cleanup finally:).
  • tests/test_askcc.pyTestResolveOAuthToken (14 tests), TestClaudeRunnerOAuthIntegration (4 tests), TestMainOAuthExitPath (1 test). Existing runner test classes get an autouse fixture that sets CLAUDE_CODE_OAUTH_TOKEN so they remain isolated from host filesystem.
  • README.md — adds the two new env vars to the table and an Authentication subsection documenting the discovery chain.

Out of scope (deferred per the issue)

  • Pre-flight claude -p "ok" validation probe.
  • ANTHROPIC_API_KEY handling.
  • Token refresh / rotation.

Test plan

  • uv run pytest -v — 256 passed
  • uv run poe check — ruff clean
  • uv run poe typecheck — pyright clean
  • Unit tests cover env-var present, each fallback in isolation, malformed credentials.json, unreadable token file, trailing-newline stripping, all-sources-empty error path
  • Runner integration tests assert token injection, INFO log on non-env source, WARNING on credentials.json source
  • CLI exit-path test asserts SystemExit(1) and error in caplog

Resolve CLAUDE_CODE_OAUTH_TOKEN via an explicit discovery chain in
ClaudeRunner.run() instead of relying solely on the parent process env.
Stops at the first non-empty source and logs which source won, so a
401 from the spawned `claude` subprocess is replaced with a clear
"no Claude credentials found in any of: ..." error before the
subprocess is invoked.

Sources, in order:
1. CLAUDE_CODE_OAUTH_TOKEN env (no log when used)
2. CLAUDE_OAUTH_TOKEN_FILE env -> file path
3. ~/.tokens/.claude-oauth-token (conventional headless)
4. ${XDG_CONFIG_HOME:-~/.config}/claude/oauth-token
5. ~/.claude/.credentials.json (logs WARNING about staleness)

PermissionError on token files and JSON parse/schema errors on
credentials.json log a WARNING and continue. File contents are
.strip()-ed.
Comment thread askcc/runners.py Outdated
"""Raised when no Claude OAuth token can be resolved from any source."""


def _read_token_file(path: Path) -> str | None:

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

move function to Runner method

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Done in 9c638ac_read_token_file is now ClaudeRunner._read_token_file (instance method).

Comment thread askcc/runners.py Outdated
return token or None


def _read_credentials_json(path: Path) -> str | None:

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

move function to Runner method

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Done in 9c638ac_read_credentials_json is now ClaudeRunner._read_credentials_json (instance method).

Comment thread askcc/runners.py Outdated
return token or None


def _xdg_token_path() -> Path:

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

move function to Runner method

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Done in 9c638ac_xdg_token_path is now ClaudeRunner._xdg_token_path (instance method).

Comment thread askcc/runners.py Outdated
return base / "claude" / "oauth-token"


def _resolve_oauth_token() -> tuple[str, str]:

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

move function to Runner method

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Done in 9c638ac_resolve_oauth_token is now ClaudeRunner._resolve_oauth_token (instance method); call site in run() updated to self._resolve_oauth_token(). All 4 helpers now live on the runner; test patches updated to patch.object / patch("askcc.runners.ClaudeRunner._resolve_oauth_token"). pytest 256 passed, poe check and poe typecheck clean.

Refactor _read_token_file, _read_credentials_json, _xdg_token_path,
and _resolve_oauth_token from module-level functions to ClaudeRunner
instance methods, per PR review feedback.

Tests updated to use a ClaudeRunner fixture and patch.object for
method-level isolation.
@monkut monkut merged commit d2e1535 into main Apr 30, 2026
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.

feat(auth): discover Claude OAuth credentials via fallback chain when CLAUDE_CODE_OAUTH_TOKEN is missing or stale

1 participant