Skip to content

Align OAuth pool with Claude CLI: Retry-After, accountId, scope validation, User-Agent, login_hint - #4

Merged
superdav42 merged 2 commits into
mainfrom
feature/oauth-alignment
Apr 4, 2026
Merged

superdav42 merged 2 commits into
mainfrom
feature/oauth-alignment

Conversation

@superdav42

@superdav42 superdav42 commented Apr 4, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Incorporates six alignment improvements identified by comparing the plugin against the aidevops oauth-pool-helper.sh reference implementation.

Changes

High priority

Retry-After header support (429/529)

  • PoolManager::markRateLimited() now accepts a $retry_after_secs parameter that overrides the fixed 5-minute default cooldown.
  • New PoolManager::markRateLimitedFromResponse() helper parses Retry-After (integer seconds or HTTP-date per RFC 7231) from a wp_remote_* response.
  • AnthropicMaxTextGenerationModel::generateTextResult() intercepts 429/529 responses before throwing, calls handleRateLimitResponse() to mark the active account with the server-specified cooldown.
  • AnthropicOAuthRequestAuthentication now tracks the active account email via getActiveEmail() so the model can identify which account to mark.
  • PoolManager::getActiveTokenWithEmail() returns {token, email} together to avoid a second pool lookup.

Medium priority

accountId storage

  • PoolManager::addAccount() accepts an optional $account_id parameter (the account.uuid from the token response).
  • exchangeCode() extracts account.uuid from the account object in the token response and passes it to addAccount().
  • listAccounts() and healthCheck() expose accountId in their output for diagnostics.

user:inference scope validation

  • exchangeCode() checks the scope field in the token response against user:inference before adding the account to the pool.
  • Returns ['scope_error' => true, 'granted_scopes' => [...]] on failure (account is NOT added).
  • REST rest_exchange_code() maps this to HTTP 403 with a clear message prompting re-authorization.

Low priority

User-Agent alignment

  • Token exchange and refresh requests now send claude-cli/2.1.80 (wordpress-plugin) via PoolManager::USER_AGENT, matching the Claude CLI format.
  • TokenRefresher references PoolManager::USER_AGENT instead of a hardcoded string.

Fallback URL comments

  • PoolManager constants block now documents the platform.claude.com/oauth/code/callback fallback redirect URI and the claude.com/cai/oauth/authorize fallback authorize URL, matching the notes in oauth-pool-helper.sh.

login_hint / login_method / org_uuid

  • PoolManager::startOAuthFlow() accepts optional $login_hint, $login_method, and $org_uuid parameters, appending them to the authorize URL when provided.
  • The /authorize REST endpoint exposes these as optional query params with validation.

Testing

All five modified files pass php -l syntax check. Functional testing requires a WordPress environment with a Claude Max account.

Summary by CodeRabbit

  • New Features

    • OAuth authorization now accepts optional login hint, login method preference, and organization UUID
    • Active account email is tracked for OAuth sessions
    • Automatic rate-limit detection using Retry-After header to record cooldowns
  • Bug Fixes

    • Improved handling when granted OAuth scopes are insufficient: returns a 403 with granted-scope details and re-authorization guidance

…ation, User-Agent, login_hint

- Parse Retry-After header (seconds or HTTP-date) on 429/529 responses and
  use it as the pool cooldown duration instead of the fixed 5-minute default.
  Implemented in PoolManager::markRateLimited(), a new markRateLimitedFromResponse()
  helper, and AnthropicMaxTextGenerationModel::handleRateLimitResponse().

- Store accountId (account.uuid from token response) alongside each pool entry.
  Exposed in listAccounts() and healthCheck() output for diagnostics.

- Validate user:inference scope after token exchange. Returns a scope_error
  result when the granted scope list is missing the required scope; the REST
  API surfaces this as HTTP 403 with a clear message before the account is
  added to the pool.

- Update User-Agent to claude-cli/2.1.80 (wordpress-plugin) to align with
  Claude CLI format on token exchange and refresh requests.

- Add alignment comments for REDIRECT_URI fallback (platform.claude.com) and
  AUTHORIZE_URL fallback (claude.com/cai/oauth/authorize).

- Add login_hint, login_method, and org_uuid params to startOAuthFlow() and
  the /authorize REST endpoint, matching Claude CLI optional authorize params.
@coderabbitai

