Skip to content

Commit 57dbe82

Browse files
committed
oauth2 additional scopes
1 parent 9116665 commit 57dbe82

File tree

11 files changed

+692
-31
lines changed

11 files changed

+692
-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: 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 oauth2_provider.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
}

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: 17 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,29 @@ 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, string) {
3031
// JWT tokens require a "."
3132
if !strings.Contains(accessToken, ".") {
32-
return 0
33+
return 0, ""
3334
}
3435
token, err := oauth2_provider.ParseToken(accessToken, oauth2_provider.DefaultSigningKey)
3536
if err != nil {
3637
log.Trace("oauth2.ParseToken: %v", err)
37-
return 0
38+
return 0, ""
3839
}
3940
var grant *auth_model.OAuth2Grant
4041
if grant, err = auth_model.GetOAuth2GrantByID(ctx, token.GrantID); err != nil || grant == nil {
41-
return 0
42+
return 0, ""
4243
}
4344
if token.Kind != oauth2_provider.KindAccessToken {
44-
return 0
45+
return 0, ""
4546
}
4647
if token.ExpiresAt.Before(time.Now()) || token.IssuedAt.After(time.Now()) {
47-
return 0
48+
return 0, ""
4849
}
49-
return grant.UserID
50+
grantScopes := oauth2_provider.GrantAdditionalScopes(grant.Scope)
51+
return grant.UserID, grantScopes
5052
}
5153

5254
// OAuth2 implements the Auth interface and authenticates requests
@@ -92,10 +94,15 @@ func parseToken(req *http.Request) (string, bool) {
9294
func (o *OAuth2) userIDFromToken(ctx context.Context, tokenSHA string, store DataStore) int64 {
9395
// Let's see if token is valid.
9496
if strings.Contains(tokenSHA, ".") {
95-
uid := CheckOAuthAccessToken(ctx, tokenSHA)
97+
uid, grantScopes := CheckOAuthAccessToken(ctx, tokenSHA)
98+
9699
if uid != 0 {
97100
store.GetData()["IsApiToken"] = true
98-
store.GetData()["ApiTokenScope"] = auth_model.AccessTokenScopeAll // fallback to all
101+
if grantScopes != "" {
102+
store.GetData()["ApiTokenScope"] = auth_model.AccessTokenScope(grantScopes)
103+
} else {
104+
store.GetData()["ApiTokenScope"] = auth_model.AccessTokenScopeAll // fallback to all
105+
}
99106
}
100107
return uid
101108
}

services/oauth2_provider/access_token.go

Lines changed: 71 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,59 @@ 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) string {
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 apiTokenScopes []string
84+
for _, apiTokenScope := range strings.Split(grantScopes, " ") {
85+
if slices.Index(scopesSupported, apiTokenScope) == -1 {
86+
apiTokenScopes = append(apiTokenScopes, apiTokenScope)
87+
}
88+
}
89+
90+
if len(apiTokenScopes) == 0 {
91+
return ""
92+
}
93+
94+
var additionalGrantScopes []string
95+
allScopes := auth.AccessTokenScope("all")
96+
97+
for _, apiTokenScope := range apiTokenScopes {
98+
grantScope := auth.AccessTokenScope(apiTokenScope)
99+
if ok, _ := allScopes.HasScope(grantScope); ok {
100+
additionalGrantScopes = append(additionalGrantScopes, apiTokenScope)
101+
} else if apiTokenScope == "public-only" {
102+
additionalGrantScopes = append(additionalGrantScopes, apiTokenScope)
103+
}
104+
}
105+
if len(additionalGrantScopes) > 0 {
106+
return strings.Join(additionalGrantScopes, ",")
107+
}
108+
109+
return ""
110+
}
111+
112+
// check if groups are public only
113+
func IfOnlyPublicGroups(scopes string) bool {
114+
tokenScopes := GrantAdditionalScopes(scopes)
115+
accessTokenScope, err := auth.AccessTokenScope(tokenScopes).Normalize()
116+
if err != nil {
117+
return true
118+
}
119+
requiredScopes := auth.GetRequiredScopes(auth.Read, auth.AccessTokenScopeCategoryRepository, auth.AccessTokenScopeCategoryOrganization)
120+
if private, _ := accessTokenScope.HasAnyScope(requiredScopes...); private {
121+
return false
122+
}
123+
return true
124+
}
125+
71126
func NewAccessTokenResponse(ctx context.Context, grant *auth.OAuth2Grant, serverKey, clientKey JWTSigningKey) (*AccessTokenResponse, *AccessTokenError) {
72127
if setting.OAuth2.InvalidateRefreshTokens {
73128
if err := grant.IncreaseCounter(ctx); err != nil {
@@ -160,7 +215,9 @@ func NewAccessTokenResponse(ctx context.Context, grant *auth.OAuth2Grant, server
160215
idToken.EmailVerified = user.IsActive
161216
}
162217
if grant.ScopeContains("groups") {
163-
groups, err := GetOAuthGroupsForUser(ctx, user)
218+
onlyPublicGroups := IfOnlyPublicGroups(grant.Scope)
219+
220+
groups, err := GetOAuthGroupsForUser(ctx, user, onlyPublicGroups)
164221
if err != nil {
165222
log.Error("Error getting groups: %v", err)
166223
return nil, &AccessTokenError{
@@ -191,14 +248,26 @@ func NewAccessTokenResponse(ctx context.Context, grant *auth.OAuth2Grant, server
191248

192249
// returns a list of "org" and "org:team" strings,
193250
// that the given user is a part of.
194-
func GetOAuthGroupsForUser(ctx context.Context, user *user_model.User) ([]string, error) {
251+
func GetOAuthGroupsForUser(ctx context.Context, user *user_model.User, onlyPublicGroups bool) ([]string, error) {
195252
orgs, err := org_model.GetUserOrgsList(ctx, user)
196253
if err != nil {
197254
return nil, fmt.Errorf("GetUserOrgList: %w", err)
198255
}
199256

200257
var groups []string
201258
for _, org := range orgs {
259+
// process additional scopes only if enabled in settings
260+
// this could be removed once additional scopes get accepted
261+
if setting.OAuth2.EnableAdditionalGrantScopes {
262+
if onlyPublicGroups {
263+
if public, err := org_model.IsPublicMembership(ctx, org.ID, user.ID); err == nil {
264+
if !public || !org.Visibility.IsPublic() {
265+
continue
266+
}
267+
}
268+
}
269+
}
270+
202271
groups = append(groups, org.Name)
203272
teams, err := org.LoadTeams(ctx)
204273
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", "read:user,all"},
22+
{"openid profile email groups read:user", "read:user"},
23+
{"read:user read:repository", "read:user,read:repository"},
24+
{"read:user write:issue public-only", "read:user,write:issue,public-only"},
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)