From 4eb8d8c4960b7d26679be31824f91218eba1ed55 Mon Sep 17 00:00:00 2001 From: Marcell Mars Date: Thu, 11 Jul 2024 11:12:51 +0200 Subject: [PATCH 1/4] OAuth2 provider: support for granular scopes - `CheckOAuthAccessToken` returns both user ID and additional scopes - `grantAdditionalScopes` returns AccessTokenScope ready string (grantScopes) compiled from requested additional scopes by the client - `userIDFromToken` sets returned grantScopes (if any) instead of default `all` --- modules/setting/oauth2.go | 34 +++++++------ routers/web/user/setting/applications.go | 1 + services/auth/basic.go | 2 +- services/auth/oauth2.go | 65 ++++++++++++++++++++---- 4 files changed, 76 insertions(+), 26 deletions(-) diff --git a/modules/setting/oauth2.go b/modules/setting/oauth2.go index 86617f7513..49288e2639 100644 --- a/modules/setting/oauth2.go +++ b/modules/setting/oauth2.go @@ -92,23 +92,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: true, - 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: true, + 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/routers/web/user/setting/applications.go b/routers/web/user/setting/applications.go index e3822ca988..24ebf9b922 100644 --- a/routers/web/user/setting/applications.go +++ b/routers/web/user/setting/applications.go @@ -110,5 +110,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 c8cb1735ee..382c8bc90c 100644 --- a/services/auth/basic.go +++ b/services/auth/basic.go @@ -72,7 +72,7 @@ func (b *Basic) Verify(req *http.Request, w http.ResponseWriter, store DataStore } // check oauth2 token - uid := CheckOAuthAccessToken(req.Context(), authToken) + uid, _ := CheckOAuthAccessToken(req.Context(), authToken) if uid != 0 { log.Trace("Basic Authorization: Valid OAuthAccessToken for user[%d]", uid) diff --git a/services/auth/oauth2.go b/services/auth/oauth2.go index 46d8510143..6a63c62796 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,69 @@ var ( _ Method = &OAuth2{} ) +// grantAdditionalScopes returns valid scopes coming from grant +func grantAdditionalScopes(grantScopes string) string { + // 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.ParseToken(accessToken, oauth2.DefaultSigningKey) if err != nil { log.Trace("oauth2.ParseToken: %v", err) - return 0 + return 0, "" } var grant *auth_model.OAuth2Grant if grant, err = auth_model.GetOAuth2GrantByID(ctx, token.GrantID); err != nil || grant == nil { - return 0 + return 0, "" } if token.Type != oauth2.TypeAccessToken { - return 0 + return 0, "" } if token.ExpiresAt.Before(time.Now()) || token.IssuedAt.After(time.Now()) { - return 0 + return 0, "" } - return grant.UserID + grantScopes := grantAdditionalScopes(grant.Scope) + return grant.UserID, grantScopes } // OAuth2 implements the Auth interface and authenticates requests @@ -92,10 +134,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 } From 7dbad2715662d26c56cc062d48632bdb4fd03038 Mon Sep 17 00:00:00 2001 From: Marcell Mars Date: Wed, 7 Aug 2024 09:21:28 +0200 Subject: [PATCH 2/4] id_token & userinfo endpoint's public groups check - if `groups` scope provided it checks if all, r:org or r:admin are provided to pass all the groups. otherwise only public memberships - in InfoOAuth it captures scopes from the token if provided in the header. the extraction from the header is maybe a candidate for the separate function so no duplicated code --- routers/web/auth/oauth.go | 41 +++++++++++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/routers/web/auth/oauth.go b/routers/web/auth/oauth.go index c9cdb08d9f..0626157dd8 100644 --- a/routers/web/auth/oauth.go +++ b/routers/web/auth/oauth.go @@ -244,7 +244,9 @@ func newAccessTokenResponse(ctx go_context.Context, grant *auth.OAuth2Grant, ser 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{ @@ -279,7 +281,18 @@ 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"` +} + +func ifOnlyPublicGroups(scopes string) bool { + scopes = strings.ReplaceAll(scopes, ",", " ") + scopesList := strings.Fields(scopes) + for _, scope := range scopesList { + if scope == "all" || scope == "read:organization" || scope == "read:admin" { + return false + } + } + return true } // InfoOAuth manages request for userinfo endpoint @@ -298,7 +311,18 @@ func InfoOAuth(ctx *context.Context) { Picture: ctx.Doer.AvatarLink(ctx), } - groups, err := 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 := ifOnlyPublicGroups(grantScopes) + + groups, err := getOAuthGroupsForUser(ctx, ctx.Doer, onlyPublicGroups) if err != nil { ctx.ServerError("Oauth groups for user", err) return @@ -310,7 +334,7 @@ func InfoOAuth(ctx *context.Context) { // returns a list of "org" and "org:team" strings, // that the given user is a part of. -func getOAuthGroupsForUser(ctx go_context.Context, user *user_model.User) ([]string, error) { +func getOAuthGroupsForUser(ctx go_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) @@ -318,6 +342,15 @@ func getOAuthGroupsForUser(ctx go_context.Context, user *user_model.User) ([]str var groups []string for _, org := range orgs { + if setting.OAuth2.EnableAdditionalGrantScopes { + if onlyPublicGroups { + public, err := org_model.IsPublicMembership(ctx, org.ID, user.ID) + if !public && err == nil { + continue + } + } + } + groups = append(groups, org.Name) teams, err := org.LoadTeams(ctx) if err != nil { From 8524589d8c84b3e9b4130474fd0cfcdac155b4c9 Mon Sep 17 00:00:00 2001 From: Marcell Mars Date: Wed, 7 Aug 2024 09:22:48 +0200 Subject: [PATCH 3/4] show OAuth2 requested scopes in authorization UI - by displaying the scopes requested for authorization in the OAuth2 app, users can make more informed decisions when granting access --- templates/user/auth/grant.tmpl | 1 + 1 file changed, 1 insertion(+) diff --git a/templates/user/auth/grant.tmpl b/templates/user/auth/grant.tmpl index a18a3bd27a..1a1b72b83b 100644 --- a/templates/user/auth/grant.tmpl +++ b/templates/user/auth/grant.tmpl @@ -11,6 +11,7 @@ {{ctx.Locale.Tr "auth.authorize_application_description"}}
{{ctx.Locale.Tr "auth.authorize_application_created_by" .ApplicationCreatorLinkHTML}}