coderabbitai Bot commented Apr 4, 2026 •

Copy link
Copy Markdown

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Walkthrough

Adds optional OAuth authorize query params (login_hint, login_method, org_uuid), surfaces account email with token retrieval, enforces required scope on token exchange, persists account IDs, and implements Retry-After based rate-limit handling that marks individual accounts on cooldown.

Changes

Cohort / File(s) Summary
REST API Authorization Route
inc/rest-api.php
GET /authorize now declares optional login_hint, login_method, org_uuid. rest_start_oauth() signature changed to accept \WP_REST_Request $request and forwards those params to PoolManager::startOAuthFlow(). rest_exchange_code() returns HTTP 403 insufficient_scope when exchangeCode() signals a scope error.
OAuth Pool Core
src/OAuthPool/PoolManager.php, src/OAuthPool/TokenRefresher.php
Added USER_AGENT and REQUIRED_SCOPE. startOAuthFlow() accepts optional login_hint, login_method, org_uuid and conditionally includes them in the authorize URL. exchangeCode() validates granted scopes (returns scope_error on mismatch), extracts account.uuid into accountId, and persists it via updated addAccount() (now accepts optional $account_id). Introduced getActiveTokenWithEmail(), expanded listAccounts()/healthCheck() to include accountId, and enhanced rate-limit APIs: markRateLimited($retry_after_secs?) and markRateLimitedFromResponse(). Token refresh uses PoolManager::USER_AGENT.
Request Authentication
src/Authentication/AnthropicOAuthRequestAuthentication.php
authenticateRequest() now calls PoolManager::getActiveTokenWithEmail() and caches the selected account email in a new private ?string $activeEmail. Added public getActiveEmail(): ?string. Authorization header still uses the returned token.
Model: Request & Rate-limit Handling
src/Models/AnthropicMaxTextGenerationModel.php
Uses a local $auth to authenticate requests. Adds handleRateLimitResponse($auth, $response) and parseRetryAfterHeader($response) to detect 429/529 responses, extract Retry-After, retrieve the active email from the auth object, and call the pool to mark that account rate-limited. Calls handleRateLimitResponse() before throwing on non-success responses.

Sequence Diagram(s)

sequenceDiagram
    participant Model as AnthropicMaxTextGenerationModel
    participant Auth as AnthropicOAuthRequestAuthentication
    participant PM as PoolManager
    participant API as Anthropic API

    Model->>Auth: authenticateRequest()
    Auth->>PM: getActiveTokenWithEmail()
    PM-->>Auth: {token, email}
    Auth->>Auth: cache email
    Auth-->>Model: Authorization header (Bearer token)

    Model->>API: send request with token
    API-->>Model: 429/529 + Retry-After

    Model->>Model: handleRateLimitResponse($auth, $response)
    Model->>Auth: getActiveEmail()
    Auth-->>Model: email
    Model->>Model: parseRetryAfterHeader($response)
    Model->>PM: markRateLimited(email, retry_after_secs)
    PM->>PM: update account cooldown
Loading
sequenceDiagram
    participant Client as Client/Browser
    participant REST as REST API Handler
    participant PM as PoolManager
    participant OAuth as OAuth Provider

    Client->>REST: GET /authorize?login_hint=...&login_method=...&org_uuid=...
    REST->>PM: startOAuthFlow(login_hint, login_method, org_uuid)
    PM->>PM: build auth URL with provided params
    PM-->>REST: redirect URL
    REST-->>Client: redirect to OAuth provider

    Client->>OAuth: user authenticates
    OAuth-->>Client: authorization code
    Client->>REST: POST /authorize (code)
    REST->>PM: exchangeCode(code)
    PM->>OAuth: exchange code for token
    OAuth-->>PM: {access_token, scope, account.uuid, email?}
    PM->>PM: validate scope contains REQUIRED_SCOPE
    alt scope missing
        PM-->>REST: {scope_error, granted_scopes}
        REST-->>Client: 403 insufficient_scope
    else scope ok
        PM->>PM: addAccount(token, accountId)
        PM-->>REST: success
        REST-->>Client: success response
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰
New query springs, I hop and cheer,
Tokens paired with emails near.
Scopes are checked, Retry-After heard,
Accounts cool down at my word.
Hoppity-hop—OAuth fields are stirred!

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title directly and specifically summarizes the main changes: alignment improvements to the OAuth pool regarding Retry-After, accountId, scope validation, User-Agent, and login_hint parameters.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/oauth-alignment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (1)
src/Authentication/AnthropicOAuthRequestAuthentication.php (1)

