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