go-gitea / gitea

Git with a cup of tea! Painless self-hosted all-in-one software development service, including Git hosting, code review, team collaboration, package registry and CI/CD
https://gitea.com
MIT License
44.07k stars 5.41k forks source link

Enhancing Gitea OAuth2 Provider with Granular Scopes for Resource Access #31609

Open marcellmars opened 2 months ago

marcellmars commented 2 months ago

Feature Description

Title

"Enhancing OAuth2 Scopes in Gitea for More Granular Access Control"


Hi everyone,

After some research among OAuth2 provider solutions, I ended up with a desire for Gitea to handle that job! ;)

Gitea is useful for more than just coder communities. It doesn't require too many resources and does a great job managing users and allowing them to maintain their accounts.

Other solutions I found either ask for enterprise requirements or don't do a good enough job managing users.

For my use case, Gitea does exactly what I expect from an OAuth2 provider, except for the granular settings of what resources can be accessed through OAuth2 clients using Gitea as the OAuth2 provider.

From my understanding, Gitea serves the usual suspects such as openid, profile, email, and groups, but it also implicitly adds read/write all so every token gets access to everything under the user who accepts the OAuth2 client/service. That has obviously served all the users well so far.

I think it would be great if OAuth2 clients could ask for what they need by requesting additional scopes such as read:user, read:repository, read:issue, write:issue, public-only, etc.

In my case, I would be happy to ask Gitea to only allow read:user. This would make Gitea the best OAuth2 provider for me.

This itch pushed me into my first attempt to hack on Gitea.

I found that I could add a check for additional scopes in CheckOAuthAccessToken and pass it further so it could be used in userIDFromToken. Instead of store.GetData()["ApiTokenScope"] = auth_model.AccessTokenScopeAll (which allows access to all resources under the user), the scopes requested by the OAuth2 client are used.

