Ubisam-MobileGit/tasks/033-write-repository-oauth-scope-for-starring.md
choiminyeong 72de43d273 docs: TASK-033 follow-up, diagnose login server_error after scope change
Traced the live 'denied:server_error' login failure to Gitea 1.23.5's
own GrantApplicationOAuth: it refuses to silently widen an existing
OAuth2 grant's scope (grant.Scope != form.Scope -> ErrorCodeServerError,
'a grant exists with different scope'). No code change -- this is
Gitea's own intended behavior. Fix is to revoke UBGitea's existing
authorization from Gitea's Settings > Applications page before retrying
login, so a fresh grant is created under the new scope.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 23:46:19 +09:00

7.4 KiB

TASK-033: Request write:repository OAuth scope so starring actually works

Status: implemented; all required checks pass; live-installed, requires a one-time re-login on the device to take effect Owner: Claude Reviewer: Codex

Outcome

Every attempt to star/unstar a repository was guaranteed to fail server-side, because Gitea's PUT/DELETE /user/starred/{owner}/{repo} endpoints require write:repository OAuth scope (verified against Gitea's own route registration source) while this app's OAuthClientConfig only ever requested read-only scopes, enforced by a hard require() invariant. Live-reported as: the new favorite star turns red (this app's failed-action color) but never actually favorites anything. The human product owner was given three options — add the write scope, make favorites a local-only bookmark, or remove the feature — and explicitly chose to add the scope. This task adds write:repository as one narrow, explicit exception to that read-only invariant.

Scope

  • Included: core/auth/protocol/OAuthClientConfig.kt (new WRITE_REPOSITORY scope value, the read-only require() narrowed to allow specifically that one scope, UBGiteaOAuthDefaults.config now requests it), matching test additions in OAuthClientConfigTest.kt.
  • Excluded: No change to toggleFavorite, FavoriteStar, or any other favorite/star UI or ViewModel logic from TASK-018/TASK-030 — those were already correctly built against the RepositoryFavorites interface; the only thing wrong was the token they were being handed never had permission to actually do it. No change to any other OAuth scope (WRITE_USER remains rejected, confirmed by a new test asserting it's still rejected even alongside the now-allowed WRITE_REPOSITORY).

Design notes

  • A narrow exception, not a blanket relaxation: the original require(scopes.all { it.isReadOnly }) invariant existed to keep this app's OAuth footprint deliberately minimal — this task doesn't remove that principle, it carves out exactly the one scope this app's own already-built favorite feature actually needs, and only that one. require(scopes.all { it.isReadOnly || it == OAuthScope.WRITE_REPOSITORY }) still rejects every other write scope (including the pre-existing, still-unused WRITE_USER value) — verified by a new test that pairs WRITE_REPOSITORY with WRITE_USER together and confirms that combination is still rejected, so a future accidental addition of some other write scope doesn't silently slip through just because one exception now exists.
  • Verified against Gitea's actual source, not assumed: Gitea's route registration (routers/api/v1/api.go) puts the whole /starred/{owner}/{repo} group under tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository), with the PUT/DELETE methods specifically requiring Write-level access within that category — read:repository alone is insufficient no matter what, this isn't a bug that could have been fixed any other way from the client side.
  • A one-time re-login is required and expected, not a regression of TASK-029: TASK-029's proactive refresh-token flow keeps a session alive indefinitely for the scope it was originally granted — a refresh token from before this change carries the old (read-only) scope forward automatically, and Gitea's refresh flow doesn't expand it. The device's current session therefore needs one explicit new login (which will show the OAuth consent screen once more, listing the new write:repository permission) before starring will actually start working; every login after that continues to benefit from TASK-029's no-re-consent behavior exactly as before. This is reported directly to the human product owner rather than silently worked around (e.g. by force-clearing the stored session), since forcing a logout is a disruptive, irreversible-feeling action that should be their call, not an automatic side effect of this change.

Acceptance criteria

  • write:repository is requested alongside the app's existing read-only scopes.
  • Any other write scope (e.g. write:user) is still rejected by OAuthClientConfig's invariant, even when paired with the now-allowed write:repository.
  • Build, unit tests, and lint pass.
  • Live confirmation, after a fresh login on the device, that starring/unstarring a repository now actually succeeds (star turns filled/primary-colored, not red; the repository shows up in/disappears from the FAVORITES list) — requires the human product owner to log out and back in first, not yet done in this session.

Required checks

./gradlew compileDebugKotlin compileDebugUnitTestKotlin
./gradlew testDebugUnitTest
./gradlew assembleDebug lintDebug

Evidence

  • OAuthClientConfig.kt: new OAuthScope.WRITE_REPOSITORY("write:repository", false); the read-only require() narrowed with a source-cited comment; UBGiteaOAuthDefaults.config's scope set now includes it.
  • OAuthClientConfigTest.kt: two new tests — write:repository alone (with a read scope) is now accepted; write:user is still rejected even when write:repository is also present in the same scope set.
  • ./gradlew compileDebugKotlin compileDebugUnitTestKotlin: BUILD SUCCESSFUL.
  • ./gradlew testDebugUnitTest: BUILD SUCCESSFUL, all unit tests passing (including the two new ones above; the pre-existing rejectsWriteScopeInTheProductionOAuthContract test, which uses WRITE_USER specifically, still passes unchanged).
  • ./gradlew assembleDebug lintDebug: BUILD SUCCESSFUL, 0 lint errors.
  • Installed on the human product owner's device over wireless adb (adb install -r, streamed install succeeded); the device's current session still carries the old, read-only-only scope until the human product owner logs out and back in.

Known limitations

  • Requires a manual one-time re-login on the device before starring actually works — see Design notes. Not yet done in this session.
  • The re-login itself fails with "denied:server_error" until the old grant is revoked first. Live-reproduced: logging in after this change showed "로그인 응답을 확인할 수 없습니다... [debug] denied:server_error". Traced directly to Gitea 1.23.5's own source (routers/web/auth/oauth2_provider.go, GrantApplicationOAuth): if grant.Scope != form.Scope { ... ErrorCode: ErrorCodeServerError, ErrorDescription: "a grant exists with different scope" } — Gitea deliberately refuses to silently widen an existing OAuth2 grant's scope; the human product owner's account already had a grant for this app from before this change, stored under the old (read-only-only) scope string, and the new login's differently-scoped request hits this exact check. This is Gitea's own intended behavior, not a bug introduced by this task or something fixable from the client side. Fix: on the Gitea web UI, go to Settings → Applications → "Authorized OAuth2 Applications" and revoke UBGitea's existing authorization, then try logging in again on the device — a fresh grant will be created under the new scope and the consent screen should show write:repository listed. Not yet confirmed live.
  • No change was made to retroactively reflect a repository's already-starred-on-the-server state when the ALL list first loads (the pre-existing, unrelated favoriteAction limitation noted in TASK-030 — still applies, unaffected by this task).

Review findings

None yet — pending Codex/human review.