Skip to content
Merged
1 change: 1 addition & 0 deletions options/locale/locale_en-US.ini
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,7 @@ authorize_application = Authorize Application
authorize_redirect_notice = You will be redirected to %s if you authorize this application.
authorize_application_created_by = This application was created by %s.
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.
authorize_application_with_scopes = With scopes: %s
authorize_title = Authorize "%s" to access your account?
authorization_failed = Authorization failed
authorization_failed_desc = The authorization failed because we detected an invalid request. Please contact the maintainer of the app you have tried to authorize.
Expand Down
16 changes: 15 additions & 1 deletion routers/web/auth/oauth2_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,18 @@ func InfoOAuth(ctx *context.Context) {
Picture: ctx.Doer.AvatarLink(ctx),
}

groups, err := oauth2_provider.GetOAuthGroupsForUser(ctx, ctx.Doer)
var accessTokenScope auth.AccessTokenScope
if auHead := ctx.Req.Header.Get("Authorization"); auHead != "" {
auths := strings.Fields(auHead)
if len(auths) == 2 && (auths[0] == "token" || strings.ToLower(auths[0]) == "bearer") {
accessTokenScope, _ = auth_service.GetOAuthAccessTokenScopeAndUserID(ctx, auths[1])
}
}

// since version 1.22 does not verify if groups should be public-only,
// onlyPublicGroups will be set only if 'public-only' is included in a valid scope
onlyPublicGroups, _ := accessTokenScope.PublicOnly()
groups, err := oauth2_provider.GetOAuthGroupsForUser(ctx, ctx.Doer, onlyPublicGroups)
if err != nil {
ctx.ServerError("Oauth groups for user", err)
return
Expand Down Expand Up @@ -304,6 +315,9 @@ func AuthorizeOAuth(ctx *context.Context) {
return
}

// check if additional scopes
ctx.Data["AdditionalScopes"] = oauth2_provider.GrantAdditionalScopes(form.Scope) != auth.AccessTokenScopeAll

// show authorize page to grant access
ctx.Data["Application"] = app
ctx.Data["RedirectURI"] = form.RedirectURI
Expand Down
4 changes: 2 additions & 2 deletions services/auth/basic.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,8 @@ func (b *Basic) Verify(req *http.Request, w http.ResponseWriter, store DataStore
log.Trace("Basic Authorization: Attempting login with username as token")
}

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

Expand Down
24 changes: 13 additions & 11 deletions services/auth/oauth2.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,33 +26,35 @@ var (
_ Method = &OAuth2{}
)

// CheckOAuthAccessToken returns uid of user from oauth token
func CheckOAuthAccessToken(ctx context.Context, accessToken string) int64 {
// GetOAuthAccessTokenScopeAndUserID returns access token scope and user id
func GetOAuthAccessTokenScopeAndUserID(ctx context.Context, accessToken string) (auth_model.AccessTokenScope, int64) {
var accessTokenScope auth_model.AccessTokenScope
if !setting.OAuth2.Enabled {
return 0
return accessTokenScope, 0
}

// JWT tokens require a ".", if the token isn't like that, return early
if !strings.Contains(accessToken, ".") {
return 0
return accessTokenScope, 0
}

token, err := oauth2_provider.ParseToken(accessToken, oauth2_provider.DefaultSigningKey)
if err != nil {
log.Trace("oauth2.ParseToken: %v", err)
return 0
return accessTokenScope, 0
}
var grant *auth_model.OAuth2Grant
if grant, err = auth_model.GetOAuth2GrantByID(ctx, token.GrantID); err != nil || grant == nil {
return 0
return accessTokenScope, 0
}
if token.Kind != oauth2_provider.KindAccessToken {
return 0
return accessTokenScope, 0
}
if token.ExpiresAt.Before(time.Now()) || token.IssuedAt.After(time.Now()) {
return 0
return accessTokenScope, 0
}
return grant.UserID
accessTokenScope = oauth2_provider.GrantAdditionalScopes(grant.Scope)
return accessTokenScope, grant.UserID
}

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

// Otherwise, check if this is an OAuth access token
uid := CheckOAuthAccessToken(ctx, tokenSHA)
accessTokenScope, uid := GetOAuthAccessTokenScopeAndUserID(ctx, tokenSHA)
if uid != 0 {
store.GetData()["IsApiToken"] = true
store.GetData()["ApiTokenScope"] = auth_model.AccessTokenScopeAll // fallback to all
store.GetData()["ApiTokenScope"] = accessTokenScope
}
return uid
}
Expand Down
40 changes: 37 additions & 3 deletions services/oauth2_provider/access_token.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ package oauth2_provider //nolint
import (
"context"
"fmt"
"slices"
"strings"

auth "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db"
Expand Down Expand Up @@ -69,6 +71,32 @@ type AccessTokenResponse struct {
IDToken string `json:"id_token,omitempty"`
}

// GrantAdditionalScopes returns valid scopes coming from grant
func GrantAdditionalScopes(grantScopes string) auth.AccessTokenScope {
// scopes_supported from templates/user/auth/oidc_wellknown.tmpl
scopesSupported := []string{
"openid",
"profile",
"email",
"groups",
}

var tokenScopes []string
for _, tokenScope := range strings.Split(grantScopes, " ") {
if slices.Index(scopesSupported, tokenScope) == -1 {
tokenScopes = append(tokenScopes, tokenScope)
}
}

// since version 1.22, access tokens grant full access to the API
// with this access is reduced only if additional scopes are provided
accessTokenScope := auth.AccessTokenScope(strings.Join(tokenScopes, ","))
if accessTokenWithAdditionalScopes, err := accessTokenScope.Normalize(); err == nil && len(tokenScopes) > 0 {
return accessTokenWithAdditionalScopes
}
return auth.AccessTokenScopeAll
}

func NewAccessTokenResponse(ctx context.Context, grant *auth.OAuth2Grant, serverKey, clientKey JWTSigningKey) (*AccessTokenResponse, *AccessTokenError) {
if setting.OAuth2.InvalidateRefreshTokens {
if err := grant.IncreaseCounter(ctx); err != nil {
Expand Down Expand Up @@ -161,7 +189,13 @@ func NewAccessTokenResponse(ctx context.Context, grant *auth.OAuth2Grant, server
idToken.EmailVerified = user.IsActive
}
if grant.ScopeContains("groups") {
groups, err := GetOAuthGroupsForUser(ctx, user)
accessTokenScope := GrantAdditionalScopes(grant.Scope)

// since version 1.22 does not verify if groups should be public-only,
// onlyPublicGroups will be set only if 'public-only' is included in a valid scope
onlyPublicGroups, _ := accessTokenScope.PublicOnly()

groups, err := GetOAuthGroupsForUser(ctx, user, onlyPublicGroups)
if err != nil {
log.Error("Error getting groups: %v", err)
return nil, &AccessTokenError{
Expand Down Expand Up @@ -192,10 +226,10 @@ func NewAccessTokenResponse(ctx context.Context, grant *auth.OAuth2Grant, server

// returns a list of "org" and "org:team" strings,
// that the given user is a part of.
func GetOAuthGroupsForUser(ctx context.Context, user *user_model.User) ([]string, error) {
func GetOAuthGroupsForUser(ctx context.Context, user *user_model.User, onlyPublicGroups bool) ([]string, error) {
orgs, err := db.Find[org_model.Organization](ctx, org_model.FindOrgOptions{
UserID: user.ID,
IncludePrivate: true,
IncludePrivate: !onlyPublicGroups,
})
if err != nil {
return nil, fmt.Errorf("GetUserOrgList: %w", err)
Expand Down
35 changes: 35 additions & 0 deletions services/oauth2_provider/additional_scopes_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package oauth2_provider //nolint

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestGrantAdditionalScopes(t *testing.T) {
tests := []struct {
grantScopes string
expectedScopes string
}{
{"openid profile email", "all"},
{"openid profile email groups", "all"},
{"openid profile email all", "all"},
{"openid profile email read:user all", "all"},
{"openid profile email groups read:user", "read:user"},
{"read:user read:repository", "read:repository,read:user"},
{"read:user write:issue public-only", "public-only,write:issue,read:user"},
{"openid profile email read:user", "read:user"},
{"read:invalid_scope", "all"},
{"read:invalid_scope,write:scope_invalid,just-plain-wrong", "all"},
}

for _, test := range tests {
t.Run(test.grantScopes, func(t *testing.T) {
result := GrantAdditionalScopes(test.grantScopes)
assert.Equal(t, test.expectedScopes, string(result))
})
}
}
5 changes: 4 additions & 1 deletion templates/user/auth/grant.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@
<div class="ui attached segment">
{{template "base/alert" .}}
<p>
{{if not .AdditionalScopes}}
<b>{{ctx.Locale.Tr "auth.authorize_application_description"}}</b><br>
{{ctx.Locale.Tr "auth.authorize_application_created_by" .ApplicationCreatorLinkHTML}}
{{end}}
{{ctx.Locale.Tr "auth.authorize_application_created_by" .ApplicationCreatorLinkHTML}}<br>
{{ctx.Locale.Tr "auth.authorize_application_with_scopes" (HTMLFormat "<b>%s</b>" .Scope)}}
</p>
</div>
<div class="ui attached segment">
Expand Down
Loading
Loading