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) + } +}