Skip to content

Commit

Permalink
Enhancing Gitea OAuth2 Provider with Granular Scopes for Resource Acc…
Browse files Browse the repository at this point in the history
…ess (#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 <wxiaoguang@gmail.com>
  • Loading branch information
marcellmars and wxiaoguang authored Nov 22, 2024
1 parent a175f98 commit a3881ff
Show file tree
Hide file tree
Showing 8 changed files with 537 additions and 18 deletions.
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

0 comments on commit a3881ff

Please sign in to comment.