No task waits on a Codex review; the redesign tasks are confirmed on the product owner's Z Fold7; TASK-016's leftovers are on hold; no biometric reauthentication. Agent: main (opus) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
12 KiB
TASK-029: OAuth refresh-token support (stop re-prompting for consent every session)
Status: implemented; all required checks pass; live device verification pending Owner: Claude Reviewer: Codex
Outcome
The app previously captured Gitea's OAuth refresh token on login but never actually used it: once
the access token's stated expiry passed, RepositoryListViewModel.restoreSession() cleared the
session outright and forced the user through a brand-new authorization_code flow — browser open,
Gitea login, and (per the human product owner's direct complaint, "애플리케이션 승인 안하고 로그인
쭉 이어서 되어있게 해주면 안되나?") the "Authorize this application?" consent screen — every time.
The app now silently exchanges the stored refresh token for a new access token in the background
before it's needed, never opening a browser or showing any Gitea UI, which is what the standard
OAuth refresh_token grant is specifically designed to do without ever re-prompting for consent.
Scope
- Included:
data/auth/AuthApi.kt(AuthenticationGateway.refreshToken, itsAuthApiimplementation, shared token-response parsing extracted fromexchangeCode), newcore/auth/android/RefreshingTokenProvider.kt(the actual refresh-before-stale logic, shared Mutex for single-flight refresh), newcore/auth/android/AuthSession.kt(one sharedTokenVault/RefreshingTokenProviderper process, replacing five independentAndroidKeystoreTokenVault/BearerTokenProviderconstructions),feature/repositories/RepositoryListViewModel.kt(tokenProviderparam,restoreSession()/loadRepositories()no longer short-circuit on a stale clock check), and theFactoryclasses ofCodeBrowserViewModel,HistoryViewModel,WorkItemsViewModel,NotificationsViewModel,RepositoryListViewModel(all five now shareAuthSession's vault/provider instead of each building its own disconnected one). - Excluded: No change to the initial
authorization_codelogin flow itself (TASK-026's Custom Tabs change is untouched), no change to what's stored or how (still the sameAndroidKeystoreTokenVaultencryption, just one shared instance instead of five), no reactive "retry the request once after a 401" fallback — this task is proactive-refresh only (see Known limitations).
Design notes
- Verified against Gitea's own source before implementing, per this project's established
convention for anything not directly provable from the client:
routers/web/auth/oauth2_provider.go'shandleRefreshTokenconfirms therefresh_tokengrant readsgrant_type,refresh_token, andclient_id(noclient_secretneeded for a public/PKCE client — only confidential clients are validated against one), and reuses the sameNewAccessTokenResponseresponse shape as the existing, already-workingauthorization_codeexchange. Gitea's own refresh-token reuse detection (grant.Counter-based) confirms it rotates refresh tokens on each use, which is why the response'srefresh_token(when present) always replaces the one just used, not the other way around. - Proactive, not reactive:
RefreshingTokenProviderrefreshes a token that's within 60 seconds of its stated expiry before handing it out, rather than waiting for an API call to actually fail with a 401 first. This matters specifically because the refresh-aware provider is shared (see below) across five independent feature API clients — proactively refreshing means whichever feature happens to make the next call already sees a fresh token, instead of each one separately racing a token that's already gone stale and only some of them noticing. - One shared
AuthSessionsingleton, not five independent instances: previously everyViewModel.Factorybuilt its ownAndroidKeystoreTokenVaultandBearerTokenProviderfrom scratch, meaning a token refreshed by (say) the repository list would never be visible to the code browser's independent copy.AuthSessionholds exactly oneTokenVaultand oneRefreshingTokenProviderper process; every Factory now pulls from it instead. This also makes the provider's internalMutexactually meaningful — it stops concurrent callers across different features from independently trying to consume the same single-use, rotating refresh token, which would make every refresh but the first one fail. RepositoryListViewModel.restoreSession()'s old "expiresAtEpochMillis already past -> clear and fail immediately" check was removed outright, not adjusted, since it actively worked against refresh support: it fired before the (now refresh-capable) API call ever had a chance to run. The exact same fallback behavior it existed for — clearing the session and surfacingAUTHENTICATION_EXPIRED— still happens today, just now driven by a genuine failed API call (refresh attempted and failed, or no refresh token available) rather than a client-side clock guess made before ever trying.RepositoryListViewModel's constructor gained atokenProviderparameter defaulted to the old no-refresh behavior (BearerTokenProvider { tokenVault.read()?.accessToken.orEmpty() }) specifically so no existing test needed to change — only production wiring (theFactory) explicitly passes the shared refresh-aware one.
Acceptance criteria
AuthApi.refreshTokensendsgrant_type=refresh_token,client_id,refresh_token, and noclient_secret, to the same/login/oauth/access_tokenendpoint as the existing code exchange.- A successful refresh response's
access_token/refresh_token/expires_inare parsed and persisted; a response omittingrefresh_tokenfalls back to reusing the one just sent rather than losing refresh capability entirely. RefreshingTokenProviderreturns the stored token unchanged when it isn't close to expiry, refreshes (and persists the result) once within 60 seconds of expiry, falls back to the stale token — without attempting a network call — when no refresh token is stored, and falls back to the stale token (leaving the vault untouched) when the refresh attempt itself fails.- A token with no
expiresAtEpochMillisat all is never treated as due for refresh. RepositoryListViewModel.restoreSession()authenticates through the injected token provider (not a stale value read once at the top of the function) — proven directly by a test using a deliberately-wrong stored token alongside a provider that returns a different one.- Every existing test continues to pass unmodified except the two direct
AuthenticationGatewayfakes, which now also implement the new interface method. - Build, unit tests, and lint pass.
- Live device confirmation that a session now survives past what used to force a full re-login/re-approval — not yet observed over a long enough idle period this session; see Known limitations.
Security and privacy
- No new data is stored beyond what
TokenSetalready held (refreshTokenwas already part of the persisted, Android-Keystore-encryptedTokenSet; this task is the first code to actually use the field, not the first to store it). - Refresh failures fail closed: any error refreshing falls back to the existing stale token (which then draws a normal 401 from Gitea, handled exactly as before) — never to a broadened scope, an unencrypted fallback, or a silently-extended session past what the server itself allows.
client_secretis deliberately never sent for this public/PKCE client's refresh calls, matching the existingauthorization_codeexchange and Gitea's own documented requirement that only confidential clients need one.
Required checks
./gradlew assembleDebug
./gradlew testDebugUnitTest
./gradlew lintDebug
Evidence
data/auth/AuthApi.kt:AuthenticationGateway.refreshToken(refreshToken, nowEpochMillis);AuthApi's implementation andexchangeCodenow share oneparseTokenResponse(...)helper (fallbackRefreshTokenparameter distinguishes "nothing to fall back to" for a fresh code exchange from "reuse the token just sent" for a refresh).core/auth/android/RefreshingTokenProvider.kt(new): the refresh-before-staleBearerTokenProviderimplementation described above.core/auth/android/AuthSession.kt(new): the shared singleton wiring.feature/repositories/RepositoryListViewModel.kt:tokenProviderconstructor param (defaulted);restoreSession()'s early-expiry short-circuit removed;loadRepositories()takes no arguments and readstokenProvider.accessToken()instead of a capturedTokenSet.accessToken;Factorywired toAuthSession.CodeBrowserViewModel.kt,HistoryViewModel.kt,WorkItemsViewModel.kt,NotificationsViewModel.kt: eachFactorynow callsAuthSession.tokenProvider(application)instead of constructing its ownAndroidKeystoreTokenVault/BearerTokenProviderpair.- New tests:
AuthApiTest.kt(refresh sends the right grant/fields and no client secret, response without arefresh_tokenfalls back to the one sent, blank refresh token rejected before any request);RefreshingTokenProviderTest.kt(not-yet-expired passthrough, refresh-and-persist within the skew window, proactive-before-actual-expiry, no-refresh-token fallback, failed-refresh fallback leaving the vault untouched, empty-string when nothing stored, no-expiry-never-refreshes);RepositoryListViewModelTest.kt(new test provingrestoreSessionauthenticates through the injected provider rather than a raw stored token;FakeAuthGatewayupdated to implement the new interface method and capture the token it was called with). ./gradlew compileDebugKotlin compileDebugUnitTestKotlin: BUILD SUCCESSFUL (one intermediate failure — a second, missedloadRepositories(tokens)call site inauthenticate()after the signature change — found and fixed before this)../gradlew testDebugUnitTest: BUILD SUCCESSFUL, all 231+ unit tests passing (two intermediate failures were bugs in the new tests' own fixture data, not the implementation — fixed before this: one test'sexpiresAtEpochMilliswas accidentally always within the skew window regardless ofnow, and one asserted the vault wasnullafter a failed refresh when "untouched" was the actually-intended and correct behavior)../gradlew assembleDebug lintDebug: BUILD SUCCESSFUL, 0 lint errors.- Live device: build installed over wireless
adb.
Known limitations
- This task is proactive-refresh only. There is no "catch a live 401, refresh, and retry the
same request once" fallback at the HTTP-transport layer. In practice this should rarely matter —
the provider refreshes 60 seconds ahead of the token's own stated expiry before any request that
goes through it — but a token invalidated server-side for a reason other than ordinary expiry
(e.g. an admin revoking the OAuth grant) would still surface as a live 401 exactly as before this
task, correctly falling through to
AUTHENTICATION_EXPIREDrather than being silently retried. - Not yet confirmed against a real long-idle session on the device. The fix targets a problem (repeated forced re-login/re-approval) that only manifests after enough real time passes for Gitea's access token to actually expire — this session's live testing so far has been continuous active use, not a multi-hour idle gap, so the felt improvement (no more surprise "승인" prompts) hasn't been directly observed yet, only proven correct at the unit level.
- Gitea's server-side
expires_in/ refresh-token TTL/rotation settings are instance-configurable (OAuth2.InvalidateRefreshTokensand friends) and unknown for this specific Gitea instance; nothing in this implementation assumes a particular value, but the practical frequency of silent refreshes on this instance hasn't been observed yet either.
Review findings
None yet — no Codex review pending (Codex retired, 2026-09-29).