Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Closed
marcellmars opened this issue Jul 10, 2024 · 8 comments · Fixed by #32573
Closed

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

marcellmars opened this issue Jul 10, 2024 · 8 comments · Fixed by #32573
Labels
proposal/accepted We have reviewed the proposal and agree that it should be implemented like that/at all. type/proposal The new feature has not been accepted yet but needs to be discussed first.

Comments

@marcellmars
Copy link
Contributor

marcellmars commented Jul 10, 2024

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>
 				
@marcellmars marcellmars added the type/proposal The new feature has not been accepted yet but needs to be discussed first. label Jul 10, 2024
@marcellmars marcellmars closed this as not planned Won't fix, can't repro, duplicate, stale Aug 6, 2024
@techknowlogick
Copy link
Member

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.

@techknowlogick techknowlogick reopened this Aug 6, 2024
@marcellmars
Copy link
Contributor Author

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.

@er2off
Copy link

er2off commented Sep 22, 2024

Any updates? I cherry-picked PR from Forgejo and it worked so it will be nice to have this in regular Gitea.

@marcellmars
Copy link
Contributor Author

marcellmars commented Sep 27, 2024

Any updates? I cherry-picked the PR from Forgejo, and it worked, so it would be great to have this in regular Gitea.

The previously linked PR should be ready for the new v9 Forgejo release, scheduled for 16 October 2024.

During testing, I realized that—following my initial goal of creating a minimal OIDC Single-Sign-On—using only the openid and public-only scopes achieves exactly what I wanted. If you need additional scopes like profile, email, or groups, those should work just fine.

However, when preparing a good example for the documentation, I discovered that the public-only scope, which was supposed to protect private repositories and organizations from being exposed, wasn’t working—not only for OAuth2 apps (I was trying to test) but also for personal access tokens when it was originally introduced.

This led to another PR that attempts to address that issue. I hope both PRs make it into the next release. Once it lands in Forgejo, I’ll see how to move it forward for Gitea.

@lunny
Copy link
Member

lunny commented Sep 27, 2024

I don't know whether we can easily cherry-pick code from that project because of the LICENSE compatibility between GPLv3 and MIT(Gitea). And I sent #32148 maybe break the patch but I think it's necessary to make the code clear.

@lunny lunny added the proposal/accepted We have reviewed the proposal and agree that it should be implemented like that/at all. label Sep 27, 2024
@marcellmars
Copy link
Contributor Author

marcellmars commented Sep 30, 2024

I don't know whether we can easily cherry-pick code from that project because of the LICENSE compatibility between GPLv3 and MIT(Gitea). And I sent #32148 maybe break the patch but I think it's necessary to make the code clear.

I'm not certain about the LICENSE compatibility, but I've been informed that Forgejo contributors are definitely allowed to contribute to Gitea as well.

I applied both pull requests from Forgejo against the #32148 and I managed to get it all in, together with tests. I did it in two big commits. I hope that will be comprehensible enough.

The pull request is here.

I don't know when lunny:lunny/clean_oauth2 will be merged so this is intended more as a form of testing before submitting a proper pull request or two.

@lunny is that ok?

@lunny
Copy link
Member

lunny commented Oct 1, 2024

I don't know whether we can easily cherry-pick code from that project because of the LICENSE compatibility between GPLv3 and MIT(Gitea). And I sent #32148 maybe break the patch but I think it's necessary to make the code clear.

I'm not certain about the LICENSE compatibility, but I've been informed that Forgejo contributors are definitely allowed to contribute to Gitea as well.

I applied both pull requests from Forgejo against the #32148 and I managed to get it all in, together with tests. I did it in two big commits. I hope that will be comprehensible enough.

The pull request is here.

I don't know when lunny:lunny/clean_oauth2 will be merged so this is intended more as a form of testing before submitting a proper pull request or two.

@lunny is that ok?

Thank you very much. I will review those pull requests ASAP. And if you can send docs pull request to https://gitea.com/gitea/docs, that would be better.

@marcellmars
Copy link
Contributor Author

I don't know whether we can easily cherry-pick code from that project because of the LICENSE compatibility between GPLv3 and MIT(Gitea). And I sent #32148 maybe break the patch but I think it's necessary to make the code clear.

I'm not certain about the LICENSE compatibility, but I've been informed that Forgejo contributors are definitely allowed to contribute to Gitea as well.
I applied both pull requests from Forgejo against the #32148 and I managed to get it all in, together with tests. I did it in two big commits. I hope that will be comprehensible enough.
The pull request is here.
I don't know when lunny:lunny/clean_oauth2 will be merged so this is intended more as a form of testing before submitting a proper pull request or two.
@lunny is that ok?

Thank you very much. I will review those pull requests ASAP. And if you can send docs pull request to https://gitea.com/gitea/docs, that would be better.

i deleted the old PR and made a new cleaned up one here

lunny pushed a commit that referenced this issue Nov 22, 2024
…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 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
proposal/accepted We have reviewed the proposal and agree that it should be implemented like that/at all. type/proposal The new feature has not been accepted yet but needs to be discussed first.
Projects
None yet
Development

Successfully merging a pull request may close this issue.

4 participants