+

With scopes: {{.Scope}}.

{{ctx.Locale.Tr "auth.authorize_redirect_notice" .ApplicationRedirectDomainHTML}}

From d6647f710f78ef5b05176422c92fffda8746b477 Mon Sep 17 00:00:00 2001 From: Marcell Mars Date: Fri, 9 Aug 2024 11:14:40 +0200 Subject: [PATCH 4/4] tests additional grant scopes - parsing scopes in `grantAdditionalScopes` - read basic user info if `read:user` - fail reading repository info if only `read:user` - read repository info if `read:repository` - if `setting.OAuth2.EnabledAdditionalGrantScopes` not provided it reads all groups (public+private) - if `setting.OAuth2.EnabledAdditionalGrantScopes` provided it reads only public groups - if `setting.OAuth2.EnabledAdditionalGrantScopes` and `read:organization` provided it reads all groups --- services/auth/additional_scopes_test.go | 32 ++ tests/integration/oauth_test.go | 516 ++++++++++++++++++++++++ 2 files changed, 548 insertions(+) create mode 100644 services/auth/additional_scopes_test.go diff --git a/services/auth/additional_scopes_test.go b/services/auth/additional_scopes_test.go new file mode 100644 index 0000000000..9ab4e6e61f --- /dev/null +++ b/services/auth/additional_scopes_test.go @@ -0,0 +1,32 @@ +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/tests/integration/oauth_test.go b/tests/integration/oauth_test.go index 785e6dd266..0d5e9a0472 100644 --- a/tests/integration/oauth_test.go +++ b/tests/integration/oauth_test.go @@ -12,13 +12,16 @@ import ( "io" "net/http" "net/url" + "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" "code.gitea.io/gitea/modules/test" "code.gitea.io/gitea/routers/web/auth" "code.gitea.io/gitea/tests" @@ -799,3 +802,516 @@ func TestOAuthIntrospection(t *testing.T) { assert.Contains(t, resp.Body.String(), "no valid authorization") }) } + +func TestOAuth_GrantScopesReadUser(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] + htmlDoc := NewHTMLParser(t, authorizeResp.Body) + grantReq := NewRequestWithValues(t, "POST", "/login/oauth/grant", map[string]string{ + "_csrf": htmlDoc.GetCSRF(), + "client_id": app.ClientID, + "redirect_uri": "a", + "state": "thestate", + "granted": "true", + }) + grantResp := ctx.MakeRequest(t, grantReq, http.StatusSeeOther) + htmlDocGrant := NewHTMLParser(t, grantResp.Body) + + accessTokenReq := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{ + "_csrf": htmlDocGrant.GetCSRF(), + "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) + + // assert.Contains(t, string(userResp.Body.Bytes()), "blah") + 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") +} + +func TestOAuth_GrantScopesFailReadRepository(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] + htmlDoc := NewHTMLParser(t, authorizeResp.Body) + grantReq := NewRequestWithValues(t, "POST", "/login/oauth/grant", map[string]string{ + "_csrf": htmlDoc.GetCSRF(), + "client_id": app.ClientID, + "redirect_uri": "a", + "state": "thestate", + "granted": "true", + }) + grantResp := ctx.MakeRequest(t, grantReq, http.StatusSeeOther) + htmlDocGrant := NewHTMLParser(t, grantResp.Body) + + accessTokenReq := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{ + "_csrf": htmlDocGrant.GetCSRF(), + "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.StatusForbidden) + + type userResponse struct { + Message string `json:"message"` + } + + userParsed := new(userResponse) + require.NoError(t, json.Unmarshal(userResp.Body.Bytes(), userParsed)) + assert.Contains(t, userParsed.Message, "token does not have at least one of required scope(s): [read:repository]") +} + +func TestOAuth_GrantScopesReadRepository(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] + htmlDoc := NewHTMLParser(t, authorizeResp.Body) + grantReq := NewRequestWithValues(t, "POST", "/login/oauth/grant", map[string]string{ + "_csrf": htmlDoc.GetCSRF(), + "client_id": app.ClientID, + "redirect_uri": "a", + "state": "thestate", + "granted": "true", + }) + grantResp := ctx.MakeRequest(t, grantReq, http.StatusSeeOther) + htmlDocGrant := NewHTMLParser(t, grantResp.Body) + + accessTokenReq := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{ + "_csrf": htmlDocGrant.GetCSRF(), + "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 repos struct { + FullRepoName string `json:"full_name"` + } + var userResponse []*repos + require.NoError(t, json.Unmarshal(userResp.Body.Bytes(), &userResponse)) + if assert.NotEmpty(t, userResponse) { + assert.Contains(t, userResponse[0].FullRepoName, "user2/repo1") + } +} + +func TestOAuth_GrantScopesReadPrivateGroups(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + // setting.OAuth2.EnableAdditionalGrantScopes = true + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user5"}) + + 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:user", + } + + err := db.Insert(db.DefaultContext, grant) + require.NoError(t, err) + + assert.Contains(t, grant.Scope, "openid profile email groups 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] + htmlDoc := NewHTMLParser(t, authorizeResp.Body) + grantReq := NewRequestWithValues(t, "POST", "/login/oauth/grant", map[string]string{ + "_csrf": htmlDoc.GetCSRF(), + "client_id": app.ClientID, + "redirect_uri": "a", + "state": "thestate", + "granted": "true", + }) + grantResp := ctx.MakeRequest(t, grantReq, http.StatusSeeOther) + htmlDocGrant := NewHTMLParser(t, grantResp.Body) + + accessTokenReq := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{ + "_csrf": htmlDocGrant.GetCSRF(), + "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{"limited_org36", "limited_org36:team20writepackage", "org6", "org6:owners", "org7", "org7:owners", "privated_org", "privated_org:team14writeauth"} { + assert.Contains(t, claims.Groups, group) + } +} + +func TestOAuth_GrantScopesReadOnlyPublicGroups(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + setting.OAuth2.EnableAdditionalGrantScopes = true + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user5"}) + + 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:user", + } + + err := db.Insert(db.DefaultContext, grant) + require.NoError(t, err) + + assert.Contains(t, grant.Scope, "openid profile email groups 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] + htmlDoc := NewHTMLParser(t, authorizeResp.Body) + grantReq := NewRequestWithValues(t, "POST", "/login/oauth/grant", map[string]string{ + "_csrf": htmlDoc.GetCSRF(), + "client_id": app.ClientID, + "redirect_uri": "a", + "state": "thestate", + "granted": "true", + }) + grantResp := ctx.MakeRequest(t, grantReq, http.StatusSeeOther) + htmlDocGrant := NewHTMLParser(t, grantResp.Body) + + accessTokenReq := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{ + "_csrf": htmlDocGrant.GetCSRF(), + "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 _, privOrg := range []string{"org7", "org7:owners", "privated_org", "privated_org:team14writeauth"} { + assert.NotContains(t, claims.Groups, privOrg) + } + + userReq := NewRequest(t, "GET", "/login/oauth/userinfo") + userReq.SetHeader("Authorization", "Bearer "+parsed.AccessToken) + userResp := MakeRequest(t, userReq, http.StatusOK) + + type userinfo struct { + Groups []string `json:"groups"` + } + parsedUserInfo := new(userinfo) + require.NoError(t, json.Unmarshal(userResp.Body.Bytes(), parsedUserInfo)) + + for _, privOrg := range []string{"org7", "org7:owners", "privated_org", "privated_org:team14writeauth"} { + assert.NotContains(t, parsedUserInfo.Groups, privOrg) + } +} + +func TestOAuth_GrantScopesReadPublicGroupsWithTheReadScope(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + setting.OAuth2.EnableAdditionalGrantScopes = true + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user5"}) + + 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:user read:organization", + } + + err := db.Insert(db.DefaultContext, grant) + require.NoError(t, err) + + assert.Contains(t, grant.Scope, "openid profile email groups read:user read:organization") + + 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] + htmlDoc := NewHTMLParser(t, authorizeResp.Body) + grantReq := NewRequestWithValues(t, "POST", "/login/oauth/grant", map[string]string{ + "_csrf": htmlDoc.GetCSRF(), + "client_id": app.ClientID, + "redirect_uri": "a", + "state": "thestate", + "granted": "true", + }) + grantResp := ctx.MakeRequest(t, grantReq, http.StatusSeeOther) + htmlDocGrant := NewHTMLParser(t, grantResp.Body) + + accessTokenReq := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{ + "_csrf": htmlDocGrant.GetCSRF(), + "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 _, privOrg := range []string{"org7", "org7:owners", "privated_org", "privated_org:team14writeauth"} { + assert.Contains(t, claims.Groups, privOrg) + } + + userReq := NewRequest(t, "GET", "/login/oauth/userinfo") + userReq.SetHeader("Authorization", "Bearer "+parsed.AccessToken) + userResp := MakeRequest(t, userReq, http.StatusOK) + + type userinfo struct { + Groups []string `json:"groups"` + } + parsedUserInfo := new(userinfo) + require.NoError(t, json.Unmarshal(userResp.Body.Bytes(), parsedUserInfo)) + for _, privOrg := range []string{"org7", "org7:owners", "privated_org", "privated_org:team14writeauth"} { + assert.Contains(t, parsedUserInfo.Groups, privOrg) + } +}