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