74-83: Reset activeEmail before each lookup.

If getActiveTokenWithEmail() returns null, the previous request's email stays cached and getActiveEmail() can report the wrong account until the next successful authentication.

Suggested change
 public function authenticateRequest(Request $request): Request
 {
+        $this->activeEmail = null;
         $result = $this->pool->getActiveTokenWithEmail();

         if ($result === null || empty($result['token'])) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Authentication/AnthropicOAuthRequestAuthentication.php` around lines 74 -
83, Reset the cached active email before attempting to fetch a token: in
AnthropicOAuthRequestAuthentication (the method calling
$this->pool->getActiveTokenWithEmail()), set $this->activeEmail = null prior to
calling getActiveTokenWithEmail() so that a null result does not leave the
previous email cached; if getActiveTokenWithEmail() returns a result, then set
$this->activeEmail = $result['email'] as you already do, otherwise keep it null
so getActiveEmail() returns the correct state.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/OAuthPool/PoolManager.php`:
- Around line 190-229: The selection loop in PoolManager is choosing the oldest
account regardless of expiry, so an already-expired account can be picked and a
failed TokenRefresher.refresh causes an immediate null return; change selection
to prefer healthy, non-expired active tokens first (check $account['expires']
against $now_ms and $account['status'] === 'active' and non-empty access) and
only if none exist choose a refreshable candidate (has non-empty 'refresh' and
expired); implement two-pass selection or track both $best_active and
$best_refreshable; when a refresh attempt on $best_refreshable fails, mark its
status ('refresh-failed') and continue scanning/try the next candidate instead
of returning null so a healthy token (if any) can still be returned.
- Around line 442-446: The current check treats Retry-After: 0 as no header and
falls back to DEFAULT_COOLDOWN_MS; change the condition so any non-null
$retry_after_secs (including 0) takes precedence: if ($retry_after_secs !==
null) set $cooldown_ms = $retry_after_secs * 1000; otherwise if ($cooldown_ms
=== null) set DEFAULT_COOLDOWN_MS. Update the logic around the variables
$retry_after_secs, $cooldown_ms and use self::DEFAULT_COOLDOWN_MS only when
$retry_after_secs is null.
- Around line 637-646: Remove the unsupported organization query parameter by
deleting the conditional that sets $params['orgUUID'] based on $org_uuid;
specifically, remove the block that checks if ($org_uuid !== null && $org_uuid
!== '') and the assignment $params['orgUUID'] = $org_uuid so the authorize URL
is not sent an orgUUID parameter. Keep the other optional param blocks
(login_hint, login_method) unchanged and ensure no other code references
$params['orgUUID'] or $org_uuid for the authorization request.

---

Nitpick comments:
In `@src/Authentication/AnthropicOAuthRequestAuthentication.php`:
- Around line 74-83: Reset the cached active email before attempting to fetch a
token: in AnthropicOAuthRequestAuthentication (the method calling
$this->pool->getActiveTokenWithEmail()), set $this->activeEmail = null prior to
calling getActiveTokenWithEmail() so that a null result does not leave the
previous email cached; if getActiveTokenWithEmail() returns a result, then set
$this->activeEmail = $result['email'] as you already do, otherwise keep it null
so getActiveEmail() returns the correct state.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9e63aee8-b201-4758-83d3-f5175e6151e8

📥 Commits

Reviewing files that changed from the base of the PR and between 2509e0b and 067af82.

📒 Files selected for processing (5)
  • inc/rest-api.php
  • src/Authentication/AnthropicOAuthRequestAuthentication.php
  • src/Models/AnthropicMaxTextGenerationModel.php
  • src/OAuthPool/PoolManager.php
  • src/OAuthPool/TokenRefresher.php

Comment on lines +190 to +229
foreach ($pool['accounts'] as $index => $account) {
$status = $account['status'] ?? 'idle';
if ($status === 'rate-limited') {
continue;
}
if (empty($account['access'])) {
continue;
}
if ($best === null || ($account['lastUsed'] ?? '') < ($best['lastUsed'] ?? '')) {
$best = $account;
$best_index = $index;
}
}

if ($best === null) {
return null;
}

$expires = $best['expires'] ?? 0;
if ($expires > 0 && $expires <= $now_ms && !empty($best['refresh'])) {
$refresher = new TokenRefresher();
$refreshed = $refresher->refresh($best['refresh']);

if ($refreshed !== null) {
$pool['accounts'][$best_index]['access'] = $refreshed['access_token'];
$pool['accounts'][$best_index]['expires'] = $now_ms + ($refreshed['expires_in'] * 1000);
$pool['accounts'][$best_index]['status'] = 'active';
$pool['accounts'][$best_index]['lastUsed'] = gmdate('Y-m-d\TH:i:s\Z');

if (!empty($refreshed['refresh_token'])) {
$pool['accounts'][$best_index]['refresh'] = $refreshed['refresh_token'];
}

$this->savePool($pool);
return ['token' => $refreshed['access_token'], 'email' => $best['email'] ?? ''];
}

$pool['accounts'][$best_index]['status'] = 'refresh-failed';
$this->savePool($pool);
return null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Prefer healthy tokens before refresh candidates.

This scan ignores expiry when picking the “best” account, so an older expired entry can win over a later valid token. If its refresh fails, the method returns null immediately and the new auth path never reaches the healthy account.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/OAuthPool/PoolManager.php` around lines 190 - 229, The selection loop in
PoolManager is choosing the oldest account regardless of expiry, so an
already-expired account can be picked and a failed TokenRefresher.refresh causes
an immediate null return; change selection to prefer healthy, non-expired active
tokens first (check $account['expires'] against $now_ms and $account['status']
=== 'active' and non-empty access) and only if none exist choose a refreshable
candidate (has non-empty 'refresh' and expired); implement two-pass selection or
track both $best_active and $best_refreshable; when a refresh attempt on
$best_refreshable fails, mark its status ('refresh-failed') and continue
scanning/try the next candidate instead of returning null so a healthy token (if
any) can still be returned.

Comment thread src/OAuthPool/PoolManager.php Outdated
Comment thread src/OAuthPool/PoolManager.php Outdated
- AnthropicOAuthRequestAuthentication::authenticateRequest(): reset
  $this->activeEmail = null before getActiveTokenWithEmail() to prevent
  stale email cache when the result is null.

- PoolManager::markRateLimited(): treat Retry-After=0 as authoritative;
  only fall back to DEFAULT_COOLDOWN_MS when $retry_after_secs is null
  (previously the > 0 guard incorrectly skipped the 0 case).

- PoolManager::startOAuthFlow(): remove unsupported orgUUID parameter
  from the authorize URL params; the claude.ai endpoint does not accept
  this parameter. $org_uuid is retained in the method signature for API
  compatibility but is no longer appended to the URL.
@superdav42

Copy link
Copy Markdown
Contributor Author

CodeRabbit review fixes (commit 6bdd5b1)

All three actionable issues from the CodeRabbit review have been addressed:

1. Stale email cache in authenticateRequest()
Reset $this->activeEmail = null before calling getActiveTokenWithEmail(). Previously, if the pool returned null, the previous request's email remained cached and getActiveEmail() would report the wrong account.

2. Retry-After=0 edge case in markRateLimited()
Changed $retry_after_secs !== null && $retry_after_secs > 0 to $retry_after_secs !== null. A server-sent value of 0 is now treated as authoritative (immediate retry); the DEFAULT_COOLDOWN_MS fallback only applies when the header is absent (null).

3. Unsupported orgUUID parameter in startOAuthFlow()
Removed the $params['orgUUID'] = $org_uuid block. The claude.ai authorize endpoint does not accept this parameter. The $org_uuid method parameter is retained for API compatibility but is no longer appended to the URL. Docblock updated accordingly.

Both modified files pass php -l syntax check.


aidevops.sh v3.6.31 plugin for OpenCode v1.3.0 with claude-sonnet-4-6 Overall, 24m since this issue was created.

@superdav42
superdav42 merged commit e1f3833 into main Apr 4, 2026
1 check was pending
@superdav42

Copy link
Copy Markdown
Contributor Author

Completed via PR #4, merged to main.

Merged by deterministic merge pass (pulse-wrapper.sh). No worker summary was available — the worker either crashed before writing one or this PR predates the merge summary convention.

aidevops.sh v3.6.31 spent 8m on this as a headless bash routine.

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.

1 participant