Skip to content

Commit 6bcde48

Browse files
committed
oauth2 additional scopes
1 parent 81383ef commit 6bcde48

File tree

10 files changed

+698
-30
lines changed

10 files changed

+698
-30
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) {

routers/web/auth/oauth2_provider.go

Lines changed: 21 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,19 @@ func InfoOAuth(ctx *context.Context) {
104104
Picture: ctx.Doer.AvatarLink(ctx),
105105
}
106106

107-
groups, err := oauth2_provider.GetOAuthGroupsForUser(ctx, ctx.Doer)
107+
// groups, err := oauth2_provider.GetOAuthGroupsForUser(ctx, ctx.Doer)
108+
var token string
109+
if auHead := ctx.Req.Header.Get("Authorization"); auHead != "" {
110+
auths := strings.Fields(auHead)
111+
if len(auths) == 2 && (auths[0] == "token" || strings.ToLower(auths[0]) == "bearer") {
112+
token = auths[1]
113+
}
114+
}
115+
116+
_, grantScopes := auth_service.CheckOAuthAccessToken(ctx, token)
117+
onlyPublicGroups := oauth2_provider.IfOnlyPublicGroups(grantScopes)
118+
119+
groups, err := oauth2_provider.GetOAuthGroupsForUser(ctx, ctx.Doer, onlyPublicGroups)
108120
if err != nil {
109121
ctx.ServerError("Oauth groups for user", err)
110122
return
@@ -304,6 +316,13 @@ func AuthorizeOAuth(ctx *context.Context) {
304316
return
305317
}
306318

319+
// check if additional scopes
320+
if auth_service.GrantAdditionalScopes(form.Scope) == "" {
321+
ctx.Data["AdditionalScopes"] = false
322+
} else {
323+
ctx.Data["AdditionalScopes"] = true
324+
}
325+
307326
// show authorize page to grant access
308327
ctx.Data["Application"] = app
309328
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
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// Copyright 2024 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package auth
5+
6+
import (
7+
"testing"
8+
9+
"code.gitea.io/gitea/modules/setting"
10+
11+
"github.com/stretchr/testify/assert"
12+
)
13+
14+
func TestGrantAdditionalScopes(t *testing.T) {
15+
setting.OAuth2.EnableAdditionalGrantScopes = true
16+
tests := []struct {
17+
grantScopes string
18+
expectedScopes string
19+
}{
20+
{"openid profile email", ""},
21+
{"openid profile email groups", ""},
22+
{"openid profile email all", "all"},
23+
{"openid profile email read:user all", "read:user,all"},
24+
{"openid profile email groups read:user", "read:user"},
25+
{"read:user read:repository", "read:user,read:repository"},
26+
{"read:user write:issue public-only", "read:user,write:issue,public-only"},
27+
{"openid profile email read:user", "read:user"},
28+
{"read:invalid_scope", ""},
29+
{"read:invalid_scope,write:scope_invalid,just-plain-wrong", ""},
30+
}
31+
32+
for _, test := range tests {
33+
t.Run(test.grantScopes, func(t *testing.T) {
34+
result := GrantAdditionalScopes(test.grantScopes)
35+
assert.Equal(t, test.expectedScopes, result)
36+
})
37+
}
38+
}

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: 62 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ package auth
77
import (
88
"context"
99
"net/http"
10+
"slices"
1011
"strings"
1112
"time"
1213

@@ -25,28 +26,75 @@ var (
2526
_ Method = &OAuth2{}
2627
)
2728

29+
// GrantAdditionalScopes returns valid scopes coming from grant
30+
func GrantAdditionalScopes(grantScopes string) string {
31+
// process additional scopes only if enabled in settings
32+
// this could be removed once additional scopes get accepted
33+
if !setting.OAuth2.EnableAdditionalGrantScopes {
34+
return ""
35+
}
36+
37+
// scopes_supported from templates/user/auth/oidc_wellknown.tmpl
38+
scopesSupported := []string{
39+
"openid",
40+
"profile",
41+
"email",
42+
"groups",
43+
}
44+
45+
var apiTokenScopes []string
46+
for _, apiTokenScope := range strings.Split(grantScopes, " ") {
47+
if slices.Index(scopesSupported, apiTokenScope) == -1 {
48+
apiTokenScopes = append(apiTokenScopes, apiTokenScope)
49+
}
50+
}
51+
52+
if len(apiTokenScopes) == 0 {
53+
return ""
54+
}
55+
56+
var additionalGrantScopes []string
57+
allScopes := auth_model.AccessTokenScope("all")
58+
59+
for _, apiTokenScope := range apiTokenScopes {
60+
grantScope := auth_model.AccessTokenScope(apiTokenScope)
61+
if ok, _ := allScopes.HasScope(grantScope); ok {
62+
additionalGrantScopes = append(additionalGrantScopes, apiTokenScope)
63+
} else if apiTokenScope == "public-only" {
64+
additionalGrantScopes = append(additionalGrantScopes, apiTokenScope)
65+
}
66+
}
67+
if len(additionalGrantScopes) > 0 {
68+
return strings.Join(additionalGrantScopes, ",")
69+
}
70+
71+
return ""
72+
}
73+
2874
// CheckOAuthAccessToken returns uid of user from oauth token
29-
func CheckOAuthAccessToken(ctx context.Context, accessToken string) int64 {
75+
// + non default openid scopes requested
76+
func CheckOAuthAccessToken(ctx context.Context, accessToken string) (int64, string) {
3077
// JWT tokens require a "."
3178
if !strings.Contains(accessToken, ".") {
32-
return 0
79+
return 0, ""
3380
}
3481
token, err := oauth2_provider.ParseToken(accessToken, oauth2_provider.DefaultSigningKey)
3582
if err != nil {
3683
log.Trace("oauth2.ParseToken: %v", err)
37-
return 0
84+
return 0, ""
3885
}
3986
var grant *auth_model.OAuth2Grant
4087
if grant, err = auth_model.GetOAuth2GrantByID(ctx, token.GrantID); err != nil || grant == nil {
41-
return 0
88+
return 0, ""
4289
}
4390
if token.Kind != oauth2_provider.KindAccessToken {
44-
return 0
91+
return 0, ""
4592
}
4693
if token.ExpiresAt.Before(time.Now()) || token.IssuedAt.After(time.Now()) {
47-
return 0
94+
return 0, ""
4895
}
49-
return grant.UserID
96+
grantScopes := GrantAdditionalScopes(grant.Scope)
97+
return grant.UserID, grantScopes
5098
}
5199

52100
// OAuth2 implements the Auth interface and authenticates requests
@@ -92,10 +140,15 @@ func parseToken(req *http.Request) (string, bool) {
92140
func (o *OAuth2) userIDFromToken(ctx context.Context, tokenSHA string, store DataStore) int64 {
93141
// Let's see if token is valid.
94142
if strings.Contains(tokenSHA, ".") {
95-
uid := CheckOAuthAccessToken(ctx, tokenSHA)
143+
uid, grantScopes := CheckOAuthAccessToken(ctx, tokenSHA)
144+
96145
if uid != 0 {
97146
store.GetData()["IsApiToken"] = true
98-
store.GetData()["ApiTokenScope"] = auth_model.AccessTokenScopeAll // fallback to all
147+
if grantScopes != "" {
148+
store.GetData()["ApiTokenScope"] = auth_model.AccessTokenScope(grantScopes)
149+
} else {
150+
store.GetData()["ApiTokenScope"] = auth_model.AccessTokenScopeAll // fallback to all
151+
}
99152
}
100153
return uid
101154
}

services/oauth2_provider/access_token.go

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package oauth2_provider //nolint
66
import (
77
"context"
88
"fmt"
9+
"strings"
910

1011
auth "code.gitea.io/gitea/models/auth"
1112
org_model "code.gitea.io/gitea/models/organization"
@@ -68,6 +69,20 @@ type AccessTokenResponse struct {
6869
IDToken string `json:"id_token,omitempty"`
6970
}
7071

72+
// check if groups are public only
73+
func IfOnlyPublicGroups(scopes string) bool {
74+
scopes = strings.ReplaceAll(scopes, ",", " ")
75+
scopesList := strings.Fields(scopes)
76+
for _, scope := range scopesList {
77+
if scope == "public-only" {
78+
return true
79+
} else if scope == "all" || scope == "read:organization" || scope == "read:admin" {
80+
return false
81+
}
82+
}
83+
return true
84+
}
85+
7186
func NewAccessTokenResponse(ctx context.Context, grant *auth.OAuth2Grant, serverKey, clientKey JWTSigningKey) (*AccessTokenResponse, *AccessTokenError) {
7287
if setting.OAuth2.InvalidateRefreshTokens {
7388
if err := grant.IncreaseCounter(ctx); err != nil {
@@ -160,7 +175,9 @@ func NewAccessTokenResponse(ctx context.Context, grant *auth.OAuth2Grant, server
160175
idToken.EmailVerified = user.IsActive
161176
}
162177
if grant.ScopeContains("groups") {
163-
groups, err := GetOAuthGroupsForUser(ctx, user)
178+
onlyPublicGroups := IfOnlyPublicGroups(grant.Scope)
179+
180+
groups, err := GetOAuthGroupsForUser(ctx, user, onlyPublicGroups)
164181
if err != nil {
165182
log.Error("Error getting groups: %v", err)
166183
return nil, &AccessTokenError{
@@ -191,14 +208,26 @@ func NewAccessTokenResponse(ctx context.Context, grant *auth.OAuth2Grant, server
191208

192209
// returns a list of "org" and "org:team" strings,
193210
// that the given user is a part of.
194-
func GetOAuthGroupsForUser(ctx context.Context, user *user_model.User) ([]string, error) {
211+
func GetOAuthGroupsForUser(ctx context.Context, user *user_model.User, onlyPublicGroups bool) ([]string, error) {
195212
orgs, err := org_model.GetUserOrgsList(ctx, user)
196213
if err != nil {
197214
return nil, fmt.Errorf("GetUserOrgList: %w", err)
198215
}
199216

200217
var groups []string
201218
for _, org := range orgs {
219+
// process additional scopes only if enabled in settings
220+
// this could be removed once additional scopes get accepted
221+
if setting.OAuth2.EnableAdditionalGrantScopes {
222+
if onlyPublicGroups {
223+
if public, err := org_model.IsPublicMembership(ctx, org.ID, user.ID); err == nil {
224+
if !public || !org.Visibility.IsPublic() {
225+
continue
226+
}
227+
}
228+
}
229+
}
230+
202231
groups = append(groups, org.Name)
203232
teams, err := org.LoadTeams(ctx)
204233
if err != nil {

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>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"
@@ -506,3 +507,30 @@ func GetCSRFFromCookie(t testing.TB, session *TestSession, urlStr string) string
506507
session.MakeRequest(t, req, http.StatusOK)
507508
return session.GetCookie("_csrf").Value
508509
}
510+
511+
func loginUserWithPasswordRemember(t testing.TB, userName, password string, rememberMe bool) *TestSession {
512+
t.Helper()
513+
req := NewRequest(t, "GET", "/user/login")
514+
resp := MakeRequest(t, req, http.StatusOK)
515+
516+
doc := NewHTMLParser(t, resp.Body)
517+
req = NewRequestWithValues(t, "POST", "/user/login", map[string]string{
518+
"_csrf": doc.GetCSRF(),
519+
"user_name": userName,
520+
"password": password,
521+
"remember": strconv.FormatBool(rememberMe),
522+
})
523+
resp = MakeRequest(t, req, http.StatusSeeOther)
524+
525+
ch := http.Header{}
526+
ch.Add("Cookie", strings.Join(resp.Header()["Set-Cookie"], ";"))
527+
cr := http.Request{Header: ch}
528+
529+
session := emptyTestSession(t)
530+
531+
baseURL, err := url.Parse(setting.AppURL)
532+
require.NoError(t, err)
533+
session.jar.SetCookies(baseURL, cr.Cookies())
534+
535+
return session
536+
}

0 commit comments

Comments
 (0)