Skip to content

Commit 937cdfc

Browse files
committed
oauth2 additional scopes
1 parent 9116665 commit 937cdfc

File tree

11 files changed

+650
-31
lines changed

11 files changed

+650
-31
lines changed

modules/setting/oauth2.go

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -90,23 +90,25 @@ func parseScopes(sec ConfigSection, name string) []string {
9090
}
9191

9292
var OAuth2 = struct {
93-
Enabled bool
94-
AccessTokenExpirationTime int64
95-
RefreshTokenExpirationTime int64
96-
InvalidateRefreshTokens bool
97-
JWTSigningAlgorithm string `ini:"JWT_SIGNING_ALGORITHM"`
98-
JWTSigningPrivateKeyFile string `ini:"JWT_SIGNING_PRIVATE_KEY_FILE"`
99-
MaxTokenLength int
100-
DefaultApplications []string
93+
Enabled bool
94+
AccessTokenExpirationTime int64
95+
RefreshTokenExpirationTime int64
96+
InvalidateRefreshTokens bool
97+
JWTSigningAlgorithm string `ini:"JWT_SIGNING_ALGORITHM"`
98+
JWTSigningPrivateKeyFile string `ini:"JWT_SIGNING_PRIVATE_KEY_FILE"`
99+
MaxTokenLength int
100+
DefaultApplications []string
101+
EnableAdditionalGrantScopes bool
101102
}{
102-
Enabled: true,
103-
AccessTokenExpirationTime: 3600,
104-
RefreshTokenExpirationTime: 730,
105-
InvalidateRefreshTokens: false,
106-
JWTSigningAlgorithm: "RS256",
107-
JWTSigningPrivateKeyFile: "jwt/private.pem",
108-
MaxTokenLength: math.MaxInt16,
109-
DefaultApplications: []string{"git-credential-oauth", "git-credential-manager", "tea"},
103+
Enabled: true,
104+
AccessTokenExpirationTime: 3600,
105+
RefreshTokenExpirationTime: 730,
106+
InvalidateRefreshTokens: false,
107+
JWTSigningAlgorithm: "RS256",
108+
JWTSigningPrivateKeyFile: "jwt/private.pem",
109+
MaxTokenLength: math.MaxInt16,
110+
DefaultApplications: []string{"git-credential-oauth", "git-credential-manager", "tea"},
111+
EnableAdditionalGrantScopes: false,
110112
}
111113

112114
func loadOAuth2From(rootCfg ConfigProvider) {

options/locale/locale_en-US.ini

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,7 @@ authorize_application = Authorize Application
453453
authorize_redirect_notice = You will be redirected to %s if you authorize this application.
454454
authorize_application_created_by = This application was created by %s.
455455
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.
456+
authorize_application_with_scopes = With scopes: %s
456457
authorize_title = Authorize "%s" to access your account?
457458
authorization_failed = Authorization failed
458459
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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ type userInfoResponse struct {
8585
Username string `json:"preferred_username"`
8686
Email string `json:"email"`
8787
Picture string `json:"picture"`
88-
Groups []string `json:"groups"`
88+
Groups []string `json:"groups,omitempty"`
8989
}
9090

9191
// InfoOAuth manages request for userinfo endpoint
@@ -104,7 +104,8 @@ func InfoOAuth(ctx *context.Context) {
104104
Picture: ctx.Doer.AvatarLink(ctx),
105105
}
106106

107-
groups, err := oauth2_provider.GetOAuthGroupsForUser(ctx, ctx.Doer)
107+
onlyPublicGroups := ctx.IsSigned && (ctx.FormString("private") == "" || ctx.FormBool("private"))
108+
groups, err := oauth2_provider.GetOAuthGroupsForUser(ctx, ctx.Doer, onlyPublicGroups)
108109
if err != nil {
109110
ctx.ServerError("Oauth groups for user", err)
110111
return
@@ -304,6 +305,13 @@ func AuthorizeOAuth(ctx *context.Context) {
304305
return
305306
}
306307

308+
// check if additional scopes
309+
if oauth2_provider.GrantAdditionalScopes(form.Scope) == auth.AccessTokenScopeAll {
310+
ctx.Data["AdditionalScopes"] = false
311+
} else {
312+
ctx.Data["AdditionalScopes"] = true
313+
}
314+
307315
// show authorize page to grant access
308316
ctx.Data["Application"] = app
309317
ctx.Data["RedirectURI"] = form.RedirectURI

routers/web/user/setting/applications.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,5 +113,6 @@ func loadApplicationsData(ctx *context.Context) {
113113
ctx.ServerError("GetOAuth2GrantsByUserID", err)
114114
return
115115
}
116+
ctx.Data["EnableAdditionalGrantScopes"] = setting.OAuth2.EnableAdditionalGrantScopes
116117
}
117118
}

services/auth/basic.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ func (b *Basic) Verify(req *http.Request, w http.ResponseWriter, store DataStore
7777
}
7878

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

