Merge pull request 'Enhancing Gitea OAuth2 Provider with Granular Scopes for Resource Access' (#4449) from marcellmars/forgejo:forgejo into forgejo
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/4449 Reviewed-by: Earl Warren <earl-warren@noreply.codeberg.org>
This commit is contained in:
commit
24eb401a0a
8 changed files with 662 additions and 30 deletions
|
@ -100,6 +100,7 @@ var OAuth2 = struct {
|
||||||
JWTSigningPrivateKeyFile string `ini:"JWT_SIGNING_PRIVATE_KEY_FILE"`
|
JWTSigningPrivateKeyFile string `ini:"JWT_SIGNING_PRIVATE_KEY_FILE"`
|
||||||
MaxTokenLength int
|
MaxTokenLength int
|
||||||
DefaultApplications []string
|
DefaultApplications []string
|
||||||
|
EnableAdditionalGrantScopes bool
|
||||||
}{
|
}{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
AccessTokenExpirationTime: 3600,
|
AccessTokenExpirationTime: 3600,
|
||||||
|
@ -109,6 +110,7 @@ var OAuth2 = struct {
|
||||||
JWTSigningPrivateKeyFile: "jwt/private.pem",
|
JWTSigningPrivateKeyFile: "jwt/private.pem",
|
||||||
MaxTokenLength: math.MaxInt16,
|
MaxTokenLength: math.MaxInt16,
|
||||||
DefaultApplications: []string{"git-credential-oauth", "git-credential-manager", "tea"},
|
DefaultApplications: []string{"git-credential-oauth", "git-credential-manager", "tea"},
|
||||||
|
EnableAdditionalGrantScopes: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadOAuth2From(rootCfg ConfigProvider) {
|
func loadOAuth2From(rootCfg ConfigProvider) {
|
||||||
|
|
|
@ -244,7 +244,9 @@ func newAccessTokenResponse(ctx go_context.Context, grant *auth.OAuth2Grant, ser
|
||||||
idToken.EmailVerified = user.IsActive
|
idToken.EmailVerified = user.IsActive
|
||||||
}
|
}
|
||||||
if grant.ScopeContains("groups") {
|
if grant.ScopeContains("groups") {
|
||||||
groups, err := getOAuthGroupsForUser(ctx, user)
|
onlyPublicGroups := ifOnlyPublicGroups(grant.Scope)
|
||||||
|
|
||||||
|
groups, err := getOAuthGroupsForUser(ctx, user, onlyPublicGroups)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Error getting groups: %v", err)
|
log.Error("Error getting groups: %v", err)
|
||||||
return nil, &AccessTokenError{
|
return nil, &AccessTokenError{
|
||||||
|
@ -279,7 +281,18 @@ type userInfoResponse struct {
|
||||||
Username string `json:"preferred_username"`
|
Username string `json:"preferred_username"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Picture string `json:"picture"`
|
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
|
// InfoOAuth manages request for userinfo endpoint
|
||||||
|
@ -298,7 +311,18 @@ func InfoOAuth(ctx *context.Context) {
|
||||||
Picture: ctx.Doer.AvatarLink(ctx),
|
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 {
|
if err != nil {
|
||||||
ctx.ServerError("Oauth groups for user", err)
|
ctx.ServerError("Oauth groups for user", err)
|
||||||
return
|
return
|
||||||
|
@ -310,7 +334,7 @@ func InfoOAuth(ctx *context.Context) {
|
||||||
|
|
||||||
// returns a list of "org" and "org:team" strings,
|
// returns a list of "org" and "org:team" strings,
|
||||||
// that the given user is a part of.
|
// 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)
|
orgs, err := org_model.GetUserOrgsList(ctx, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("GetUserOrgList: %w", err)
|
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
|
var groups []string
|
||||||
for _, org := range orgs {
|
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)
|
groups = append(groups, org.Name)
|
||||||
teams, err := org.LoadTeams(ctx)
|
teams, err := org.LoadTeams(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -110,5 +110,6 @@ func loadApplicationsData(ctx *context.Context) {
|
||||||
ctx.ServerError("GetOAuth2GrantsByUserID", err)
|
ctx.ServerError("GetOAuth2GrantsByUserID", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
ctx.Data["EnableAdditionalGrantScopes"] = setting.OAuth2.EnableAdditionalGrantScopes
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
32
services/auth/additional_scopes_test.go
Normal file
32
services/auth/additional_scopes_test.go
Normal file
|
@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -72,7 +72,7 @@ func (b *Basic) Verify(req *http.Request, w http.ResponseWriter, store DataStore
|
||||||
}
|
}
|
||||||
|
|
||||||
// check oauth2 token
|
// check oauth2 token
|
||||||
uid := CheckOAuthAccessToken(req.Context(), authToken)
|
uid, _ := CheckOAuthAccessToken(req.Context(), authToken)
|
||||||
if uid != 0 {
|
if uid != 0 {
|
||||||
log.Trace("Basic Authorization: Valid OAuthAccessToken for user[%d]", uid)
|
log.Trace("Basic Authorization: Valid OAuthAccessToken for user[%d]", uid)
|
||||||
|
|
||||||
|
|
|
@ -7,6 +7,7 @@ package auth
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
@ -25,28 +26,69 @@ var (
|
||||||
_ Method = &OAuth2{}
|
_ 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
|
// 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 "."
|
// JWT tokens require a "."
|
||||||
if !strings.Contains(accessToken, ".") {
|
if !strings.Contains(accessToken, ".") {
|
||||||
return 0
|
return 0, ""
|
||||||
}
|
}
|
||||||
token, err := oauth2.ParseToken(accessToken, oauth2.DefaultSigningKey)
|
token, err := oauth2.ParseToken(accessToken, oauth2.DefaultSigningKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Trace("oauth2.ParseToken: %v", err)
|
log.Trace("oauth2.ParseToken: %v", err)
|
||||||
return 0
|
return 0, ""
|
||||||
}
|
}
|
||||||
var grant *auth_model.OAuth2Grant
|
var grant *auth_model.OAuth2Grant
|
||||||
if grant, err = auth_model.GetOAuth2GrantByID(ctx, token.GrantID); err != nil || grant == nil {
|
if grant, err = auth_model.GetOAuth2GrantByID(ctx, token.GrantID); err != nil || grant == nil {
|
||||||
return 0
|
return 0, ""
|
||||||
}
|
}
|
||||||
if token.Type != oauth2.TypeAccessToken {
|
if token.Type != oauth2.TypeAccessToken {
|
||||||
return 0
|
return 0, ""
|
||||||
}
|
}
|
||||||
if token.ExpiresAt.Before(time.Now()) || token.IssuedAt.After(time.Now()) {
|
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
|
// OAuth2 implements the Auth interface and authenticates requests
|
||||||
|
@ -92,11 +134,16 @@ func parseToken(req *http.Request) (string, bool) {
|
||||||
func (o *OAuth2) userIDFromToken(ctx context.Context, tokenSHA string, store DataStore) int64 {
|
func (o *OAuth2) userIDFromToken(ctx context.Context, tokenSHA string, store DataStore) int64 {
|
||||||
// Let's see if token is valid.
|
// Let's see if token is valid.
|
||||||
if strings.Contains(tokenSHA, ".") {
|
if strings.Contains(tokenSHA, ".") {
|
||||||
uid := CheckOAuthAccessToken(ctx, tokenSHA)
|
uid, grantScopes := CheckOAuthAccessToken(ctx, tokenSHA)
|
||||||
|
|
||||||
if uid != 0 {
|
if uid != 0 {
|
||||||
store.GetData()["IsApiToken"] = true
|
store.GetData()["IsApiToken"] = true
|
||||||
|
if grantScopes != "" {
|
||||||
|
store.GetData()["ApiTokenScope"] = auth_model.AccessTokenScope(grantScopes)
|
||||||
|
} else {
|
||||||
store.GetData()["ApiTokenScope"] = auth_model.AccessTokenScopeAll // fallback to all
|
store.GetData()["ApiTokenScope"] = auth_model.AccessTokenScopeAll // fallback to all
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return uid
|
return uid
|
||||||
}
|
}
|
||||||
t, err := auth_model.GetAccessTokenBySHA(ctx, tokenSHA)
|
t, err := auth_model.GetAccessTokenBySHA(ctx, tokenSHA)
|
||||||
|
|
|
@ -11,6 +11,7 @@
|
||||||
<b>{{ctx.Locale.Tr "auth.authorize_application_description"}}</b><br>
|
<b>{{ctx.Locale.Tr "auth.authorize_application_description"}}</b><br>
|
||||||
{{ctx.Locale.Tr "auth.authorize_application_created_by" .ApplicationCreatorLinkHTML}}
|
{{ctx.Locale.Tr "auth.authorize_application_created_by" .ApplicationCreatorLinkHTML}}
|
||||||
</p>
|
</p>
|
||||||
|
<p>With scopes: {{.Scope}}.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="ui attached segment">
|
<div class="ui attached segment">
|
||||||
<p>{{ctx.Locale.Tr "auth.authorize_redirect_notice" .ApplicationRedirectDomainHTML}}</p>
|
<p>{{ctx.Locale.Tr "auth.authorize_redirect_notice" .ApplicationRedirectDomainHTML}}</p>
|
||||||
|
|
|
@ -12,13 +12,16 @@ import (
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
auth_model "code.gitea.io/gitea/models/auth"
|
auth_model "code.gitea.io/gitea/models/auth"
|
||||||
|
"code.gitea.io/gitea/models/db"
|
||||||
"code.gitea.io/gitea/models/unittest"
|
"code.gitea.io/gitea/models/unittest"
|
||||||
user_model "code.gitea.io/gitea/models/user"
|
user_model "code.gitea.io/gitea/models/user"
|
||||||
"code.gitea.io/gitea/modules/json"
|
"code.gitea.io/gitea/modules/json"
|
||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
|
api "code.gitea.io/gitea/modules/structs"
|
||||||
"code.gitea.io/gitea/modules/test"
|
"code.gitea.io/gitea/modules/test"
|
||||||
"code.gitea.io/gitea/routers/web/auth"
|
"code.gitea.io/gitea/routers/web/auth"
|
||||||
"code.gitea.io/gitea/tests"
|
"code.gitea.io/gitea/tests"
|
||||||
|
@ -799,3 +802,516 @@ func TestOAuthIntrospection(t *testing.T) {
|
||||||
assert.Contains(t, resp.Body.String(), "no valid authorization")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue