diff --git a/modules/setting/oauth2.go b/modules/setting/oauth2.go index 0d3e63e0b4a..f6b802f2239 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 a02d939b79e..84cbaa83117 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -453,6 +453,7 @@ authorize_application = Authorize Application authorize_redirect_notice = You will be redirected to %s if you authorize this application. authorize_application_created_by = This application was created by %s. authorize_application_description = If you grant the access, it will be able to access and write to all your account information, including private repos and organisations. +authorize_application_with_scopes = With scopes: %s authorize_title = Authorize "%s" to access your account? authorization_failed = Authorization failed authorization_failed_desc = The authorization failed because we detected an invalid request. Please contact the maintainer of the app you have tried to authorize. diff --git a/routers/web/auth/oauth2_provider.go b/routers/web/auth/oauth2_provider.go index 29827b062de..71a9165ba3f 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,8 @@ func InfoOAuth(ctx *context.Context) { Picture: ctx.Doer.AvatarLink(ctx), } - groups, err := oauth2_provider.GetOAuthGroupsForUser(ctx, ctx.Doer) + onlyPublicGroups := ctx.IsSigned && (ctx.FormString("private") == "" || ctx.FormBool("private")) + groups, err := oauth2_provider.GetOAuthGroupsForUser(ctx, ctx.Doer, onlyPublicGroups) if err != nil { ctx.ServerError("Oauth groups for user", err) return @@ -304,6 +305,13 @@ func AuthorizeOAuth(ctx *context.Context) { return } + // check if additional scopes + if oauth2_provider.GrantAdditionalScopes(form.Scope) == auth.AccessTokenScopeAll { + 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 356c2ea5de3..6c2c1829113 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/basic.go b/services/auth/basic.go index 90bd6423709..916e558281d 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 523998a6345..ce021d15e47 100644 --- a/services/auth/oauth2.go +++ b/services/auth/oauth2.go @@ -17,7 +17,7 @@ import ( "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/web/middleware" - "code.gitea.io/gitea/services/oauth2_provider" + oauth2_provider "code.gitea.io/gitea/services/oauth2_provider" ) // Ensure the struct implements the interface. @@ -26,27 +26,30 @@ var ( ) // 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, auth_model.AccessTokenScope) { + var accessTokenScope auth_model.AccessTokenScope // JWT tokens require a "." if !strings.Contains(accessToken, ".") { - return 0 + return 0, accessTokenScope } token, err := oauth2_provider.ParseToken(accessToken, oauth2_provider.DefaultSigningKey) if err != nil { log.Trace("oauth2.ParseToken: %v", err) - return 0 + return 0, accessTokenScope } var grant *auth_model.OAuth2Grant if grant, err = auth_model.GetOAuth2GrantByID(ctx, token.GrantID); err != nil || grant == nil { - return 0 + return 0, accessTokenScope } if token.Kind != oauth2_provider.KindAccessToken { - return 0 + return 0, accessTokenScope } if token.ExpiresAt.Before(time.Now()) || token.IssuedAt.After(time.Now()) { - return 0 + return 0, accessTokenScope } - return grant.UserID + accessTokenScope = oauth2_provider.GrantAdditionalScopes(grant.Scope) + return grant.UserID, accessTokenScope } // OAuth2 implements the Auth interface and authenticates requests @@ -92,10 +95,11 @@ 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, accessTokenScope := CheckOAuthAccessToken(ctx, tokenSHA) + if uid != 0 { store.GetData()["IsApiToken"] = true - store.GetData()["ApiTokenScope"] = auth_model.AccessTokenScopeAll // fallback to all + store.GetData()["ApiTokenScope"] = accessTokenScope } return uid } diff --git a/services/oauth2_provider/access_token.go b/services/oauth2_provider/access_token.go index 00c960caf2e..6af08fe1e74 100644 --- a/services/oauth2_provider/access_token.go +++ b/services/oauth2_provider/access_token.go @@ -6,6 +6,8 @@ package oauth2_provider //nolint import ( "context" "fmt" + "slices" + "strings" auth "code.gitea.io/gitea/models/auth" org_model "code.gitea.io/gitea/models/organization" @@ -68,6 +70,30 @@ type AccessTokenResponse struct { IDToken string `json:"id_token,omitempty"` } +// GrantAdditionalScopes returns valid scopes coming from grant +func GrantAdditionalScopes(grantScopes string) auth.AccessTokenScope { + // scopes_supported from templates/user/auth/oidc_wellknown.tmpl + scopesSupported := []string{ + "openid", + "profile", + "email", + "groups", + } + + var tokenScopes []string + for _, tokenScope := range strings.Split(grantScopes, " ") { + if slices.Index(scopesSupported, tokenScope) == -1 { + tokenScopes = append(tokenScopes, tokenScope) + } + } + + accessTokenScope := auth.AccessTokenScope(strings.Join(tokenScopes, ",")) + if accessTokenWithAdditionalScopes, err := accessTokenScope.Normalize(); err == nil && len(tokenScopes) > 0 { + return accessTokenWithAdditionalScopes + } + return auth.AccessTokenScopeAll +} + 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 +186,10 @@ func NewAccessTokenResponse(ctx context.Context, grant *auth.OAuth2Grant, server idToken.EmailVerified = user.IsActive } if grant.ScopeContains("groups") { - groups, err := GetOAuthGroupsForUser(ctx, user) + accessTokenScope := GrantAdditionalScopes(grant.Scope) + onlyPublicGroups, _ := accessTokenScope.PublicOnly() + + groups, err := GetOAuthGroupsForUser(ctx, user, onlyPublicGroups) if err != nil { log.Error("Error getting groups: %v", err) return nil, &AccessTokenError{ @@ -191,7 +220,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 +228,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/services/oauth2_provider/additional_scopes_test.go b/services/oauth2_provider/additional_scopes_test.go new file mode 100644 index 00000000000..cca5a3a4b78 --- /dev/null +++ b/services/oauth2_provider/additional_scopes_test.go @@ -0,0 +1,36 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package oauth2_provider //nolint + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGrantAdditionalScopes(t *testing.T) { + setting.OAuth2.EnableAdditionalGrantScopes = true + tests := []struct { + grantScopes string + expectedScopes string + }{ + {"openid profile email", "all"}, + {"openid profile email groups", "all"}, + {"openid profile email all", "all"}, + {"openid profile email read:user all", "all"}, + {"openid profile email groups read:user", "read:user"}, + {"read:user read:repository", "read:repository,read:user"}, + {"read:user write:issue public-only", "public-only,write:issue,read:user"}, + {"openid profile email read:user", "read:user"}, + {"read:invalid_scope", "all"}, + {"read:invalid_scope,write:scope_invalid,just-plain-wrong", "all"}, + } + + for _, test := range tests { + t.Run(test.grantScopes, func(t *testing.T) { + result := GrantAdditionalScopes(test.grantScopes) + assert.Equal(t, test.expectedScopes, string(result)) + }) + } +} diff --git a/templates/user/auth/grant.tmpl b/templates/user/auth/grant.tmpl index a18a3bd27a2..c03017c67e9 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}} +

{{ctx.Locale.Tr "auth.authorize_application_with_scopes" .Scope}}

diff --git a/tests/integration/integration_test.go b/tests/integration/integration_test.go index f72ac5f51c3..20972556147 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" @@ -502,3 +503,30 @@ func GetAnonymousCSRFToken(t testing.TB, session *TestSession) string { require.NotEmpty(t, csrfToken) return csrfToken } + +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 b32d365b04d..b8fba7e12fb 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,383 @@ 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)() + + 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)() + + 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_GrantScopesClaimGroupsAll(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + 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 read:organization", + } + + err := db.Insert(db.DefaultContext, grant) + require.NoError(t, err) + + assert.ElementsMatch(t, []string{"openid", "profile", "email", "groups", "read:organization"}, strings.Split(grant.Scope, " ")) + + 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)() + + 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) + } +}