The new function grantAdditionalScopes adds only the scopes found as AccessTokenScope (all, public-only, read:activitypub, write:activitypub, read:admin, write:admin, read:misc, write:misc etc..

This means one could ask for read-only access to user, issue, and activitypub with read:user, read:issue, and read:activitypub. This would come after the usual OAuth2 suspects: openid, profile, email, and groups.

My approach here is based on reading and understanding how scopes might be used, and one of the examples I found that confirm my understanding is Sample Use Cases: Scopes and Claims at auth0.com.

In my internal tests, this worked fine. I'm not sure if this is the best direction.

While working on this, I felt it would be nice to list requested scopes in the consent snippet for client authorization.

It shouldn't be a big deal to even add the possibility for the user to change/reduce the requested scopes.

Here are a few snippets that made it work for me. I'm interested to hear your feedback on this.

modified   services/auth/oauth2.go
@@ -7,6 +7,7 @@ package auth
 import (
    "context"
    "net/http"
+   "slices"
    "strings"
    "time"

@@ -25,28 +26,67 @@ var (
    _ Method = &OAuth2{}
 )

+// grantAdditionalScopes returns valid scopes coming from grant
+func grantAdditionalScopes(grantScopes string) string {
+   // scopes_supported from templates/user/auth/oidc_wellknown.tmpl
+   scopes_supported := []string{
+       "openid",
+       "profile",
+       "email",
+       "groups",
+   }
+
+   var apiTokenScopes []string
+   for _, apiTokenScope := range strings.Split(grantScopes, " ") {
+       if slices.Index(scopes_supported, apiTokenScope) == -1 {
+           apiTokenScopes = append(apiTokenScopes, apiTokenScope)
+       }
+   }
+
+   if len(apiTokenScopes) == 0 {
+       return ""
+   }
+
+   var additionalGrantScopes []string
+   allScopes := auth_model.AccessTokenScope("all")
+
+   for _, apiTokenScope := range apiTokenScopes {
+       grantScope := auth_model.AccessTokenScope(apiTokenScope)
+       if ok, _ := allScopes.HasScope(grantScope); ok {
+           additionalGrantScopes = append(additionalGrantScopes, apiTokenScope)
+       }
+   }
+   if len(additionalGrantScopes) > 0 {
+       return strings.Join(additionalGrantScopes, ",")
+   }
+
+   return ""
+}
+
 // CheckOAuthAccessToken returns uid of user from oauth token
-func CheckOAuthAccessToken(ctx context.Context, accessToken string) int64 {
+// + non default openid scopes requested
+func CheckOAuthAccessToken(ctx context.Context, accessToken string) (int64, string) {
    // JWT tokens require a "."
    if !strings.Contains(accessToken, ".") {
-       return 0
+       return 0, ""
    }
    token, err := oauth2.ParseToken(accessToken, oauth2.DefaultSigningKey)
    if err != nil {
        log.Trace("oauth2.ParseToken: %v", err)
-       return 0
+       return 0, ""
    }
    var grant *auth_model.OAuth2Grant
    if grant, err = auth_model.GetOAuth2GrantByID(ctx, token.GrantID); err != nil || grant == nil {
-       return 0
+       return 0, ""
    }
    if token.Type != oauth2.TypeAccessToken {
-       return 0
+       return 0, ""
    }
    if token.ExpiresAt.Before(time.Now()) || token.IssuedAt.After(time.Now()) {
-       return 0
+       return 0, ""
    }
-   return grant.UserID
+   grantScopes := grantAdditionalScopes(grant.Scope)
+   return grant.UserID, grantScopes
 }

 // OAuth2 implements the Auth interface and authenticates requests
@@ -92,10 +132,15 @@ func parseToken(req *http.Request) (string, bool) {
 func (o *OAuth2) userIDFromToken(ctx context.Context, tokenSHA string, store DataStore) int64 {
    // Let's see if token is valid.
    if strings.Contains(tokenSHA, ".") {
-       uid := CheckOAuthAccessToken(ctx, tokenSHA)
+       uid, grantScopes := CheckOAuthAccessToken(ctx, tokenSHA)
+
        if uid != 0 {
            store.GetData()["IsApiToken"] = true
-           store.GetData()["ApiTokenScope"] = auth_model.AccessTokenScopeAll // fallback to all
+           if grantScopes != "" {
+               store.GetData()["ApiTokenScope"] = auth_model.AccessTokenScope(grantScopes)
+           } else {
+               store.GetData()["ApiTokenScope"] = auth_model.AccessTokenScopeAll // fallback to all
+           }
        }
        return uid
    }
modified   services/auth/basic.go
@@ -72,7 +72,7 @@ func (b *Basic) Verify(req *http.Request, w http.ResponseWriter, store DataStore
    }

    // check oauth2 token
-   uid := CheckOAuthAccessToken(req.Context(), authToken)
+   uid, _ := CheckOAuthAccessToken(req.Context(), authToken)
    if uid != 0 {
        log.Trace("Basic Authorization: Valid OAuthAccessToken for user[%d]", uid)
modified   templates/user/auth/grant.tmpl
@@ -11,6 +11,7 @@
                    <b>{{ctx.Locale.Tr "auth.authorize_application_description"}}</b><br>
                    {{ctx.Locale.Tr "auth.authorize_application_created_by" .ApplicationCreatorLinkHTML}}
                </p>
+               <p>With scopes: {{ .Scope }}.</p>
            </div>
            <div class="ui attached segment">
                <p>{{ctx.Locale.Tr "auth.authorize_redirect_notice" .ApplicationRedirectDomainHTML}}</p>
techknowlogick commented 1 month ago

Hi @marcellmars, thanks for this issue. Sorry you didn't get a response sooner. This is most definitely planned, and so I'll reopen this issue so it can be tracked.

marcellmars commented 1 month ago

Hi @marcellmars, thanks for this issue. Sorry you didn't get a response sooner. This is most definitely planned, and so I'll reopen this issue so it can be tracked.

Thanks for coming back.

If anyone gets into it, maybe they can cherry-pick it back from Forgejo, where I expanded this initial idea, with great support from the community, into a PR.