Skip to content

Commit a3881ff

Browse files
Enhancing Gitea OAuth2 Provider with Granular Scopes for Resource Access (#32573)
Resolve #31609 This PR was initiated following my personal research to find the lightest possible Single Sign-On solution for self-hosted setups. The existing solutions often seemed too enterprise-oriented, involving many moving parts and services, demanding significant resources while promising planetary-scale capabilities. Others were adequate in supporting basic OAuth2 flows but lacked proper user management features, such as a change password UI. Gitea hits the sweet spot for me, provided it supports more granular access permissions for resources under users who accept the OAuth2 application. This PR aims to introduce granularity in handling user resources as nonintrusively and simply as possible. It allows third parties to inform users about their intent to not ask for the full access and instead request a specific, reduced scope. If the provided scopes are **only** the typical ones for OIDC/OAuth2—`openid`, `profile`, `email`, and `groups`—everything remains unchanged (currently full access to user's resources). Additionally, this PR supports processing scopes already introduced with [personal tokens](https://docs.gitea.com/development/oauth2-provider#scopes) (e.g. `read:user`, `write:issue`, `read:group`, `write:repository`...) Personal tokens define scopes around specific resources: user info, repositories, issues, packages, organizations, notifications, miscellaneous, admin, and activitypub, with access delineated by read and/or write permissions. The initial case I wanted to address was to have Gitea act as an OAuth2 Identity Provider. To achieve that, with this PR, I would only add `openid public-only` to provide access token to the third party to authenticate the Gitea's user but no further access to the API and users resources. Another example: if a third party wanted to interact solely with Issues, it would need to add `read:user` (for authorization) and `read:issue`/`write:issue` to manage Issues. My approach is based on my understanding of how scopes can be utilized, supported by examples like [Sample Use Cases: Scopes and Claims](https://auth0.com/docs/get-started/apis/scopes/sample-use-cases-scopes-and-claims) on auth0.com. I renamed `CheckOAuthAccessToken` to `GetOAuthAccessTokenScopeAndUserID` so now it returns AccessTokenScope and user's ID. In the case of additional scopes in `userIDFromToken` the default `all` would be reduced to whatever was asked via those scopes. The main difference is the opportunity to reduce the permissions from `all`, as is currently the case, to what is provided by the additional scopes described above. Screenshots: ![Screenshot_20241121_121405](https://github.com/user-attachments/assets/29deaed7-4333-4b02-8898-b822e6f2463e) ![Screenshot_20241121_120211](https://github.com/user-attachments/assets/7a4a4ef7-409c-4116-9d5f-2fe00eb37167) ![Screenshot_20241121_120119](https://github.com/user-attachments/assets/aa52c1a2-212d-4e64-bcdf-7122cee49eb6) ![Screenshot_20241121_120018](https://github.com/user-attachments/assets/9eac318c-e381-4ea9-9e2c-3a3f60319e47) --------- Co-authored-by: wxiaoguang <[email protected]>
1 parent a175f98 commit a3881ff

File tree

8 files changed

+537
-18
lines changed

8 files changed

+537
-18
lines changed

options/locale/locale_en-US.ini

+1
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,7 @@ authorize_application = Authorize Application
459459
authorize_redirect_notice = You will be redirected to %s if you authorize this application.
460460
authorize_application_created_by = This application was created by %s.
461461
authorize_application_description = If you grant the access, it will be able to access and write to all your account information, including private repos and organisations.
462+
authorize_application_with_scopes = With scopes: %s
462463
authorize_title = Authorize "%s" to access your account?
463464
authorization_failed = Authorization failed
464465
authorization_failed_desc = The authorization failed because we detected an invalid request. Please contact the maintainer of the app you have tried to authorize.

routers/web/auth/oauth2_provider.go

+15-1
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,18 @@ func InfoOAuth(ctx *context.Context) {
104104
Picture: ctx.Doer.AvatarLink(ctx),
105105
}
106106

107-
groups, err := oauth2_provider.GetOAuthGroupsForUser(ctx, ctx.Doer)
107+
var accessTokenScope auth.AccessTokenScope
108+
if auHead := ctx.Req.Header.Get("Authorization"); auHead != "" {
109+
auths := strings.Fields(auHead)
110+
if len(auths) == 2 && (auths[0] == "token" || strings.ToLower(auths[0]) == "bearer") {
111+
accessTokenScope, _ = auth_service.GetOAuthAccessTokenScopeAndUserID(ctx, auths[1])
112+
}
113+
}
114+
115+
// since version 1.22 does not verify if groups should be public-only,
116+
// onlyPublicGroups will be set only if 'public-only' is included in a valid scope
117+
onlyPublicGroups, _ := accessTokenScope.PublicOnly()
118+
groups, err := oauth2_provider.GetOAuthGroupsForUser(ctx, ctx.Doer, onlyPublicGroups)
108119
if err != nil {
109120
ctx.ServerError("Oauth groups for user", err)
110121
return
@@ -304,6 +315,9 @@ func AuthorizeOAuth(ctx *context.Context) {
304315
return
305316
}
306317

318+
// check if additional scopes
319+
ctx.Data["AdditionalScopes"] = oauth2_provider.GrantAdditionalScopes(form.Scope) != auth.AccessTokenScopeAll
320+
307321
// show authorize page to grant access
308322
ctx.Data["Application"] = app
309323
ctx.Data["RedirectURI"] = form.RedirectURI

services/auth/basic.go

+2-2
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,8 @@ func (b *Basic) Verify(req *http.Request, w http.ResponseWriter, store DataStore
7777
log.Trace("Basic Authorization: Attempting login with username as token")
7878
}
7979

80-
// check oauth2 token
81-
uid := CheckOAuthAccessToken(req.Context(), authToken)
80+
// get oauth2 token's user's ID
81+
_, uid := GetOAuthAccessTokenScopeAndUserID(req.Context(), authToken)
8282
if uid != 0 {
8383
log.Trace("Basic Authorization: Valid OAuthAccessToken for user[%d]", uid)
8484

services/auth/oauth2.go

+13-11
Original file line numberDiff line numberDiff line change
@@ -26,33 +26,35 @@ var (
2626
_ Method = &OAuth2{}
2727
)
2828

29-
// CheckOAuthAccessToken returns uid of user from oauth token
30-
func CheckOAuthAccessToken(ctx context.Context, accessToken string) int64 {
29+
// GetOAuthAccessTokenScopeAndUserID returns access token scope and user id
30+
func GetOAuthAccessTokenScopeAndUserID(ctx context.Context, accessToken string) (auth_model.AccessTokenScope, int64) {
31+
var accessTokenScope auth_model.AccessTokenScope
3132
if !setting.OAuth2.Enabled {
32-
return 0
33+
return accessTokenScope, 0
3334
}
3435

3536
// JWT tokens require a ".", if the token isn't like that, return early
3637
if !strings.Contains(accessToken, ".") {
37-
return 0
38+
return accessTokenScope, 0
3839
}
3940

4041
token, err := oauth2_provider.ParseToken(accessToken, oauth2_provider.DefaultSigningKey)
4142
if err != nil {
4243
log.Trace("oauth2.ParseToken: %v", err)
43-
return 0
44+
return accessTokenScope, 0
4445
}
4546
var grant *auth_model.OAuth2Grant
4647
if grant, err = auth_model.GetOAuth2GrantByID(ctx, token.GrantID); err != nil || grant == nil {
47-
return 0
48+
return accessTokenScope, 0
4849
}
4950
if token.Kind != oauth2_provider.KindAccessToken {
50-
return 0
51+
return accessTokenScope, 0
5152
}
5253
if token.ExpiresAt.Before(time.Now()) || token.IssuedAt.After(time.Now()) {
53-
return 0
54+
return accessTokenScope, 0
5455
}
55-
return grant.UserID
56+
accessTokenScope = oauth2_provider.GrantAdditionalScopes(grant.Scope)
57+
return accessTokenScope, grant.UserID
5658
}
5759

5860
// CheckTaskIsRunning verifies that the TaskID corresponds to a running task
@@ -120,10 +122,10 @@ func (o *OAuth2) userIDFromToken(ctx context.Context, tokenSHA string, store Dat
120122
}
121123

122124
// Otherwise, check if this is an OAuth access token
123-
uid := CheckOAuthAccessToken(ctx, tokenSHA)
125+
accessTokenScope, uid := GetOAuthAccessTokenScopeAndUserID(ctx, tokenSHA)
124126
if uid != 0 {
125127
store.GetData()["IsApiToken"] = true
126-
store.GetData()["ApiTokenScope"] = auth_model.AccessTokenScopeAll // fallback to all
128+
store.GetData()["ApiTokenScope"] = accessTokenScope
127129
}
128130
return uid
129131
}

services/oauth2_provider/access_token.go

+37-3
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ package oauth2_provider //nolint
66
import (
77
"context"
88
"fmt"
9+
"slices"
10+
"strings"
911

1012
auth "code.gitea.io/gitea/models/auth"
1113
"code.gitea.io/gitea/models/db"
@@ -69,6 +71,32 @@ type AccessTokenResponse struct {
6971
IDToken string `json:"id_token,omitempty"`
7072
}
7173

74+
// GrantAdditionalScopes returns valid scopes coming from grant
75+
func GrantAdditionalScopes(grantScopes string) auth.AccessTokenScope {
76+
// scopes_supported from templates/user/auth/oidc_wellknown.tmpl
77+
scopesSupported := []string{
78+
"openid",
79+
"profile",
80+
"email",
81+
"groups",
82+
}
83+
84+
var tokenScopes []string
85+
for _, tokenScope := range strings.Split(grantScopes, " ") {
86+
if slices.Index(scopesSupported, tokenScope) == -1 {
87+
tokenScopes = append(tokenScopes, tokenScope)
88+
}
89+
}
90+
91+
// since version 1.22, access tokens grant full access to the API
92+
// with this access is reduced only if additional scopes are provided
93+
accessTokenScope := auth.AccessTokenScope(strings.Join(tokenScopes, ","))
94+
if accessTokenWithAdditionalScopes, err := accessTokenScope.Normalize(); err == nil && len(tokenScopes) > 0 {
95+
return accessTokenWithAdditionalScopes
96+
}
97+
return auth.AccessTokenScopeAll
98+
}
99+
72100
func NewAccessTokenResponse(ctx context.Context, grant *auth.OAuth2Grant, serverKey, clientKey JWTSigningKey) (*AccessTokenResponse, *AccessTokenError) {
73101
if setting.OAuth2.InvalidateRefreshTokens {
74102
if err := grant.IncreaseCounter(ctx); err != nil {
@@ -161,7 +189,13 @@ func NewAccessTokenResponse(ctx context.Context, grant *auth.OAuth2Grant, server
161189
idToken.EmailVerified = user.IsActive
162190
}
163191
if grant.ScopeContains("groups") {
164-
groups, err := GetOAuthGroupsForUser(ctx, user)
192+
accessTokenScope := GrantAdditionalScopes(grant.Scope)
193+
194+
// since version 1.22 does not verify if groups should be public-only,
195+
// onlyPublicGroups will be set only if 'public-only' is included in a valid scope
196+
onlyPublicGroups, _ := accessTokenScope.PublicOnly()
197+
198+
groups, err := GetOAuthGroupsForUser(ctx, user, onlyPublicGroups)
165199
if err != nil {
166200
log.Error("Error getting groups: %v", err)
167201
return nil, &AccessTokenError{
@@ -192,10 +226,10 @@ func NewAccessTokenResponse(ctx context.Context, grant *auth.OAuth2Grant, server
192226

193227
// returns a list of "org" and "org:team" strings,
194228
// that the given user is a part of.
195-
func GetOAuthGroupsForUser(ctx context.Context, user *user_model.User) ([]string, error) {
229+
func GetOAuthGroupsForUser(ctx context.Context, user *user_model.User, onlyPublicGroups bool) ([]string, error) {
196230
orgs, err := db.Find[org_model.Organization](ctx, org_model.FindOrgOptions{
197231
UserID: user.ID,
198-
IncludePrivate: true,
232+
IncludePrivate: !onlyPublicGroups,
199233
})
200234
if err != nil {
201235
return nil, fmt.Errorf("GetUserOrgList: %w", err)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Copyright 2024 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package oauth2_provider //nolint
5+
6+
import (
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
)
11+
12+
func TestGrantAdditionalScopes(t *testing.T) {
13+
tests := []struct {
14+
grantScopes string
15+
expectedScopes string
16+
}{
17+
{"openid profile email", "all"},
18+
{"openid profile email groups", "all"},
19+
{"openid profile email all", "all"},
20+
{"openid profile email read:user all", "all"},
21+
{"openid profile email groups read:user", "read:user"},
22+
{"read:user read:repository", "read:repository,read:user"},
23+
{"read:user write:issue public-only", "public-only,write:issue,read:user"},
24+
{"openid profile email read:user", "read:user"},
25+
{"read:invalid_scope", "all"},
26+
{"read:invalid_scope,write:scope_invalid,just-plain-wrong", "all"},
27+
}
28+
29+
for _, test := range tests {
30+
t.Run(test.grantScopes, func(t *testing.T) {
31+
result := GrantAdditionalScopes(test.grantScopes)
32+
assert.Equal(t, test.expectedScopes, string(result))
33+
})
34+
}
35+
}

templates/user/auth/grant.tmpl

+4-1
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,11 @@
88
<div class="ui attached segment">
99
{{template "base/alert" .}}
1010
<p>
11+
{{if not .AdditionalScopes}}
1112
<b>{{ctx.Locale.Tr "auth.authorize_application_description"}}</b><br>
12-
{{ctx.Locale.Tr "auth.authorize_application_created_by" .ApplicationCreatorLinkHTML}}
13+
{{end}}
14+
{{ctx.Locale.Tr "auth.authorize_application_created_by" .ApplicationCreatorLinkHTML}}<br>
15+
{{ctx.Locale.Tr "auth.authorize_application_with_scopes" (HTMLFormat "<b>%s</b>" .Scope)}}
1316
</p>
1417
</div>
1518
<div class="ui attached segment">

0 commit comments

Comments
 (0)