Description
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>