Skip to content

OAuth2 authorization loops when existing grant has different scope #36762

@nichicom-yasutake

Description

@nichicom-yasutake

Description

Thank you for maintaining Gitea — we rely on it daily and really appreciate the work that goes into the project.

Description

We encountered an issue where, when a user has previously authorized an OAuth2 application and the OAuth2 client later requests a different scope (e.g. the client application updated its scope configuration), the authorization flow enters an infinite redirect loop. The user has no way to recover from this through the UI — the only workaround we found is manually deleting the grant record from the database.

Gitea Version

main branch (commit b9d323c, 2026-02-18), also confirmed on v1.25.4

Steps to Reproduce

  1. Register a public OAuth2 application in Gitea
  2. An OAuth2 client requests authorization with scope=repository
  3. User clicks "Authorize Application" → a grant is created with scope repository
  4. The OAuth2 client's configuration changes to request scope=read:repository,write:repository,read:user (e.g. the client application is updated to require additional permissions)
  5. The client initiates a new authorization flow with the updated scope
  6. User sees the authorization page and clicks "Authorize Application" again

Note: The scope is specified by the OAuth2 client in the authorization request URL parameter (?scope=...), not configured in Gitea's application settings. This is standard OAuth2 behavior per RFC 6749 — clients may request different scopes on different authorization requests.

Expected Behavior

The authorization should succeed. Gitea should either:

  • Update the existing grant's scope to match the newly authorized scope (the user has explicitly consented by clicking "Authorize" on a page showing the new permissions), or
  • Delete the old grant and create a new one with the updated scope, or
  • At minimum, show a clear error message to the user explaining the problem

Actual Behavior

The authorization enters an infinite redirect loop:

  1. GET /login/oauth/authorize → shows the authorization page (200 OK)
  2. User clicks "Authorize Application"
  3. POST /login/oauth/grant → scope mismatch detected → error redirected to redirect_uri (303)
  4. OAuth2 client receives the error and retries → GET /login/oauth/authorize (200 OK)
  5. Steps 2-4 repeat indefinitely

No error message is displayed to the user. The user cannot authorize the application and has no way to resolve this through the UI.

Server Logs

The loop is clearly visible in the access log. Below is an actual example — the user clicked "Authorize" once, triggering a rapid loop:

14:12:22 router: completed GET  /login/oauth/authorize?client_id=...&scope=read:repository,write:repository,read:user&... 200 OK  @ auth/oauth2_provider.go:177(auth.AuthorizeOAuth)
14:12:24 router: completed POST /login/oauth/grant   303  @ auth/oauth2_provider.go:353(auth.GrantApplicationOAuth)
14:12:24 router: completed GET  /login/oauth/authorize?client_id=...&scope=read:repository,write:repository,read:user&... 200 OK  @ auth/oauth2_provider.go:177(auth.AuthorizeOAuth)
14:12:24 router: completed POST /login/oauth/grant   303  @ auth/oauth2_provider.go:353(auth.GrantApplicationOAuth)
14:12:25 router: completed GET  /login/oauth/authorize?client_id=...&scope=read:repository,write:repository,read:user&... 200 OK  @ auth/oauth2_provider.go:177(auth.AuthorizeOAuth)
14:12:25 router: completed POST /login/oauth/grant   303  @ auth/oauth2_provider.go:353(auth.GrantApplicationOAuth)
14:12:25 router: completed GET  /login/oauth/authorize?...  200 OK  @ auth/oauth2_provider.go:177(auth.AuthorizeOAuth)
14:12:25 router: completed POST /login/oauth/grant   303  @ auth/oauth2_provider.go:353(auth.GrantApplicationOAuth)
14:12:26 router: completed GET  /login/oauth/authorize?...  200 OK  @ auth/oauth2_provider.go:177(auth.AuthorizeOAuth)
14:12:26 router: completed POST /login/oauth/grant   303  @ auth/oauth2_provider.go:353(auth.GrantApplicationOAuth)
14:12:26 router: completed GET  /login/oauth/authorize?...  200 OK  @ auth/oauth2_provider.go:177(auth.AuthorizeOAuth)

For comparison, a successful authorization (user with no pre-existing grant):

14:02:36 router: completed GET  /login/oauth/authorize?...  200 OK  @ auth/oauth2_provider.go:177(auth.AuthorizeOAuth)
14:02:39 router: completed POST /login/oauth/grant          303     @ auth/oauth2_provider.go:353(auth.GrantApplicationOAuth)
14:02:39 router: completed POST /login/oauth/access_token   200 OK  @ auth/oauth2_provider.go:461(auth.AccessTokenOAuth)

Root Cause (our analysis)

We traced this to routers/web/auth/oauth2_provider.go. The GrantApplicationOAuth() function (line ~389) checks if the existing grant's scope matches the requested scope:

} else if grant.Scope != form.Scope {
    handleAuthorizeError(ctx, AuthorizeError{
        State:            form.State,
        ErrorDescription: "a grant exists with different scope",
        ErrorCode:        ErrorCodeServerError,
    }, form.RedirectURI)
    return
}

When the scopes don't match, it returns an error to the redirect_uri. The OAuth2 client typically retries the authorization, creating the infinite loop.

The grant is never updated or replaced, so the user is permanently stuck until the grant record is manually deleted from the oauth2_grant table.

Additional Information

  • This affects all user types (local, LDAP, etc.) — it is not authentication-source-specific
  • This is a common real-world scenario: OAuth2 clients update their scope requirements as they evolve
  • For confidential clients or applications with skip_secondary_authorization enabled, the issue manifests differently: AuthorizeOAuth() (line ~287) auto-redirects using the old grant, which may cause the client to receive insufficient permissions

Possible Fix

This is just our suggestion — we'd love to hear the maintainers' thoughts on the best approach.

One option would be to update the existing grant's scope in GrantApplicationOAuth() when a mismatch is detected. Since the user has already consented to the new scope by clicking "Authorize" on the authorization page, this seems safe:

} else if grant.Scope != form.Scope {
    grant.Scope = form.Scope
    if err = grant.UpdateScope(ctx); err != nil {
        handleServerError(ctx, form.State, form.RedirectURI)
        return
    }
}

Another option would be to delete the old grant and create a new one with the updated scope.

Gitea Version

v1.25.4

Can you reproduce the bug on the Gitea demo site?

No

Log Gist

No response

Screenshots

No response

Git Version

v1.25.4

Operating System

No response

How are you running Gitea?

Official binary downloaded from the Gitea releases page, managed by supervisor on Linux.

Database

MySQL/MariaDB

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions