[codex] Implement Google OAuth refresh lifecycle - #4174
Conversation
There was a problem hiding this comment.
Code Review
This pull request implements automatic token refresh capabilities for Google OAuth credentials, introducing a ProviderBackedCredentialAccountService to coordinate token updates and updating the GSuite executor to automatically refresh expired tokens and retry failed requests. It also adds a delete method to the SecretStore trait to clean up orphaned secrets during failed OAuth callback completions. The review feedback highlights several key improvement opportunities: grouping HTTP 5xx errors under BackendUnavailable to avoid permanent credential failure states during temporary outages, raising the log level to warn for secondary cleanup failures, optimizing GSuite response checks to avoid redundant JSON parsing, removing a redundant read check before deleting secrets, and ensuring all secrets are cleaned up in delete_tokens even if an individual deletion fails.
henrypark133
left a comment
There was a problem hiding this comment.
Code Review (multi-agent)
Intent: Implement Google OAuth refresh-token grant with host-mediated egress, credential lifecycle management, and retry-on-expired-auth for GSuite extensions.
Stats: 17 findings (from 26 raw, 17 after dedup) across 6 files. Reviewers run: security, bugs, performance, tests, conventions, local-patterns, maintainability, pattern-refactor. Reviewers failed: none. Body-only: 1 (one Low demoted under the 15-inline cap).
Substantive PR with real correctness risks in the refresh lifecycle. Two independent reviewers (bugs + security) converged on the destructive-consume bug, and bugs + performance both flagged the TOCTOU race — those are the headline issues. REQUEST_CHANGES driven by the High-confidence refresh-token data-loss path and the concurrency race.
Bugs
- High
load_refresh_tokenconsumes the refresh secret before new tokens are stored (crates/ironclaw_reborn_composition/src/google_oauth/secret_sink.rs:200-214, confidence 90) — anchor: secret_sink.rs:207.lease_once + consumedestroys the old refresh token; ifstore_refreshed_tokensthen fails, the account keeps a dead handle and can never auto-recover. A transient network blip during refresh permanently locks the user out until re-auth. Also flagged by security/Medium, performance/High. - Medium Refresh handle keyed on
scope.invocation_idorphans access secrets every refresh cycle (secret_sink.rs:242-251, confidence 80) — anchor: secret_sink.rs:246. New key per request; prior access secret never deleted → unbounded orphaned live tokens. Also flagged by security/Low, local-patterns/Nit (different error mapper than siblingstore_tokens). - Medium Retry response not re-checked for auth expiry — a 401 on retry (or on the AddAttendees PATCH leg) is silently returned as successful output (
crates/ironclaw_first_party_extensions/src/gsuite/handlers.rs:96-102, confidence 78) — anchor: handlers.rs:100. Also flagged by performance/Medium. - Medium
delete_tokensaborts on first failure, leaving remaining handles undeleted (secret_sink.rs:216-228, confidence 75) — anchor: secret_sink.rs:221. Partial cleanup leaves a dangling refresh secret. Also flagged by performance/Medium.
Security
- High 401 retry re-resolves the account (may pick a different account for the scope) and has no rate limit on attacker-triggerable refresh cycles (
handlers.rs:81-102, confidence 75) — anchor: handlers.rs:83. Quota-burn + potential cross-account credential use. - Medium
is_google_auth_expired_responseroutes auth decisions off the untrusted Google response body; a crafted{"error":{"status":"UNAUTHENTICATED"}}on any status triggers refresh (handlers.rs:285-302, confidence 75) — anchor: handlers.rs:290. Gate on HTTP 401 only.
Performance / Concurrency
- High TOCTOU race in
refresh_account: non-atomic read-refresh-reread compare-and-swap; concurrent refresh for the same account corrupts status and orphans a valid token (crates/ironclaw_auth/src/credential.rs:665-755, confidence 82) — anchor: credential.rs:709. Add optimistic versioning or a per-account lock. Also flagged by security/Medium. - Medium
spawn_blockingwraps HTTP egress per token request — blocking-pool exhaustion risk under concurrent refreshes if egress is async-backed (crates/ironclaw_reborn_composition/src/google_oauth/client.rs:253-255, confidence 75) — anchor: client.rs:253.
Tests
- High
GoogleProviderClient::refresh_tokensystem-scopeCrossScopeDeniedguard has no test (client.rs:215-217, confidence 90) — anchor: client.rs:215. - High
GoogleProviderClient::cleanup_exchangehas no unit test; only a mock exists (client.rs:285-298, confidence 85) — anchor: client.rs:285. - Medium
add_attendees401-on-GET refresh+retry path untested (multi-step capability; "Test Through the Caller") (handlers.rs:226-231, confidence 80) — anchor: handlers.rs:229. Also flagged by conventions/Medium. - Medium System-ownership refresh rejection untested through the production
ProviderBackedCredentialAccountService(credential.rs:758-771, confidence 75) — anchor: credential.rs:770.
Conventions
- Medium
unwrap_or_elseonproject_credential_recoveryI/O call lacks// silent-okjustification and discards the error (credential.rs:598-601, confidence 80) — anchor: .claude/rules/error-handling.md.
Maintainability / Pattern
- Medium
ProviderBackedCredentialAccountServicegrowscredential.rsto 831 lines — extract to its own file (credential.rs:524-831, confidence 75) — anchor: credential.rs:524. Also flagged by local-patterns/Nit (missing doc comment). - Medium Lift refresh-and-retry into a decorator wrapping capability execution; dissolves the sentinel
Ok(expired_response)contract and the two duplicate inspection points (handlers.rs:60-117, confidence 75) — anchor: handlers.rs:81. - Low (body-only)
with_provider_clientwrapping duplicated across two parallel builder structs (crates/ironclaw_reborn_composition/src/auth.rs:328-336, confidence 60) — anchor: auth.rs:328. Apply on the ports struct only and carry throughinto_services.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 217a6cfa76
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
henrypark133
left a comment
There was a problem hiding this comment.
Code Review (multi-agent)
Intent: Implement Google OAuth refresh-token lifecycle — concrete refresh grant via host-mediated egress + fixed Google token-endpoint policy; provider-backed credential refresh through the auth-owned ProviderBackedCredentialAccountService, preserving the existing refresh handle when Google omits a replacement; compensating cleanup for callback persistence failures + shared secret-store delete; retry GSuite capability once after expired-auth while keeping insufficient-scope intact. Closes #4160.
Stats: 11 findings (from ~17 raw, after dedup; 1 reviewer claim verified false and dropped — see note) across 5 files. Reviewers run: security, bugs, performance, tests, conventions, local-patterns, maintainability, pattern-refactor. Reviewers failed: none. Inline comments suppressed — all 23 files are modifications to large existing files where diff-line anchors don't resolve cleanly; exact file:line anchors are below. Diff truncated for reviewer context (122 KB / 23 files); reviewers had full worktree access. REQUEST_CHANGES driven by finding #1.
Dropped (verified false): a reviewer flagged
load_refresh_token'slease_once+consumeas destroying the refresh token before the HTTP call. Verified againstironclaw_secrets:consumemarks the lease consumed and drops retained lease material, but the underlying secret persists (get_decrypted, not delete). The refresh token survives a failed exchange. Not a bug.
Bugs / Security — High
- High —
store_refreshed_tokenscompensatingdeletewipes the freshly-written valid access token (crates/ironclaw_reborn_composition/src/google_oauth/secret_sink.rs:186, bugs conf 90 / security conf 75). Unlikestore_tokens(whose handle is unique perflow_id+invocation_id, so cleanup is safe), the refresh path's handle is fixed per account (google_refresh_token_handle→google-oauth-refresh-{kind}-{account_id}). Line 174 overwrites the live access secret with the new token; if the refresh-tokenputthen fails, line 186 deletes that fixed handle — destroying the just-issued valid access token and leaving the account's persisted record pointing at a now-missing secret. Recovery is not guaranteed: the GSuite retry-once path only fires on HTTP 401, not on a "unknown secret" load error, so the account can stay broken until something else forces a refresh. Fix: on the refresh path, do not delete the access secret on refresh-write failure (the new access token is valid and usable) — or write the refresh token before the access token so a partial failure leaves the prior access token intact. Also flagged by: tests/High — this compensating branch has zero coverage (the analogousstore_tokenscleanup has two tests).
Performance / Security — Medium
- Medium —
ProviderBackedCredentialAccountService.refresh_locksis aMutex<HashMap<CredentialAccountId, Arc<Mutex<()>>>>that inserts on first refresh and never evicts (crates/ironclaw_auth/src/credential.rs:545, perf 85 / security 80 / conventions 75). Grows unbounded over process lifetime per distinct account; CLAUDE.md: in-memory caches must be evicted. Fix: drop the entry after the guard releases whenArc::strong_count == 1. - Medium — Both
exchange_callbackandrefresh_tokendispatch egress viatokio::task::spawn_blocking(|| egress.execute(...))(crates/ironclaw_reborn_composition/src/google_oauth/client.rs:172, perf conf 90). Each in-flight token exchange/refresh pins a blocking-pool thread for the full HTTP round-trip (up to 30 s); concurrent refresh load can saturate the pool. Fix: makeRuntimeHttpEgress::executeasync (or use an async HTTP client) and await directly. - Medium — After a 401, the GSuite handler retries via
resolver.resolve_account(credential.account_id)using the pre-refreshaccount_idrather than re-running the fullresolve()selection (crates/ironclaw_first_party_extensions/src/gsuite/handlers.rs:88, security conf 70).resolve_accountdoes not re-validate scope/ownership the wayresolve()does. Fix: re-resolve through the standardresolve()path post-refresh.
Conventions / Tests — Medium
- Medium —
map_secret_store_errorcollapses everySecretStoreErrorvariant intoAuthProductError::BackendUnavailablevia|_error|(secret_sink.rs:258, conventions conf 75); diagnostic detail (crypto vs FS vs precondition) is erased, and the compensating-cleanup paths discard the delete result (let _ =). Fix: preserve the variant in adebug!log at the mapping site so partial-failure orphans are detectable. - Medium —
ProviderBackedCredentialAccountServiceis a concrete runtime service (provider calls,Mutexconcurrency, refresh-serialization policy) living inironclaw_auth, whoseCLAUDE.mdsays "own product-facing auth vocabulary and fake services only" (credential.rs:524, conventions conf 75). The PR body explains the move from composition was deliberate after a subagent review — so this is a boundary-doc gap, not necessarily wrong placement. Fix: amendironclaw_auth/CLAUDE.mdto record the exception (or split into anironclaw_auth_servicescrate). - Medium — The new
SecretStore::deleteis exercised by compensating cleanup but is not in the sharedtests/secret_store_contract.rscontract suite (crates/ironclaw_secrets/tests/secret_store_contract.rs, tests conf 75). A future impl could mis-handle absent-handle return or scope isolation without a contract failure. Fix: add adeletecontract test (idempotency + scoped isolation).
Low / Nit
- Low —
refresh_accountissues up to 3 sequentialget_accountreads per refresh (credential.rs:735, perf conf 75); the post-HTTP success-path read can be dropped since the refresh lock prevents concurrent same-account mutation. - Low — Token-endpoint network policy is staged into the obligation store separately from the hard-coded
GOOGLE_TOKEN_ENDPOINTegress call (client.rs:229, security conf 55); SSRF protection relies onRuntimeHttpEgress::executeconsulting the staged policy — an implicit, type-unenforced invariant. Document/enforce it. - Low —
execute_add_attendeesreturnsOk((401_response, bytes))to signal "retry me" todispatch, splitting the 401→refresh→retry protocol across two functions (handlers.rs:237, local-patterns conf 75). Note: the naive fix (delete the inner 401 check) is unsafe — the inner GET 401 would feed garbage into the PATCH. Restructure the GET+PATCH sequence or return a typed "auth-expired" outcome instead of anOk-with-401 sentinel. - Nit —
is_google_auth_expired_responsehas a dead(200..300)guard (401 is never 2xx); reduce toresponse.status == 401(handlers.rs:298). AndProviderBackedCredentialAccountServiceis exported without a doc comment unlike its documented siblings (credential.rs:524).
Pattern-refactor: no consolidating reframe — confirmed the execute_add_attendees fix is already at the right zoom level and that a naive deletion would be unsafe.
|
Review follow-up pushed in ec695b0. Fixed straightforward items:
Not changing in this PR:
Validation:
|
|
Handled the three remaining direct-impact follow-ups we discussed in
Verification:
|
…eborn-google-refresh-account-update
henrypark133
left a comment
There was a problem hiding this comment.
Code Review (multi-agent) — re-review at head 47a9a7e
Intent: Google OAuth refresh-token lifecycle — host-mediated refresh grant; provider-backed credential refresh via the auth-owned ProviderBackedCredentialAccountService; preserve the existing refresh handle when Google omits a replacement; compensating cleanup + shared secret-store delete; retry GSuite once after expired-auth. Closes #4160.
Stats: Forced re-review (new head, rebased on reborn-integration). 7 reviewers + verification of the prior round. No High/Critical → COMMENT. Inline suppressed (large modified files; anchors below). Diff 142 KB (incl. base merge); reviewers had full worktree access.
✅ Resolved since last review (head 217a6cf)
- High —
store_refreshed_tokenscompensating delete destroying the live access token: FIXED. Line 191 now returns the error without deleting the access secret; regression teststore_refreshed_tokens_keeps_access_secret_when_refresh_write_failslocks it. Verified by 3 reviewers. SecretStore::deletenow in the shared contract suite (secret_store_contract.rs—secret_store_delete_is_idempotent_and_scope_isolated).- Auth-crate boundary (
ProviderBackedCredentialAccountServiceinironclaw_auth) — documented exception added toironclaw_auth/CLAUDE.md. refresh_locksunbounded growth —release_refresh_locknow evicts onArc::strong_count == 1; downgraded to a Low/comment item (see #6).
Remaining — Medium
- Medium —
spawn_blocking(|| egress.execute(...))for the token endpoint pins a tokio blocking-pool thread for the full 30 s timeout, in bothexchange_callbackandrefresh_token(crates/ironclaw_reborn_composition/src/google_oauth/client.rs:175and:259, perf conf 85 / security conf 75). Concurrent exchange/refresh storms can exhaust the pool. Fix: makeRuntimeHttpEgress::executeasync, or bound concurrency with a semaphore.
Remaining — Tests (Medium)
- Medium —
store_refreshed_tokenspartial-failure has a helper-level test, but no caller-level test (realInMemorySecretStore) asserting the access secret is still readable after a refresh-write failure (secret_sink.rs:179, conf 75). Per the "test through the caller" rule, add one throughGoogleProviderClient.refresh_token. - Medium — No test for: first attempt 401→refresh succeeds→retry returns 403 insufficient-scope, asserting the 403 body passes through as a
Responserather than re-triggering an auth error (gsuite/handlers.rs:108, conf 75). - Medium — No test that a refresh failure during the retry path preserves the first-attempt
network_egress_bytesin the returned error usage (gsuite/handlers.rs:92, conf 75).
Remaining — Low / Nit
- Low (contested) — Post-401 retry uses
resolver.resolve_account(account_id)rather than fullresolve()(gsuite/handlers.rs:95, conf ~60). Security flagged it as skippingselect_unique_configured_accountscope re-validation; bugs+conventions reviewers readresolve_accountas still validating scope/extension viarecoverable_lookup, withrefresh_accountvalidating throughvalidate_refresh_target. Likely fine — worth a confirming glance that a scope-narrowed refresh surfaces as a scope error, not a silent under-scoped retry. - Low (informational — do NOT re-add the delete) — On a refresh-token write failure,
store_refreshed_tokensleaves the just-written access secret in the store (orphaned until the next refresh overwrites the deterministicgoogle-oauth-refresh-access-{account_id}handle). Two reviewers suggested "mirrorstore_tokens' compensating delete" — that suggestion is wrong here and would reintroduce the High bug just fixed:store_tokensuses a unique{flow_id}-{invocation_id}handle (safe to delete), but the refresh path's handle is the account's live fixed handle. Current behavior (keep it) is correct. Optional: add a one-line comment explaining why no cleanup runs, so a future editor doesn't "fix" it back into the bug. - Low —
release_refresh_lock'sArc::strong_count == 1eviction is correct under the sharedstd::Mutex(check-and-remove is atomic), but the rationale is non-obvious; add a brief comment (thefilesystem_store.rsFILESYSTEM_RECORD_LOCKSnever-evict comment is the local precedent) (crates/ironclaw_auth/src/credential.rs:562, conf 75). - Low —
refresh_tokenduplicates the ~11-line Arc-clone +spawn_blocking+ error-map block fromexchange_callback(client.rs:259, conf 75); extract a sharedexecute_google_token_requesthelper (mirrorsfirst_party_tools/http.rs'sexecute_runtime_http). - Low —
refresh_account'sOkandRefreshFailed/TokenExchangeFailedarms repeat re-fetch + concurrent-check + update + report (~40 lines) (credential.rs:796, conf 65); extract anapply_provider_outcomehelper. - Nit —
ProviderBackedCredentialAccountServicedoc omits the non-obvious per-account single-flight locking (the reason for theHashMap<_, Arc<Mutex<()>>>) (credential.rs:524, conf 50).
Dropped (verified false, as last round): load_refresh_token's lease_once+consume is not token loss — consume consumes the lease, the underlying secret persists. map_secret_store_error variant-flattening is pre-existing and narrow (SecretExpired unreachable on put).
Good progress — the headline security bug is fixed and the supporting tests/contract/boundary items landed. Remaining items are hardening + coverage, none blocking.
|
No blocking comments merging. |
* fix(reborn): implement google oauth refresh * fix(reborn): address google refresh review * fix(reborn): warn on oauth cleanup failure * fix(reborn): harden google refresh retry * fix(reborn): address google refresh review * fix(reborn): tighten google oauth follow-ups
Summary
Closes #4160.
Validation
cargo test -p ironclaw_auth --all-targets -- --nocapturecargo test -p ironclaw_reborn_composition --all-targets -- --nocapturecargo test -p ironclaw_secrets --all-targets -- --nocapturecargo test -p ironclaw_first_party_extensions --all-targets -- --nocapturecargo clippy -p ironclaw_auth -p ironclaw_reborn_composition --all-targets -- -D warningsgit diff --checkcargo test -p ironclaw_reborn_composition google_oauth::tests::product_auth_refresh_uses_concrete_google_provider_and_updates_account -- --nocaptureNotes
A gpt-5.4-mini subagent reviewed crate/module ownership after the implementation. It initially flagged provider-backed refresh policy living in
ironclaw_reborn_composition; that logic was moved intoironclaw_auth::ProviderBackedCredentialAccountService, leaving Reborn composition as wiring only. The re-check passed with no remaining boundary concerns.