services/auth/oauth2.go

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import (
1717
"code.gitea.io/gitea/modules/setting"
1818
"code.gitea.io/gitea/modules/timeutil"
1919
"code.gitea.io/gitea/modules/web/middleware"
20-
"code.gitea.io/gitea/services/oauth2_provider"
20+
oauth2_provider "code.gitea.io/gitea/services/oauth2_provider"
2121
)
2222

2323
// Ensure the struct implements the interface.
@@ -26,27 +26,30 @@ var (
2626
)
2727

2828
// CheckOAuthAccessToken returns uid of user from oauth token
29-
func CheckOAuthAccessToken(ctx context.Context, accessToken string) int64 {
29+
// + non default openid scopes requested
30+
func CheckOAuthAccessToken(ctx context.Context, accessToken string) (int64, auth_model.AccessTokenScope) {
31+
var accessTokenScope auth_model.AccessTokenScope
3032
// JWT tokens require a "."
3133
if !strings.Contains(accessToken, ".") {
32-
return 0
34+
return 0, accessTokenScope
3335
}
3436
token, err := oauth2_provider.ParseToken(accessToken, oauth2_provider.DefaultSigningKey)
3537
if err != nil {
3638
log.Trace("oauth2.ParseToken: %v", err)
37-
return 0
39+
return 0, accessTokenScope
3840
}
3941
var grant *auth_model.OAuth2Grant
4042
if grant, err = auth_model.GetOAuth2GrantByID(ctx, token.GrantID); err != nil || grant == nil {
41-
return 0
43+
return 0, accessTokenScope
4244
}
4345
if token.Kind != oauth2_provider.KindAccessToken {
44-
return 0
46+
return 0, accessTokenScope
4547
}
4648
if token.ExpiresAt.Before(time.Now()) || token.IssuedAt.After(time.Now()) {
47-
return 0
49+
return 0, accessTokenScope
4850
}
49-
return grant.UserID
51+
accessTokenScope = oauth2_provider.GrantAdditionalScopes(grant.Scope)
52+
return grant.UserID, accessTokenScope
5053
}
5154

5255
// OAuth2 implements the Auth interface and authenticates requests
@@ -92,10 +95,11 @@ func parseToken(req *http.Request) (string, bool) {
9295
func (o *OAuth2) userIDFromToken(ctx context.Context, tokenSHA string, store DataStore) int64 {
9396
// Let's see if token is valid.
9497
if strings.Contains(tokenSHA, ".") {
95-
uid := CheckOAuthAccessToken(ctx, tokenSHA)
98+
uid, accessTokenScope := CheckOAuthAccessToken(ctx, tokenSHA)
99+
96100
if uid != 0 {
97101
store.GetData()["IsApiToken"] = true
98-
store.GetData()["ApiTokenScope"] = auth_model.AccessTokenScopeAll // fallback to all
102+
store.GetData()["ApiTokenScope"] = accessTokenScope
99103
}
100104
return uid
101105
}

services/oauth2_provider/access_token.go

Lines changed: 44 additions & 2 deletions
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
org_model "code.gitea.io/gitea/models/organization"
@@ -68,6 +70,31 @@ type AccessTokenResponse struct {
6870
IDToken string `json:"id_token,omitempty"`
6971
}
7072

73+
// GrantAdditionalScopes returns valid scopes coming from grant
74+
func GrantAdditionalScopes(grantScopes string) auth.AccessTokenScope {
75+
// scopes_supported from templates/user/auth/oidc_wellknown.tmpl
76+
scopesSupported := []string{
77+
"openid",
78+
"profile",
79+
"email",
80+
"groups",
81+
}
82+
83+
var tokenScopes []string
84+
for _, tokenScope := range strings.Split(grantScopes, " ") {
85+
if slices.Index(scopesSupported, tokenScope) == -1 {
86+
tokenScopes = append(tokenScopes, tokenScope)
87+
}
88+
}
89+
90+
accessTokenScope := auth.AccessTokenScope(strings.Join(tokenScopes, ","))
91+
if accessTokenWithAdditionalScopes, err := accessTokenScope.Normalize(); err == nil {
92+
return accessTokenWithAdditionalScopes
93+
} else {
94+
return auth.AccessTokenScopeAll
95+
}
96+
}
97+
7198
func NewAccessTokenResponse(ctx context.Context, grant *auth.OAuth2Grant, serverKey, clientKey JWTSigningKey) (*AccessTokenResponse, *AccessTokenError) {
7299
if setting.OAuth2.InvalidateRefreshTokens {
73100
if err := grant.IncreaseCounter(ctx); err != nil {
@@ -160,7 +187,10 @@ func NewAccessTokenResponse(ctx context.Context, grant *auth.OAuth2Grant, server
160187
idToken.EmailVerified = user.IsActive
161188
}
162189
if grant.ScopeContains("groups") {
163-
groups, err := GetOAuthGroupsForUser(ctx, user)
190+
accessTokenScope := GrantAdditionalScopes(grant.Scope)
191+
onlyPublicGroups, _ := accessTokenScope.PublicOnly()
192+
193+
groups, err := GetOAuthGroupsForUser(ctx, user, onlyPublicGroups)
164194
if err != nil {
165195
log.Error("Error getting groups: %v", err)
166196
return nil, &AccessTokenError{
@@ -191,14 +221,26 @@ func NewAccessTokenResponse(ctx context.Context, grant *auth.OAuth2Grant, server
191221

192222
// returns a list of "org" and "org:team" strings,
193223
// that the given user is a part of.
194-
func GetOAuthGroupsForUser(ctx context.Context, user *user_model.User) ([]string, error) {
224+
func GetOAuthGroupsForUser(ctx context.Context, user *user_model.User, onlyPublicGroups bool) ([]string, error) {
195225
orgs, err := org_model.GetUserOrgsList(ctx, user)
196226
if err != nil {
197227
return nil, fmt.Errorf("GetUserOrgList: %w", err)
198228
}
199229

200230
var groups []string
201231
for _, org := range orgs {
232+
// process additional scopes only if enabled in settings
233+
// this could be removed once additional scopes get accepted
234+
if setting.OAuth2.EnableAdditionalGrantScopes {
235+
if onlyPublicGroups {
236+
if public, err := org_model.IsPublicMembership(ctx, org.ID, user.ID); err == nil {
237+
if !public || !org.Visibility.IsPublic() {
238+
continue
239+
}
240+
}
241+
}
242+
}
243+
202244
groups = append(groups, org.Name)
203245
teams, err := org.LoadTeams(ctx)
204246
if err != nil {
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// Copyright 2024 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package oauth2_provider
5+
6+
import (
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
)
11+
12+
func TestGrantAdditionalScopes(t *testing.T) {
13+
setting.OAuth2.EnableAdditionalGrantScopes = true
14+
tests := []struct {
15+
grantScopes string
16+
expectedScopes string
17+
}{
18+
{"openid profile email", ""},
19+
{"openid profile email groups", ""},
20+
{"openid profile email all", "all"},
21+
{"openid profile email read:user all", "all"},
22+
{"openid profile email groups read:user", "read:user"},
23+
{"read:user read:repository", "read:repository,read:user"},
24+
{"read:user write:issue public-only", "public-only,write:issue,read:user"},
25+
{"openid profile email read:user", "read:user"},
26+
{"read:invalid_scope", ""},
27+
{"read:invalid_scope,write:scope_invalid,just-plain-wrong", ""},
28+
}
29+
30+
for _, test := range tests {
31+
t.Run(test.grantScopes, func(t *testing.T) {
32+
result := GrantAdditionalScopes(test.grantScopes)
33+
assert.Equal(t, test.expectedScopes, result)
34+
})
35+
}
36+
}

templates/user/auth/grant.tmpl

Lines changed: 3 additions & 0 deletions
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>
13+
{{end}}
1214
{{ctx.Locale.Tr "auth.authorize_application_created_by" .ApplicationCreatorLinkHTML}}
15+
<p>{{ctx.Locale.Tr "auth.authorize_application_with_scopes" .Scope}}</p>
1316
</p>
1417
</div>
1518
<div class="ui attached segment">

tests/integration/integration_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"net/url"
1818
"os"
1919
"path/filepath"
20+
"strconv"
2021
"strings"
2122
"sync/atomic"
2223
"testing"
@@ -502,3 +503,30 @@ func GetAnonymousCSRFToken(t testing.TB, session *TestSession) string {
502503
require.NotEmpty(t, csrfToken)
503504
return csrfToken
504505
}
506+
507+
func loginUserWithPasswordRemember(t testing.TB, userName, password string, rememberMe bool) *TestSession {
508+
t.Helper()
509+
req := NewRequest(t, "GET", "/user/login")
510+
resp := MakeRequest(t, req, http.StatusOK)
511+
512+
doc := NewHTMLParser(t, resp.Body)
513+
req = NewRequestWithValues(t, "POST", "/user/login", map[string]string{
514+
"_csrf": doc.GetCSRF(),
515+
"user_name": userName,
516+
"password": password,
517+
"remember": strconv.FormatBool(rememberMe),
518+
})
519+
resp = MakeRequest(t, req, http.StatusSeeOther)
520+
521+
ch := http.Header{}
522+
ch.Add("Cookie", strings.Join(resp.Header()["Set-Cookie"], ";"))
523+
cr := http.Request{Header: ch}
524+
525+
session := emptyTestSession(t)
526+
527+
baseURL, err := url.Parse(setting.AppURL)
528+
require.NoError(t, err)
529+
session.jar.SetCookies(baseURL, cr.Cookies())
530+
531+
return session
532+
}

0 commit comments

Comments
 (0)