# Gitea: OAuth2 sign-in reactivates an administrator-deactivated account on auth sources without refresh tokens (incomplete fix of #38009)

Gitea's OAuth2 callback treats an empty refresh token as auto-sync-disabled, so admin-deactivated users on GitHub/no-refresh-token sources reactivate themselves on next sign-in.

- Payout: Research
- Severity: high
- Program: Gitea
- Found by (vendor credit): khoadb175
- Found by: khoadb175
- Disclosed: 2026-07-21
- CVE: CVE-2026-55987
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-vrhc-jjfc-m3m3
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-vrhc-jjfc-m3m3
- Tags: area:web domain:web impact:privesc kind:research platform:ghsa sev:high tech:go tech:oauth technique:incomplete-fix technique:source-review vuln:access-control vuln:auth-bypass

**Target:** OAuth2/OIDC sign-in callback (routers/web/auth/oauth.go)
**Root cause:** The reactivation gate in handleOAuth2SignIn uses `extLogin.RefreshToken == ""` as the signal that the auto-sync cron disabled the account, but sources without refresh tokens (GitHub, OIDC without offline_access) store an empty refresh token for every user, so the gate cannot distinguish cron-disabled from admin-deactivated accounts.
**Steps:**
1. Configure a GitHub auth source (or OIDC/OAuth2 without `offline_access`); have user V sign in once so an `external_login_user` row exists with empty `refresh_token`.
2. As admin, uncheck "Activated" for V (`is_active=false`); confirm V is bounced to the activation page.
3. As V, sign in again via "Sign in with GitHub" and complete the provider flow.
4. V receives a working session; `SELECT is_active FROM "user" WHERE lower_name='v';` returns `true`.
5. Control: OIDC user with a stored refresh token stays disabled; OIDC user cron-disabled with empty token reactivates (intended).
**How found:** Reviewed the #38009 fix and noticed the gate's assumption that `RefreshToken == ""` uniquely identifies cron-disabled accounts; traced source_sync.go's early return for providers without refresh tokens and goth's hardcoded `RefreshTokenAvailable() false` for GitHub, then compared against Gitea's own regression test state.
**Exploitation:** A deactivated user simply re-authenticates through the OAuth2 source; the callback sets `opts.IsActive = optional.Some(true)` and issues a full session, restoring the account's prior privileges (including admin, since deactivation does not clear IsAdmin).
**Escalation:** Deactivated administrator regains admin access because IsAdmin is not cleared on deactivation; only ProhibitLogin remains enforced.
**Insight:** When a fix uses a stored field's emptiness as a state discriminator, check whether that field is also empty in normal operation for some provider/config class — here GitHub and no-offline_access OIDC make the sentinel indistinguishable from the legitimate state.

