diff --git a/modules/setting/oauth2.go b/modules/setting/oauth2.go
index 0d3e63e0b4aa3..f6b802f223994 100644
--- a/modules/setting/oauth2.go
+++ b/modules/setting/oauth2.go
@@ -90,23 +90,25 @@ func parseScopes(sec ConfigSection, name string) []string {
}
var OAuth2 = struct {
- Enabled bool
- AccessTokenExpirationTime int64
- RefreshTokenExpirationTime int64
- InvalidateRefreshTokens bool
- JWTSigningAlgorithm string `ini:"JWT_SIGNING_ALGORITHM"`
- JWTSigningPrivateKeyFile string `ini:"JWT_SIGNING_PRIVATE_KEY_FILE"`
- MaxTokenLength int
- DefaultApplications []string
+ Enabled bool
+ AccessTokenExpirationTime int64
+ RefreshTokenExpirationTime int64
+ InvalidateRefreshTokens bool
+ JWTSigningAlgorithm string `ini:"JWT_SIGNING_ALGORITHM"`
+ JWTSigningPrivateKeyFile string `ini:"JWT_SIGNING_PRIVATE_KEY_FILE"`
+ MaxTokenLength int
+ DefaultApplications []string
+ EnableAdditionalGrantScopes bool
}{
- Enabled: true,
- AccessTokenExpirationTime: 3600,
- RefreshTokenExpirationTime: 730,
- InvalidateRefreshTokens: false,
- JWTSigningAlgorithm: "RS256",
- JWTSigningPrivateKeyFile: "jwt/private.pem",
- MaxTokenLength: math.MaxInt16,
- DefaultApplications: []string{"git-credential-oauth", "git-credential-manager", "tea"},
+ Enabled: true,
+ AccessTokenExpirationTime: 3600,
+ RefreshTokenExpirationTime: 730,
+ InvalidateRefreshTokens: false,
+ JWTSigningAlgorithm: "RS256",
+ JWTSigningPrivateKeyFile: "jwt/private.pem",
+ MaxTokenLength: math.MaxInt16,
+ DefaultApplications: []string{"git-credential-oauth", "git-credential-manager", "tea"},
+ EnableAdditionalGrantScopes: false,
}
func loadOAuth2From(rootCfg ConfigProvider) {
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index f77fd203a2d6b..e0cf5224be6b2 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -893,8 +893,8 @@ access_token_deletion_confirm_action = Delete
access_token_deletion_desc = Deleting a token will revoke access to your account for applications using it. This cannot be undone. Continue?
delete_token_success = The token has been deleted. Applications using it no longer have access to your account.
repo_and_org_access = Repository and Organization Access
-permissions_public_only = Public only
-permissions_access_all = All (public, private, and limited)
+permissions_public_only = Public only (Grant access only to public and exclude private and limited organizations and repositories)
+permissions_access_all = All (Grant access to public, private, and limited organizations and repositories)
select_permissions = Select permissions
permission_not_set = Not set
permission_no_access = No Access
diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go
index 1244676508ee5..7e44e832a2c7d 100644
--- a/routers/api/v1/api.go
+++ b/routers/api/v1/api.go
@@ -92,6 +92,7 @@ import (
"code.gitea.io/gitea/routers/api/v1/repo"
"code.gitea.io/gitea/routers/api/v1/settings"
"code.gitea.io/gitea/routers/api/v1/user"
+ "code.gitea.io/gitea/routers/api/v1/utils"
"code.gitea.io/gitea/routers/common"
"code.gitea.io/gitea/services/actions"
"code.gitea.io/gitea/services/auth"
@@ -184,6 +185,10 @@ func repoAssignment() func(ctx *context.APIContext) {
}
return
}
+ if repo.IsPrivate && utils.PublicOnlyToken(ctx, "ApiTokenScopePublicRepoOnly") {
+ ctx.NotFound()
+ return
+ }
repo.Owner = owner
ctx.Repo.Repository = repo
@@ -954,9 +959,9 @@ func Routes() *web.Router {
m.Get("/{target}", user.CheckFollowing)
})
- m.Get("/starred", user.GetStarredRepos)
+ m.Get("/starred", tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository), user.GetStarredRepos)
- m.Get("/subscriptions", user.GetWatchedRepos)
+ m.Get("/subscriptions", tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository), user.GetWatchedRepos)
}, context.UserAssignmentAPI())
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser), reqToken())
@@ -1476,13 +1481,13 @@ func Routes() *web.Router {
m.Get("/{org}/permissions", reqToken(), org.GetUserOrgsPermissions)
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser, auth_model.AccessTokenScopeCategoryOrganization), context.UserAssignmentAPI())
m.Post("/orgs", tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), reqToken(), bind(api.CreateOrgOption{}), org.Create)
- m.Get("/orgs", org.GetAll, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization))
+ m.Get("/orgs", tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), org.GetAll)
m.Group("/orgs/{org}", func() {
m.Combo("").Get(org.Get).
Patch(reqToken(), reqOrgOwnership(), bind(api.EditOrgOption{}), org.Edit).
Delete(reqToken(), reqOrgOwnership(), org.Delete)
- m.Combo("/repos").Get(user.ListOrgRepos).
- Post(reqToken(), bind(api.CreateRepoOption{}), repo.CreateOrgRepo)
+ m.Combo("/repos").Get(tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository), user.ListOrgRepos).
+ Post(reqToken(), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository), bind(api.CreateRepoOption{}), repo.CreateOrgRepo)
m.Group("/members", func() {
m.Get("", reqToken(), org.ListMembers)
m.Combo("/{username}").Get(reqToken(), org.IsMember).
@@ -1550,7 +1555,7 @@ func Routes() *web.Router {
Put(reqToken(), org.AddTeamRepository).
Delete(reqToken(), org.RemoveTeamRepository).
Get(reqToken(), org.GetTeamRepo)
- })
+ }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository, auth_model.AccessTokenScopeCategoryRepository))
m.Get("/activities/feeds", org.ListTeamActivityFeeds)
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(false, true), reqToken(), reqTeamMembership())
@@ -1570,23 +1575,25 @@ func Routes() *web.Router {
m.Post("", bind(api.CreateKeyOption{}), admin.CreatePublicKey)
m.Delete("/{id}", admin.DeleteUserPublicKey)
})
- m.Get("/orgs", org.ListUserOrgs)
- m.Post("/orgs", bind(api.CreateOrgOption{}), admin.CreateOrg)
- m.Post("/repos", bind(api.CreateRepoOption{}), admin.CreateRepo)
+ m.Get("/orgs", tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), org.ListUserOrgs)
+ m.Get("/orgs", tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), org.ListUserOrgs)
+ m.Post("/orgs", tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), bind(api.CreateOrgOption{}), admin.CreateOrg)
+ m.Post("/repos", tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository), bind(api.CreateRepoOption{}), admin.CreateRepo)
m.Post("/rename", bind(api.RenameUserOption{}), admin.RenameUser)
m.Get("/badges", admin.ListUserBadges)
m.Post("/badges", bind(api.UserBadgeOption{}), admin.AddUserBadges)
m.Delete("/badges", bind(api.UserBadgeOption{}), admin.DeleteUserBadges)
}, context.UserAssignmentAPI())
- })
+ }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser))
+
m.Group("/emails", func() {
m.Get("", admin.GetAllEmails)
m.Get("/search", admin.SearchEmail)
})
m.Group("/unadopted", func() {
m.Get("", admin.ListUnadoptedRepositories)
- m.Post("/{username}/{reponame}", admin.AdoptRepository)
- m.Delete("/{username}/{reponame}", admin.DeleteUnadoptedRepository)
+ m.Post("/{username}/{reponame}", tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository), admin.AdoptRepository)
+ m.Delete("/{username}/{reponame}", tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository), admin.DeleteUnadoptedRepository)
})
m.Group("/hooks", func() {
m.Combo("").Get(admin.ListHooks).
diff --git a/routers/api/v1/org/org.go b/routers/api/v1/org/org.go
index e848d95181094..f859d8a981318 100644
--- a/routers/api/v1/org/org.go
+++ b/routers/api/v1/org/org.go
@@ -26,6 +26,9 @@ import (
func listUserOrgs(ctx *context.APIContext, u *user_model.User) {
listOptions := utils.GetListOptions(ctx)
showPrivate := ctx.IsSigned && (ctx.Doer.IsAdmin || ctx.Doer.ID == u.ID)
+ if utils.PublicOnlyToken(ctx, "ApiTokenScopePublicOrgOnly") {
+ showPrivate = false
+ }
opts := organization.FindOrgOptions{
ListOptions: listOptions,
@@ -191,10 +194,12 @@ func GetAll(ctx *context.APIContext) {
// "$ref": "#/responses/OrganizationList"
vMode := []api.VisibleType{api.VisibleTypePublic}
- if ctx.IsSigned {
- vMode = append(vMode, api.VisibleTypeLimited)
- if ctx.Doer.IsAdmin {
- vMode = append(vMode, api.VisibleTypePrivate)
+ if !utils.PublicOnlyToken(ctx, "ApiTokenScopePublicOrgOnly") {
+ if ctx.IsSigned {
+ vMode = append(vMode, api.VisibleTypeLimited)
+ if ctx.Doer.IsAdmin {
+ vMode = append(vMode, api.VisibleTypePrivate)
+ }
}
}
@@ -304,6 +309,11 @@ func Get(ctx *context.APIContext) {
return
}
+ if !ctx.Org.Organization.Visibility.IsPublic() && utils.PublicOnlyToken(ctx, "ApiTokenScopePublicOrgOnly") {
+ ctx.NotFound()
+ return
+ }
+
org := convert.ToOrganization(ctx, ctx.Org.Organization)
// Don't show Mail, when User is not logged in
diff --git a/routers/api/v1/repo/repo.go b/routers/api/v1/repo/repo.go
index 1bcec8fcf7e72..b9bfd69be6f40 100644
--- a/routers/api/v1/repo/repo.go
+++ b/routers/api/v1/repo/repo.go
@@ -197,6 +197,11 @@ func Search(ctx *context.APIContext) {
}
}
+ if utils.PublicOnlyToken(ctx, "ApiTokenScopePublicRepoOnly") {
+ opts.Private = false
+ opts.IsPrivate = optional.Some(false)
+ }
+
var err error
repos, count, err := repo_model.SearchRepository(ctx, opts)
if err != nil {
@@ -589,6 +594,12 @@ func GetByID(ctx *context.APIContext) {
ctx.NotFound()
return
}
+
+ if repo.IsPrivate && utils.PublicOnlyToken(ctx, "ApiTokenScopePublicRepoOnly") {
+ ctx.NotFound()
+ return
+ }
+
ctx.JSON(http.StatusOK, convert.ToRepo(ctx, repo, permission))
}
diff --git a/routers/api/v1/user/repo.go b/routers/api/v1/user/repo.go
index d0264d6b5a261..7ee8207c05337 100644
--- a/routers/api/v1/user/repo.go
+++ b/routers/api/v1/user/repo.go
@@ -80,6 +80,9 @@ func ListUserRepos(ctx *context.APIContext) {
// "$ref": "#/responses/notFound"
private := ctx.IsSigned
+ if utils.PublicOnlyToken(ctx, "ApiTokenScopePublicRepoOnly") {
+ private = false
+ }
listUserRepos(ctx, ctx.ContextUser, private)
}
@@ -111,6 +114,10 @@ func ListMyRepos(ctx *context.APIContext) {
IncludeDescription: true,
}
+ if utils.PublicOnlyToken(ctx, "ApiTokenScopePublicRepoOnly") {
+ opts.Private = false
+ }
+
var err error
repos, count, err := repo_model.SearchRepository(ctx, opts)
if err != nil {
@@ -162,6 +169,9 @@ func ListOrgRepos(ctx *context.APIContext) {
// "$ref": "#/responses/RepositoryList"
// "404":
// "$ref": "#/responses/notFound"
-
- listUserRepos(ctx, ctx.Org.Organization.AsUser(), ctx.IsSigned)
+ private := ctx.IsSigned
+ if utils.PublicOnlyToken(ctx, "ApiTokenScopePublicRepoOnly") {
+ private = false
+ }
+ listUserRepos(ctx, ctx.Org.Organization.AsUser(), private)
}
diff --git a/routers/api/v1/utils/token.go b/routers/api/v1/utils/token.go
new file mode 100644
index 0000000000000..1f959a8d579ad
--- /dev/null
+++ b/routers/api/v1/utils/token.go
@@ -0,0 +1,12 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package utils
+
+import "code.gitea.io/gitea/services/context"
+
+// check if api token contains `public-only` scope
+func PublicOnlyToken(ctx *context.APIContext, scopeKey string) bool {
+ publicScope, _ := ctx.Data[scopeKey].(bool)
+ return publicScope
+}
diff --git a/routers/web/auth/oauth2_provider.go b/routers/web/auth/oauth2_provider.go
index 29827b062de84..952304b1fed5a 100644
--- a/routers/web/auth/oauth2_provider.go
+++ b/routers/web/auth/oauth2_provider.go
@@ -85,7 +85,7 @@ type userInfoResponse struct {
Username string `json:"preferred_username"`
Email string `json:"email"`
Picture string `json:"picture"`
- Groups []string `json:"groups"`
+ Groups []string `json:"groups,omitempty"`
}
// InfoOAuth manages request for userinfo endpoint
@@ -104,7 +104,19 @@ func InfoOAuth(ctx *context.Context) {
Picture: ctx.Doer.AvatarLink(ctx),
}
- groups, err := oauth2_provider.GetOAuthGroupsForUser(ctx, ctx.Doer)
+ // groups, err := oauth2_provider.GetOAuthGroupsForUser(ctx, ctx.Doer)
+ var token string
+ if auHead := ctx.Req.Header.Get("Authorization"); auHead != "" {
+ auths := strings.Fields(auHead)
+ if len(auths) == 2 && (auths[0] == "token" || strings.ToLower(auths[0]) == "bearer") {
+ token = auths[1]
+ }
+ }
+
+ _, grantScopes := auth_service.CheckOAuthAccessToken(ctx, token)
+ onlyPublicGroups := oauth2_provider.IfOnlyPublicGroups(grantScopes)
+
+ groups, err := oauth2_provider.GetOAuthGroupsForUser(ctx, ctx.Doer, onlyPublicGroups)
if err != nil {
ctx.ServerError("Oauth groups for user", err)
return
@@ -304,6 +316,13 @@ func AuthorizeOAuth(ctx *context.Context) {
return
}
+ // check if additional scopes
+ if auth_service.GrantAdditionalScopes(form.Scope) == "" {
+ ctx.Data["AdditionalScopes"] = false
+ } else {
+ ctx.Data["AdditionalScopes"] = true
+ }
+
// show authorize page to grant access
ctx.Data["Application"] = app
ctx.Data["RedirectURI"] = form.RedirectURI
diff --git a/routers/web/user/setting/applications.go b/routers/web/user/setting/applications.go
index 356c2ea5de37f..6c2c182911332 100644
--- a/routers/web/user/setting/applications.go
+++ b/routers/web/user/setting/applications.go
@@ -113,5 +113,6 @@ func loadApplicationsData(ctx *context.Context) {
ctx.ServerError("GetOAuth2GrantsByUserID", err)
return
}
+ ctx.Data["EnableAdditionalGrantScopes"] = setting.OAuth2.EnableAdditionalGrantScopes
}
}
diff --git a/services/auth/additional_scopes_test.go b/services/auth/additional_scopes_test.go
new file mode 100644
index 0000000000000..9cc88d0dead7c
--- /dev/null
+++ b/services/auth/additional_scopes_test.go
@@ -0,0 +1,35 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package auth
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestGrantAdditionalScopes(t *testing.T) {
+ tests := []struct {
+ grantScopes string
+ expectedScopes string
+ }{
+ {"openid profile email", ""},
+ {"openid profile email groups", ""},
+ {"openid profile email all", "all"},
+ {"openid profile email read:user all", "read:user,all"},
+ {"openid profile email groups read:user", "read:user"},
+ {"read:user read:repository", "read:user,read:repository"},
+ {"read:user write:issue public-only", "read:user,write:issue,public-only"},
+ {"openid profile email read:user", "read:user"},
+ {"read:invalid_scope", ""},
+ {"read:invalid_scope,write:scope_invalid,just-plain-wrong", ""},
+ }
+
+ for _, test := range tests {
+ t.Run(test.grantScopes, func(t *testing.T) {
+ result := GrantAdditionalScopes(test.grantScopes)
+ assert.Equal(t, test.expectedScopes, result)
+ })
+ }
+}
diff --git a/services/auth/basic.go b/services/auth/basic.go
index 90bd64237091d..916e558281d37 100644
--- a/services/auth/basic.go
+++ b/services/auth/basic.go
@@ -77,7 +77,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)
diff --git a/services/auth/oauth2.go b/services/auth/oauth2.go
index 523998a634522..65d4046c10042 100644
--- a/services/auth/oauth2.go
+++ b/services/auth/oauth2.go
@@ -7,6 +7,7 @@ package auth
import (
"context"
"net/http"
+ "slices"
"strings"
"time"
@@ -25,28 +26,75 @@ var (
_ Method = &OAuth2{}
)
+// GrantAdditionalScopes returns valid scopes coming from grant
+func GrantAdditionalScopes(grantScopes string) string {
+ // process additional scopes only if enabled in settings
+ // this could be removed once additional scopes get accepted
+ if !setting.OAuth2.EnableAdditionalGrantScopes {
+ return ""
+ }
+
+ // scopes_supported from templates/user/auth/oidc_wellknown.tmpl
+ scopesSupported := []string{
+ "openid",
+ "profile",
+ "email",
+ "groups",
+ }
+
+ var apiTokenScopes []string
+ for _, apiTokenScope := range strings.Split(grantScopes, " ") {
+ if slices.Index(scopesSupported, 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)
+ } else if apiTokenScope == "public-only" {
+ 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_provider.ParseToken(accessToken, oauth2_provider.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.Kind != oauth2_provider.KindAccessToken {
- 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 +140,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
}
diff --git a/services/oauth2_provider/access_token.go b/services/oauth2_provider/access_token.go
index 00c960caf2ee7..7a201256dc33f 100644
--- a/services/oauth2_provider/access_token.go
+++ b/services/oauth2_provider/access_token.go
@@ -6,6 +6,7 @@ package oauth2_provider //nolint
import (
"context"
"fmt"
+ "strings"
auth "code.gitea.io/gitea/models/auth"
org_model "code.gitea.io/gitea/models/organization"
@@ -68,6 +69,20 @@ type AccessTokenResponse struct {
IDToken string `json:"id_token,omitempty"`
}
+// check if groups are public only
+func IfOnlyPublicGroups(scopes string) bool {
+ scopes = strings.ReplaceAll(scopes, ",", " ")
+ scopesList := strings.Fields(scopes)
+ for _, scope := range scopesList {
+ if scope == "public-only" {
+ return true
+ } else if scope == "all" || scope == "read:organization" || scope == "read:admin" {
+ return false
+ }
+ }
+ return true
+}
+
func NewAccessTokenResponse(ctx context.Context, grant *auth.OAuth2Grant, serverKey, clientKey JWTSigningKey) (*AccessTokenResponse, *AccessTokenError) {
if setting.OAuth2.InvalidateRefreshTokens {
if err := grant.IncreaseCounter(ctx); err != nil {
@@ -160,7 +175,9 @@ func NewAccessTokenResponse(ctx context.Context, grant *auth.OAuth2Grant, server
idToken.EmailVerified = user.IsActive
}
if grant.ScopeContains("groups") {
- groups, err := GetOAuthGroupsForUser(ctx, user)
+ onlyPublicGroups := IfOnlyPublicGroups(grant.Scope)
+
+ groups, err := GetOAuthGroupsForUser(ctx, user, onlyPublicGroups)
if err != nil {
log.Error("Error getting groups: %v", err)
return nil, &AccessTokenError{
@@ -191,7 +208,7 @@ 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 := org_model.GetUserOrgsList(ctx, user)
if err != nil {
return nil, fmt.Errorf("GetUserOrgList: %w", err)
@@ -199,6 +216,18 @@ func GetOAuthGroupsForUser(ctx context.Context, user *user_model.User) ([]string
var groups []string
for _, org := range orgs {
+ // process additional scopes only if enabled in settings
+ // this could be removed once additional scopes get accepted
+ if setting.OAuth2.EnableAdditionalGrantScopes {
+ if onlyPublicGroups {
+ if public, err := org_model.IsPublicMembership(ctx, org.ID, user.ID); err == nil {
+ if !public || !org.Visibility.IsPublic() {
+ continue
+ }
+ }
+ }
+ }
+
groups = append(groups, org.Name)
teams, err := org.LoadTeams(ctx)
if err != nil {
diff --git a/templates/user/auth/grant.tmpl b/templates/user/auth/grant.tmpl
index a18a3bd27a23c..e12eff4b40abb 100644
--- a/templates/user/auth/grant.tmpl
+++ b/templates/user/auth/grant.tmpl
@@ -8,8 +8,11 @@
{{template "base/alert" .}}
+ {{ if not .AdditionalScopes }}
{{ctx.Locale.Tr "auth.authorize_application_description"}}
+ {{ end }}
{{ctx.Locale.Tr "auth.authorize_application_created_by" .ApplicationCreatorLinkHTML}}
+
With scopes: {{.Scope}}.
diff --git a/tests/integration/api_token_test.go b/tests/integration/api_token_test.go
index 01d18ef6f16b8..4ae12f8a64e60 100644
--- a/tests/integration/api_token_test.go
+++ b/tests/integration/api_token_test.go
@@ -563,3 +563,192 @@ func deleteAPIAccessToken(t *testing.T, accessToken api.AccessToken, user *user_
unittest.AssertNotExistsBean(t, &auth_model.AccessToken{ID: accessToken.ID})
}
+
+// TestAPIPublicOnlyTokenRepos tests that token with public-only scope only shows public repos
+func TestAPIPublicOnlyTokenRepos(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+ publicOnlyScopes := []auth_model.AccessTokenScope{"public-only", "read:user", "read:repository"}
+ publicOnlyAccessToken := createAPIAccessTokenWithoutCleanUp(t, "public-only-token", user, publicOnlyScopes)
+ publicOnlyReq := NewRequest(t, "GET", "/api/v1/user/repos").
+ AddTokenAuth(publicOnlyAccessToken.Token)
+ publicOnlyResp := MakeRequest(t, publicOnlyReq, http.StatusOK)
+ var publicOnlyRepos []api.Repository
+ DecodeJSON(t, publicOnlyResp, &publicOnlyRepos)
+ publicOnlyReposCaptured := make(map[string]int64)
+ for _, repo := range publicOnlyRepos {
+ publicOnlyReposCaptured[repo.Name] = repo.ID
+ assert.False(t, repo.Private)
+ }
+ publicOnlyReposExpected := map[string]int64{
+ "commits_search_test": 36,
+ "commitsonpr": 58,
+ "git_hooks_test": 37,
+ "glob": 42,
+ "repo-release": 57,
+ "repo1": 1,
+ "repo21": 32,
+ "utf8": 33,
+ }
+ assert.Equal(t, publicOnlyReposExpected, publicOnlyReposCaptured)
+
+ noPublicOnlyScopes := []auth_model.AccessTokenScope{"read:user", "read:repository"}
+ noPublicOnlyAccessToken := createAPIAccessTokenWithoutCleanUp(t, "no-public-only-token", user, noPublicOnlyScopes)
+ noPublicOnlyReq := NewRequest(t, "GET", "/api/v1/user/repos").
+ AddTokenAuth(noPublicOnlyAccessToken.Token)
+ noPublicOnlyResp := MakeRequest(t, noPublicOnlyReq, http.StatusOK)
+ var allRepos []api.Repository
+ DecodeJSON(t, noPublicOnlyResp, &allRepos)
+ allPrivateReposCaptured := make(map[string]int64)
+ for _, repo := range allRepos {
+ if repo.Private {
+ allPrivateReposCaptured[repo.Name] = repo.ID
+ }
+ }
+ allPrivateReposExpected := map[string]int64{
+ "big_test_private_4": 24,
+ "lfs": 54,
+ "readme-test": 56,
+ "repo15": 15,
+ "repo16": 16,
+ "repo2": 2,
+ "repo20": 31,
+ "repo3": 3,
+ "repo5": 5,
+ "scoped_label": 55,
+ "test_commit_revert": 59,
+ }
+
+ assert.Equal(t, allPrivateReposExpected, allPrivateReposCaptured)
+
+ privateRepo2Req := NewRequest(t, "GET", "/api/v1/repos/user2/repo2").
+ AddTokenAuth(noPublicOnlyAccessToken.Token)
+ privateRepo2Resp := MakeRequest(t, privateRepo2Req, http.StatusOK)
+ var repo2 api.Repository
+ DecodeJSON(t, privateRepo2Resp, &repo2)
+ assert.True(t, repo2.Private)
+
+ publicOnlyPrivateRepo2Req := NewRequest(t, "GET", "/api/v1/repos/user2/repo2").
+ AddTokenAuth(publicOnlyAccessToken.Token)
+ MakeRequest(t, publicOnlyPrivateRepo2Req, http.StatusNotFound)
+
+ // search query = repo1
+ searchPublicOnlyReposReq := NewRequest(t, "GET", "/api/v1/repos/search?q=repo1").
+ AddTokenAuth(publicOnlyAccessToken.Token)
+ searchPublicOnlyReposResp := MakeRequest(t, searchPublicOnlyReposReq, http.StatusOK)
+ var searchPublicOnlyRepos api.SearchResults
+ DecodeJSON(t, searchPublicOnlyReposResp, &searchPublicOnlyRepos)
+ var searchPublicOnlyRepoNamesCaptured []string
+ for _, repo := range searchPublicOnlyRepos.Data {
+ searchPublicOnlyRepoNamesCaptured = append(searchPublicOnlyRepoNamesCaptured, repo.Name)
+ }
+ searchPublicOnlyRepoNamesExpected := []string{
+ "repo1",
+ "repo10",
+ "repo11",
+ }
+
+ assert.Equal(t, searchPublicOnlyRepoNamesExpected, searchPublicOnlyRepoNamesCaptured)
+
+ notFoundPrivateByIDReq := NewRequest(t, "GET", "/api/v1/repositories/3").
+ AddTokenAuth(publicOnlyAccessToken.Token)
+ MakeRequest(t, notFoundPrivateByIDReq, http.StatusNotFound)
+
+ foundPrivateByIDReq := NewRequest(t, "GET", "/api/v1/repositories/3").
+ AddTokenAuth(noPublicOnlyAccessToken.Token)
+ MakeRequest(t, foundPrivateByIDReq, http.StatusOK)
+}
+
+// TestAPIPublicOnlyTokenOrgs tests that token with public-only scope only shows public organizations
+func TestAPIPublicOnlyTokenOrgs(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+ session := loginUser(t, user.Name)
+
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteOrganization)
+ org := api.EditOrgOption{
+ Visibility: "private",
+ }
+ req := NewRequestWithJSON(t, "PATCH", "/api/v1/orgs/org3", &org).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+
+ publicOnlyScopes := []auth_model.AccessTokenScope{"public-only", "read:user", "read:organization", "read:repository"}
+ publicOnlyAccessToken := createAPIAccessTokenWithoutCleanUp(t, "public-only-token", user, publicOnlyScopes)
+ publicOnlyReq := NewRequest(t, "GET", "/api/v1/user/orgs").
+ AddTokenAuth(publicOnlyAccessToken.Token)
+ publicOnlyResp := MakeRequest(t, publicOnlyReq, http.StatusOK)
+ var publicOnlyOrgs []api.Organization
+ DecodeJSON(t, publicOnlyResp, &publicOnlyOrgs)
+ for _, org := range publicOnlyOrgs {
+ assert.Equal(t, "public", org.Visibility)
+ }
+ noPublicOnlyScopes := []auth_model.AccessTokenScope{"read:user", "read:organization", "read:repository"}
+ noPublicOnlyAccessToken := createAPIAccessTokenWithoutCleanUp(t, "no-public-only-token", user, noPublicOnlyScopes)
+ noPublicOnlyReq := NewRequest(t, "GET", "/api/v1/user/orgs").
+ AddTokenAuth(noPublicOnlyAccessToken.Token)
+ noPublicOnlyResp := MakeRequest(t, noPublicOnlyReq, http.StatusOK)
+ var allOrgs []api.Organization
+ DecodeJSON(t, noPublicOnlyResp, &allOrgs)
+ allOrgsCaptured := make(map[string]string)
+ for _, org := range allOrgs {
+ allOrgsCaptured[org.Name] = org.Visibility
+ }
+ allOrgsExpected := map[string]string{
+ "org17": "public",
+ "org3": "private",
+ "private_org35": "private",
+ }
+ assert.Equal(t, allOrgsExpected, allOrgsCaptured)
+
+ publicOnlySlashOrgReq := NewRequest(t, "GET", "/api/v1/orgs").
+ AddTokenAuth(publicOnlyAccessToken.Token)
+ publicOnlySlashOrgResp := MakeRequest(t, publicOnlySlashOrgReq, http.StatusOK)
+ DecodeJSON(t, publicOnlySlashOrgResp, &publicOnlyOrgs)
+ publicOrgsCaptured := make(map[string]string)
+ for _, org := range publicOnlyOrgs {
+ publicOrgsCaptured[org.Name] = org.Visibility
+ }
+ publicOrgsExpected := map[string]string{
+ "org17": "public",
+ "org19": "public",
+ "org25": "public",
+ "org26": "public",
+ "org41": "public",
+ "org6": "public",
+ "org7": "public",
+ }
+
+ assert.Equal(t, publicOrgsExpected, publicOrgsCaptured)
+
+ orgReposReq := NewRequest(t, "GET", "/api/v1/orgs/org17/repos").
+ AddTokenAuth(noPublicOnlyAccessToken.Token)
+ orgReposResp := MakeRequest(t, orgReposReq, http.StatusOK)
+ var allOrgRepos []api.Repository
+ DecodeJSON(t, orgReposResp, &allOrgRepos)
+ allOrgReposCaptured := make(map[string]bool)
+ for _, repo := range allOrgRepos {
+ allOrgReposCaptured[repo.Name] = repo.Private
+ }
+ allOrgReposExpected := map[string]bool{
+ "big_test_private_4": true,
+ "big_test_public_4": false,
+ }
+ assert.Equal(t, allOrgReposExpected, allOrgReposCaptured)
+
+ publicOrgReposReq := NewRequest(t, "GET", "/api/v1/orgs/org17/repos").
+ AddTokenAuth(publicOnlyAccessToken.Token)
+ publicOrgReposResp := MakeRequest(t, publicOrgReposReq, http.StatusOK)
+ var publicOrgRepos []api.Repository
+ DecodeJSON(t, publicOrgReposResp, &publicOrgRepos)
+ publicOrgReposCaptured := make(map[string]bool)
+ for _, repo := range publicOrgRepos {
+ publicOrgReposCaptured[repo.Name] = repo.Private
+ }
+ publicOrgReposExpected := map[string]bool{
+ "big_test_public_4": false,
+ }
+ assert.Equal(t, publicOrgReposExpected, publicOrgReposCaptured)
+}
diff --git a/tests/integration/integration_test.go b/tests/integration/integration_test.go
index ae8ff51d43613..01d7976eeb1c7 100644
--- a/tests/integration/integration_test.go
+++ b/tests/integration/integration_test.go
@@ -17,6 +17,7 @@ import (
"net/url"
"os"
"path/filepath"
+ "strconv"
"strings"
"sync/atomic"
"testing"
@@ -37,6 +38,7 @@ import (
"github.com/PuerkitoBio/goquery"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
"github.com/xeipuuv/gojsonschema"
)
@@ -501,3 +503,30 @@ func GetCSRFFromCookie(t testing.TB, session *TestSession, urlStr string) string
session.MakeRequest(t, req, http.StatusOK)
return session.GetCookie("_csrf").Value
}
+
+func loginUserWithPasswordRemember(t testing.TB, userName, password string, rememberMe bool) *TestSession {
+ t.Helper()
+ req := NewRequest(t, "GET", "/user/login")
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ doc := NewHTMLParser(t, resp.Body)
+ req = NewRequestWithValues(t, "POST", "/user/login", map[string]string{
+ "_csrf": doc.GetCSRF(),
+ "user_name": userName,
+ "password": password,
+ "remember": strconv.FormatBool(rememberMe),
+ })
+ resp = MakeRequest(t, req, http.StatusSeeOther)
+
+ ch := http.Header{}
+ ch.Add("Cookie", strings.Join(resp.Header()["Set-Cookie"], ";"))
+ cr := http.Request{Header: ch}
+
+ session := emptyTestSession(t)
+
+ baseURL, err := url.Parse(setting.AppURL)
+ require.NoError(t, err)
+ session.jar.SetCookies(baseURL, cr.Cookies())
+
+ return session
+}
diff --git a/tests/integration/oauth_test.go b/tests/integration/oauth_test.go
index b32d365b04d15..aed45eb090266 100644
--- a/tests/integration/oauth_test.go
+++ b/tests/integration/oauth_test.go
@@ -5,16 +5,25 @@ package integration
import (
"bytes"
+ "encoding/base64"
+ "fmt"
"io"
"net/http"
+ "strings"
"testing"
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
oauth2_provider "code.gitea.io/gitea/services/oauth2_provider"
"code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
func TestAuthorizeNoClientID(t *testing.T) {
@@ -477,3 +486,491 @@ func TestOAuthIntrospection(t *testing.T) {
resp = MakeRequest(t, req, http.StatusUnauthorized)
assert.Contains(t, resp.Body.String(), "no valid authorization")
}
+
+func TestOAuth_GrantScopesReadUserFailRepos(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ setting.OAuth2.EnableAdditionalGrantScopes = true
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ appBody := api.CreateOAuth2ApplicationOptions{
+ Name: "oauth-provider-scopes-test",
+ RedirectURIs: []string{
+ "a",
+ },
+ ConfidentialClient: true,
+ }
+
+ req := NewRequestWithJSON(t, "POST", "/api/v1/user/applications/oauth2", &appBody).
+ AddBasicAuth(user.Name)
+ resp := MakeRequest(t, req, http.StatusCreated)
+
+ var app *api.OAuth2Application
+ DecodeJSON(t, resp, &app)
+
+ grant := &auth_model.OAuth2Grant{
+ ApplicationID: app.ID,
+ UserID: user.ID,
+ Scope: "openid profile email read:user",
+ }
+
+ err := db.Insert(db.DefaultContext, grant)
+ require.NoError(t, err)
+
+ assert.Contains(t, grant.Scope, "openid profile email read:user")
+
+ ctx := loginUserWithPasswordRemember(t, user.Name, "password", true)
+
+ authorizeURL := fmt.Sprintf("/login/oauth/authorize?client_id=%s&redirect_uri=a&response_type=code&state=thestate", app.ClientID)
+ authorizeReq := NewRequest(t, "GET", authorizeURL)
+ authorizeResp := ctx.MakeRequest(t, authorizeReq, http.StatusSeeOther)
+
+ authcode := strings.Split(strings.Split(authorizeResp.Body.String(), "?code=")[1], "&")[0]
+
+ accessTokenReq := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
+ "grant_type": "authorization_code",
+ "client_id": app.ClientID,
+ "client_secret": app.ClientSecret,
+ "redirect_uri": "a",
+ "code": authcode,
+ })
+ accessTokenResp := ctx.MakeRequest(t, accessTokenReq, 200)
+ type response struct {
+ AccessToken string `json:"access_token"`
+ TokenType string `json:"token_type"`
+ ExpiresIn int64 `json:"expires_in"`
+ RefreshToken string `json:"refresh_token"`
+ }
+ parsed := new(response)
+
+ require.NoError(t, json.Unmarshal(accessTokenResp.Body.Bytes(), parsed))
+ userReq := NewRequest(t, "GET", "/api/v1/user")
+ userReq.SetHeader("Authorization", "Bearer "+parsed.AccessToken)
+ userResp := MakeRequest(t, userReq, http.StatusOK)
+
+ type userResponse struct {
+ Login string `json:"login"`
+ Email string `json:"email"`
+ }
+
+ userParsed := new(userResponse)
+ require.NoError(t, json.Unmarshal(userResp.Body.Bytes(), userParsed))
+ assert.Contains(t, userParsed.Email, "user2@example.com")
+
+ errorReq := NewRequest(t, "GET", "/api/v1/users/user2/repos")
+ errorReq.SetHeader("Authorization", "Bearer "+parsed.AccessToken)
+ errorResp := MakeRequest(t, errorReq, http.StatusForbidden)
+
+ type errorResponse struct {
+ Message string `json:"message"`
+ }
+
+ errorParsed := new(errorResponse)
+ require.NoError(t, json.Unmarshal(errorResp.Body.Bytes(), errorParsed))
+ assert.Contains(t, errorParsed.Message, "token does not have at least one of required scope(s): [read:repository]")
+
+}
+
+func TestOAuth_GrantScopesReadRepositoryFailOrganization(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ setting.OAuth2.EnableAdditionalGrantScopes = true
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ appBody := api.CreateOAuth2ApplicationOptions{
+ Name: "oauth-provider-scopes-test",
+ RedirectURIs: []string{
+ "a",
+ },
+ ConfidentialClient: true,
+ }
+
+ req := NewRequestWithJSON(t, "POST", "/api/v1/user/applications/oauth2", &appBody).
+ AddBasicAuth(user.Name)
+ resp := MakeRequest(t, req, http.StatusCreated)
+
+ var app *api.OAuth2Application
+ DecodeJSON(t, resp, &app)
+
+ grant := &auth_model.OAuth2Grant{
+ ApplicationID: app.ID,
+ UserID: user.ID,
+ Scope: "openid profile email read:user read:repository",
+ }
+
+ err := db.Insert(db.DefaultContext, grant)
+ require.NoError(t, err)
+
+ assert.Contains(t, grant.Scope, "openid profile email read:user read:repository")
+
+ ctx := loginUserWithPasswordRemember(t, user.Name, "password", true)
+
+ authorizeURL := fmt.Sprintf("/login/oauth/authorize?client_id=%s&redirect_uri=a&response_type=code&state=thestate", app.ClientID)
+ authorizeReq := NewRequest(t, "GET", authorizeURL)
+ authorizeResp := ctx.MakeRequest(t, authorizeReq, http.StatusSeeOther)
+
+ authcode := strings.Split(strings.Split(authorizeResp.Body.String(), "?code=")[1], "&")[0]
+ accessTokenReq := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
+ "grant_type": "authorization_code",
+ "client_id": app.ClientID,
+ "client_secret": app.ClientSecret,
+ "redirect_uri": "a",
+ "code": authcode,
+ })
+ accessTokenResp := ctx.MakeRequest(t, accessTokenReq, http.StatusOK)
+ type response struct {
+ AccessToken string `json:"access_token"`
+ TokenType string `json:"token_type"`
+ ExpiresIn int64 `json:"expires_in"`
+ RefreshToken string `json:"refresh_token"`
+ }
+ parsed := new(response)
+
+ require.NoError(t, json.Unmarshal(accessTokenResp.Body.Bytes(), parsed))
+ userReq := NewRequest(t, "GET", "/api/v1/users/user2/repos")
+ userReq.SetHeader("Authorization", "Bearer "+parsed.AccessToken)
+ userResp := MakeRequest(t, userReq, http.StatusOK)
+
+ type repo struct {
+ FullRepoName string `json:"full_name"`
+ Private bool `json:"private"`
+ }
+
+ var reposCaptured []repo
+ require.NoError(t, json.Unmarshal(userResp.Body.Bytes(), &reposCaptured))
+
+ reposExpected := []repo{
+ {
+ FullRepoName: "user2/repo1",
+ Private: false,
+ },
+ {
+ FullRepoName: "user2/repo2",
+ Private: true,
+ },
+ {
+ FullRepoName: "user2/repo15",
+ Private: true,
+ },
+ {
+ FullRepoName: "user2/repo16",
+ Private: true,
+ },
+ {
+ FullRepoName: "user2/repo20",
+ Private: true,
+ },
+ {
+ FullRepoName: "user2/utf8",
+ Private: false,
+ },
+ {
+ FullRepoName: "user2/commits_search_test",
+ Private: false,
+ },
+ {
+ FullRepoName: "user2/git_hooks_test",
+ Private: false,
+ },
+ {
+ FullRepoName: "user2/glob",
+ Private: false,
+ },
+ {
+ FullRepoName: "user2/lfs",
+ Private: true,
+ },
+ {
+ FullRepoName: "user2/scoped_label",
+ Private: true,
+ },
+ {
+ FullRepoName: "user2/readme-test",
+ Private: true,
+ },
+ {
+ FullRepoName: "user2/repo-release",
+ Private: false,
+ },
+ {
+ FullRepoName: "user2/commitsonpr",
+ Private: false,
+ },
+ {
+ FullRepoName: "user2/test_commit_revert",
+ Private: true,
+ },
+ }
+ assert.Equal(t, reposExpected, reposCaptured)
+
+ errorReq := NewRequest(t, "GET", "/api/v1/users/user2/orgs")
+ errorReq.SetHeader("Authorization", "Bearer "+parsed.AccessToken)
+ errorResp := MakeRequest(t, errorReq, http.StatusForbidden)
+
+ type errorResponse struct {
+ Message string `json:"message"`
+ }
+
+ errorParsed := new(errorResponse)
+ require.NoError(t, json.Unmarshal(errorResp.Body.Bytes(), errorParsed))
+ assert.Contains(t, errorParsed.Message, "token does not have at least one of required scope(s): [read:user read:organization]")
+}
+
+func TestOAuth_GrantScopesReadRepositoriesPublicOnly(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ setting.OAuth2.EnableAdditionalGrantScopes = true
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2"})
+
+ appBody := api.CreateOAuth2ApplicationOptions{
+ Name: "oauth-provider-scopes-test",
+ RedirectURIs: []string{
+ "a",
+ },
+ ConfidentialClient: true,
+ }
+
+ appReq := NewRequestWithJSON(t, "POST", "/api/v1/user/applications/oauth2", &appBody).
+ AddBasicAuth(user.Name)
+ appResp := MakeRequest(t, appReq, http.StatusCreated)
+
+ var app *api.OAuth2Application
+ DecodeJSON(t, appResp, &app)
+
+ grant := &auth_model.OAuth2Grant{
+ ApplicationID: app.ID,
+ UserID: user.ID,
+ Scope: "openid profile email groups public-only read:user read:repository",
+ }
+
+ err := db.Insert(db.DefaultContext, grant)
+ require.NoError(t, err)
+
+ assert.Contains(t, grant.Scope, "openid profile email groups public-only read:user read:repository")
+
+ ctx := loginUserWithPasswordRemember(t, user.Name, "password", true)
+
+ authorizeURL := fmt.Sprintf("/login/oauth/authorize?client_id=%s&redirect_uri=a&response_type=code&state=thestate", app.ClientID)
+ authorizeReq := NewRequest(t, "GET", authorizeURL)
+ authorizeResp := ctx.MakeRequest(t, authorizeReq, http.StatusSeeOther)
+
+ authcode := strings.Split(strings.Split(authorizeResp.Body.String(), "?code=")[1], "&")[0]
+
+ accessTokenReq := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
+ "grant_type": "authorization_code",
+ "client_id": app.ClientID,
+ "client_secret": app.ClientSecret,
+ "redirect_uri": "a",
+ "code": authcode,
+ })
+ accessTokenResp := ctx.MakeRequest(t, accessTokenReq, http.StatusOK)
+ type response struct {
+ AccessToken string `json:"access_token"`
+ TokenType string `json:"token_type"`
+ ExpiresIn int64 `json:"expires_in"`
+ RefreshToken string `json:"refresh_token"`
+ }
+ parsed := new(response)
+
+ require.NoError(t, json.Unmarshal(accessTokenResp.Body.Bytes(), parsed))
+ userReq := NewRequest(t, "GET", "/api/v1/users/user2/repos")
+ userReq.SetHeader("Authorization", "Bearer "+parsed.AccessToken)
+ userResp := MakeRequest(t, userReq, http.StatusOK)
+
+ type repo struct {
+ FullRepoName string `json:"full_name"`
+ Private bool `json:"private"`
+ }
+
+ var reposCaptured []repo
+ require.NoError(t, json.Unmarshal(userResp.Body.Bytes(), &reposCaptured))
+
+ reposExpected := []repo{
+ {
+ FullRepoName: "user2/repo1",
+ Private: false,
+ },
+ {
+ FullRepoName: "user2/utf8",
+ Private: false,
+ },
+ {
+ FullRepoName: "user2/commits_search_test",
+ Private: false,
+ },
+ {
+ FullRepoName: "user2/git_hooks_test",
+ Private: false,
+ },
+ {
+ FullRepoName: "user2/glob",
+ Private: false,
+ },
+ {
+ FullRepoName: "user2/repo-release",
+ Private: false,
+ },
+ {
+ FullRepoName: "user2/commitsonpr",
+ Private: false,
+ },
+ }
+ assert.Equal(t, reposExpected, reposCaptured)
+
+}
+
+func TestOAuth_GrantScopesNotEnabledClaimGroups(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ setting.OAuth2.EnableAdditionalGrantScopes = false
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2"})
+
+ appBody := api.CreateOAuth2ApplicationOptions{
+ Name: "oauth-provider-scopes-test",
+ RedirectURIs: []string{
+ "a",
+ },
+ ConfidentialClient: true,
+ }
+
+ appReq := NewRequestWithJSON(t, "POST", "/api/v1/user/applications/oauth2", &appBody).
+ AddBasicAuth(user.Name)
+ appResp := MakeRequest(t, appReq, http.StatusCreated)
+
+ var app *api.OAuth2Application
+ DecodeJSON(t, appResp, &app)
+
+ grant := &auth_model.OAuth2Grant{
+ ApplicationID: app.ID,
+ UserID: user.ID,
+ Scope: "openid profile email groups",
+ }
+
+ err := db.Insert(db.DefaultContext, grant)
+ require.NoError(t, err)
+
+ assert.Contains(t, grant.Scope, "openid profile email groups")
+
+ ctx := loginUserWithPasswordRemember(t, user.Name, "password", true)
+
+ authorizeURL := fmt.Sprintf("/login/oauth/authorize?client_id=%s&redirect_uri=a&response_type=code&state=thestate", app.ClientID)
+ authorizeReq := NewRequest(t, "GET", authorizeURL)
+ authorizeResp := ctx.MakeRequest(t, authorizeReq, http.StatusSeeOther)
+
+ authcode := strings.Split(strings.Split(authorizeResp.Body.String(), "?code=")[1], "&")[0]
+
+ accessTokenReq := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
+ "grant_type": "authorization_code",
+ "client_id": app.ClientID,
+ "client_secret": app.ClientSecret,
+ "redirect_uri": "a",
+ "code": authcode,
+ })
+ accessTokenResp := ctx.MakeRequest(t, accessTokenReq, http.StatusOK)
+ type response struct {
+ AccessToken string `json:"access_token"`
+ TokenType string `json:"token_type"`
+ ExpiresIn int64 `json:"expires_in"`
+ RefreshToken string `json:"refresh_token"`
+ IDToken string `json:"id_token,omitempty"`
+ }
+ parsed := new(response)
+ require.NoError(t, json.Unmarshal(accessTokenResp.Body.Bytes(), parsed))
+ parts := strings.Split(parsed.IDToken, ".")
+
+ payload, _ := base64.RawURLEncoding.DecodeString(parts[1])
+ type IDTokenClaims struct {
+ Groups []string `json:"groups"`
+ }
+
+ claims := new(IDTokenClaims)
+ require.NoError(t, json.Unmarshal(payload, claims))
+ for _, group := range []string{
+ "org17",
+ "org17:test_team",
+ "org3",
+ "org3:owners",
+ "org3:team1",
+ "org3:teamcreaterepo",
+ "private_org35",
+ "private_org35:team24",
+ } {
+ assert.Contains(t, claims.Groups, group)
+ }
+}
+
+func TestOAuth_GrantScopesEnabledClaimGroups(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ setting.OAuth2.EnableAdditionalGrantScopes = true
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2"})
+
+ appBody := api.CreateOAuth2ApplicationOptions{
+ Name: "oauth-provider-scopes-test",
+ RedirectURIs: []string{
+ "a",
+ },
+ ConfidentialClient: true,
+ }
+
+ appReq := NewRequestWithJSON(t, "POST", "/api/v1/user/applications/oauth2", &appBody).
+ AddBasicAuth(user.Name)
+ appResp := MakeRequest(t, appReq, http.StatusCreated)
+
+ var app *api.OAuth2Application
+ DecodeJSON(t, appResp, &app)
+
+ grant := &auth_model.OAuth2Grant{
+ ApplicationID: app.ID,
+ UserID: user.ID,
+ Scope: "openid profile email groups",
+ }
+
+ err := db.Insert(db.DefaultContext, grant)
+ require.NoError(t, err)
+
+ assert.Contains(t, grant.Scope, "openid profile email groups")
+
+ ctx := loginUserWithPasswordRemember(t, user.Name, "password", true)
+
+ authorizeURL := fmt.Sprintf("/login/oauth/authorize?client_id=%s&redirect_uri=a&response_type=code&state=thestate", app.ClientID)
+ authorizeReq := NewRequest(t, "GET", authorizeURL)
+ authorizeResp := ctx.MakeRequest(t, authorizeReq, http.StatusSeeOther)
+
+ authcode := strings.Split(strings.Split(authorizeResp.Body.String(), "?code=")[1], "&")[0]
+
+ accessTokenReq := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
+ "grant_type": "authorization_code",
+ "client_id": app.ClientID,
+ "client_secret": app.ClientSecret,
+ "redirect_uri": "a",
+ "code": authcode,
+ })
+ accessTokenResp := ctx.MakeRequest(t, accessTokenReq, http.StatusOK)
+ type response struct {
+ AccessToken string `json:"access_token"`
+ TokenType string `json:"token_type"`
+ ExpiresIn int64 `json:"expires_in"`
+ RefreshToken string `json:"refresh_token"`
+ IDToken string `json:"id_token,omitempty"`
+ }
+ parsed := new(response)
+ require.NoError(t, json.Unmarshal(accessTokenResp.Body.Bytes(), parsed))
+ parts := strings.Split(parsed.IDToken, ".")
+
+ payload, _ := base64.RawURLEncoding.DecodeString(parts[1])
+ type IDTokenClaims struct {
+ Groups []string `json:"groups"`
+ }
+
+ claims := new(IDTokenClaims)
+ require.NoError(t, json.Unmarshal(payload, claims))
+ for _, group := range []string{
+ "org17",
+ "org17:test_team",
+ "org3",
+ "org3:owners",
+ "org3:team1",
+ "org3:teamcreaterepo",
+ } {
+ assert.Contains(t, claims.Groups, group)
+ }
+}