mirror of
https://github.com/go-gitea/gitea.git
synced 2024-11-27 12:39:29 +08:00
Enhancing Gitea OAuth2 Provider with Granular Scopes for Resource Access (#32573)
Resolve #31609 This PR was initiated following my personal research to find the lightest possible Single Sign-On solution for self-hosted setups. The existing solutions often seemed too enterprise-oriented, involving many moving parts and services, demanding significant resources while promising planetary-scale capabilities. Others were adequate in supporting basic OAuth2 flows but lacked proper user management features, such as a change password UI. Gitea hits the sweet spot for me, provided it supports more granular access permissions for resources under users who accept the OAuth2 application. This PR aims to introduce granularity in handling user resources as nonintrusively and simply as possible. It allows third parties to inform users about their intent to not ask for the full access and instead request a specific, reduced scope. If the provided scopes are **only** the typical ones for OIDC/OAuth2—`openid`, `profile`, `email`, and `groups`—everything remains unchanged (currently full access to user's resources). Additionally, this PR supports processing scopes already introduced with [personal tokens](https://docs.gitea.com/development/oauth2-provider#scopes) (e.g. `read:user`, `write:issue`, `read:group`, `write:repository`...) Personal tokens define scopes around specific resources: user info, repositories, issues, packages, organizations, notifications, miscellaneous, admin, and activitypub, with access delineated by read and/or write permissions. The initial case I wanted to address was to have Gitea act as an OAuth2 Identity Provider. To achieve that, with this PR, I would only add `openid public-only` to provide access token to the third party to authenticate the Gitea's user but no further access to the API and users resources. Another example: if a third party wanted to interact solely with Issues, it would need to add `read:user` (for authorization) and `read:issue`/`write:issue` to manage Issues. My approach is based on my understanding of how scopes can be utilized, supported by examples like [Sample Use Cases: Scopes and Claims](https://auth0.com/docs/get-started/apis/scopes/sample-use-cases-scopes-and-claims) on auth0.com. I renamed `CheckOAuthAccessToken` to `GetOAuthAccessTokenScopeAndUserID` so now it returns AccessTokenScope and user's ID. In the case of additional scopes in `userIDFromToken` the default `all` would be reduced to whatever was asked via those scopes. The main difference is the opportunity to reduce the permissions from `all`, as is currently the case, to what is provided by the additional scopes described above. Screenshots: ![Screenshot_20241121_121405](https://github.com/user-attachments/assets/29deaed7-4333-4b02-8898-b822e6f2463e) ![Screenshot_20241121_120211](https://github.com/user-attachments/assets/7a4a4ef7-409c-4116-9d5f-2fe00eb37167) ![Screenshot_20241121_120119](https://github.com/user-attachments/assets/aa52c1a2-212d-4e64-bcdf-7122cee49eb6) ![Screenshot_20241121_120018](https://github.com/user-attachments/assets/9eac318c-e381-4ea9-9e2c-3a3f60319e47) --------- Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
parent
a175f9805c
commit
a3881ffa3d
@ -459,6 +459,7 @@ authorize_application = Authorize Application
|
|||||||
authorize_redirect_notice = You will be redirected to %s if you authorize this application.
|
authorize_redirect_notice = You will be redirected to %s if you authorize this application.
|
||||||
authorize_application_created_by = This application was created by %s.
|
authorize_application_created_by = This application was created by %s.
|
||||||
authorize_application_description = If you grant the access, it will be able to access and write to all your account information, including private repos and organisations.
|
authorize_application_description = If you grant the access, it will be able to access and write to all your account information, including private repos and organisations.
|
||||||
|
authorize_application_with_scopes = With scopes: %s
|
||||||
authorize_title = Authorize "%s" to access your account?
|
authorize_title = Authorize "%s" to access your account?
|
||||||
authorization_failed = Authorization failed
|
authorization_failed = Authorization failed
|
||||||
authorization_failed_desc = The authorization failed because we detected an invalid request. Please contact the maintainer of the app you have tried to authorize.
|
authorization_failed_desc = The authorization failed because we detected an invalid request. Please contact the maintainer of the app you have tried to authorize.
|
||||||
|
@ -104,7 +104,18 @@ func InfoOAuth(ctx *context.Context) {
|
|||||||
Picture: ctx.Doer.AvatarLink(ctx),
|
Picture: ctx.Doer.AvatarLink(ctx),
|
||||||
}
|
}
|
||||||
|
|
||||||
groups, err := oauth2_provider.GetOAuthGroupsForUser(ctx, ctx.Doer)
|
var accessTokenScope auth.AccessTokenScope
|
||||||
|
if auHead := ctx.Req.Header.Get("Authorization"); auHead != "" {
|
||||||
|
auths := strings.Fields(auHead)
|
||||||
|
if len(auths) == 2 && (auths[0] == "token" || strings.ToLower(auths[0]) == "bearer") {
|
||||||
|
accessTokenScope, _ = auth_service.GetOAuthAccessTokenScopeAndUserID(ctx, auths[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// since version 1.22 does not verify if groups should be public-only,
|
||||||
|
// onlyPublicGroups will be set only if 'public-only' is included in a valid scope
|
||||||
|
onlyPublicGroups, _ := accessTokenScope.PublicOnly()
|
||||||
|
groups, err := oauth2_provider.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
|
||||||
@ -304,6 +315,9 @@ func AuthorizeOAuth(ctx *context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// check if additional scopes
|
||||||
|
ctx.Data["AdditionalScopes"] = oauth2_provider.GrantAdditionalScopes(form.Scope) != auth.AccessTokenScopeAll
|
||||||
|
|
||||||
// show authorize page to grant access
|
// show authorize page to grant access
|
||||||
ctx.Data["Application"] = app
|
ctx.Data["Application"] = app
|
||||||
ctx.Data["RedirectURI"] = form.RedirectURI
|
ctx.Data["RedirectURI"] = form.RedirectURI
|
||||||
|
@ -77,8 +77,8 @@ func (b *Basic) Verify(req *http.Request, w http.ResponseWriter, store DataStore
|
|||||||
log.Trace("Basic Authorization: Attempting login with username as token")
|
log.Trace("Basic Authorization: Attempting login with username as token")
|
||||||
}
|
}
|
||||||
|
|
||||||
// check oauth2 token
|
// get oauth2 token's user's ID
|
||||||
uid := CheckOAuthAccessToken(req.Context(), authToken)
|
_, uid := GetOAuthAccessTokenScopeAndUserID(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)
|
||||||
|
|
||||||
|
@ -26,33 +26,35 @@ var (
|
|||||||
_ Method = &OAuth2{}
|
_ Method = &OAuth2{}
|
||||||
)
|
)
|
||||||
|
|
||||||
// CheckOAuthAccessToken returns uid of user from oauth token
|
// GetOAuthAccessTokenScopeAndUserID returns access token scope and user id
|
||||||
func CheckOAuthAccessToken(ctx context.Context, accessToken string) int64 {
|
func GetOAuthAccessTokenScopeAndUserID(ctx context.Context, accessToken string) (auth_model.AccessTokenScope, int64) {
|
||||||
|
var accessTokenScope auth_model.AccessTokenScope
|
||||||
if !setting.OAuth2.Enabled {
|
if !setting.OAuth2.Enabled {
|
||||||
return 0
|
return accessTokenScope, 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// JWT tokens require a ".", if the token isn't like that, return early
|
// JWT tokens require a ".", if the token isn't like that, return early
|
||||||
if !strings.Contains(accessToken, ".") {
|
if !strings.Contains(accessToken, ".") {
|
||||||
return 0
|
return accessTokenScope, 0
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := oauth2_provider.ParseToken(accessToken, oauth2_provider.DefaultSigningKey)
|
token, err := oauth2_provider.ParseToken(accessToken, oauth2_provider.DefaultSigningKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Trace("oauth2.ParseToken: %v", err)
|
log.Trace("oauth2.ParseToken: %v", err)
|
||||||
return 0
|
return accessTokenScope, 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 accessTokenScope, 0
|
||||||
}
|
}
|
||||||
if token.Kind != oauth2_provider.KindAccessToken {
|
if token.Kind != oauth2_provider.KindAccessToken {
|
||||||
return 0
|
return accessTokenScope, 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 accessTokenScope, 0
|
||||||
}
|
}
|
||||||
return grant.UserID
|
accessTokenScope = oauth2_provider.GrantAdditionalScopes(grant.Scope)
|
||||||
|
return accessTokenScope, grant.UserID
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckTaskIsRunning verifies that the TaskID corresponds to a running task
|
// CheckTaskIsRunning verifies that the TaskID corresponds to a running task
|
||||||
@ -120,10 +122,10 @@ func (o *OAuth2) userIDFromToken(ctx context.Context, tokenSHA string, store Dat
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, check if this is an OAuth access token
|
// Otherwise, check if this is an OAuth access token
|
||||||
uid := CheckOAuthAccessToken(ctx, tokenSHA)
|
accessTokenScope, uid := GetOAuthAccessTokenScopeAndUserID(ctx, tokenSHA)
|
||||||
if uid != 0 {
|
if uid != 0 {
|
||||||
store.GetData()["IsApiToken"] = true
|
store.GetData()["IsApiToken"] = true
|
||||||
store.GetData()["ApiTokenScope"] = auth_model.AccessTokenScopeAll // fallback to all
|
store.GetData()["ApiTokenScope"] = accessTokenScope
|
||||||
}
|
}
|
||||||
return uid
|
return uid
|
||||||
}
|
}
|
||||||
|
@ -6,6 +6,8 @@ package oauth2_provider //nolint
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
auth "code.gitea.io/gitea/models/auth"
|
auth "code.gitea.io/gitea/models/auth"
|
||||||
"code.gitea.io/gitea/models/db"
|
"code.gitea.io/gitea/models/db"
|
||||||
@ -69,6 +71,32 @@ type AccessTokenResponse struct {
|
|||||||
IDToken string `json:"id_token,omitempty"`
|
IDToken string `json:"id_token,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GrantAdditionalScopes returns valid scopes coming from grant
|
||||||
|
func GrantAdditionalScopes(grantScopes string) auth.AccessTokenScope {
|
||||||
|
// scopes_supported from templates/user/auth/oidc_wellknown.tmpl
|
||||||
|
scopesSupported := []string{
|
||||||
|
"openid",
|
||||||
|
"profile",
|
||||||
|
"email",
|
||||||
|
"groups",
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokenScopes []string
|
||||||
|
for _, tokenScope := range strings.Split(grantScopes, " ") {
|
||||||
|
if slices.Index(scopesSupported, tokenScope) == -1 {
|
||||||
|
tokenScopes = append(tokenScopes, tokenScope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// since version 1.22, access tokens grant full access to the API
|
||||||
|
// with this access is reduced only if additional scopes are provided
|
||||||
|
accessTokenScope := auth.AccessTokenScope(strings.Join(tokenScopes, ","))
|
||||||
|
if accessTokenWithAdditionalScopes, err := accessTokenScope.Normalize(); err == nil && len(tokenScopes) > 0 {
|
||||||
|
return accessTokenWithAdditionalScopes
|
||||||
|
}
|
||||||
|
return auth.AccessTokenScopeAll
|
||||||
|
}
|
||||||
|
|
||||||
func NewAccessTokenResponse(ctx context.Context, grant *auth.OAuth2Grant, serverKey, clientKey JWTSigningKey) (*AccessTokenResponse, *AccessTokenError) {
|
func NewAccessTokenResponse(ctx context.Context, grant *auth.OAuth2Grant, serverKey, clientKey JWTSigningKey) (*AccessTokenResponse, *AccessTokenError) {
|
||||||
if setting.OAuth2.InvalidateRefreshTokens {
|
if setting.OAuth2.InvalidateRefreshTokens {
|
||||||
if err := grant.IncreaseCounter(ctx); err != nil {
|
if err := grant.IncreaseCounter(ctx); err != nil {
|
||||||
@ -161,7 +189,13 @@ func NewAccessTokenResponse(ctx context.Context, grant *auth.OAuth2Grant, server
|
|||||||
idToken.EmailVerified = user.IsActive
|
idToken.EmailVerified = user.IsActive
|
||||||
}
|
}
|
||||||
if grant.ScopeContains("groups") {
|
if grant.ScopeContains("groups") {
|
||||||
groups, err := GetOAuthGroupsForUser(ctx, user)
|
accessTokenScope := GrantAdditionalScopes(grant.Scope)
|
||||||
|
|
||||||
|
// since version 1.22 does not verify if groups should be public-only,
|
||||||
|
// onlyPublicGroups will be set only if 'public-only' is included in a valid scope
|
||||||
|
onlyPublicGroups, _ := accessTokenScope.PublicOnly()
|
||||||
|
|
||||||
|
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{
|
||||||
@ -192,10 +226,10 @@ func NewAccessTokenResponse(ctx context.Context, grant *auth.OAuth2Grant, server
|
|||||||
|
|
||||||
// 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 context.Context, user *user_model.User) ([]string, error) {
|
func GetOAuthGroupsForUser(ctx context.Context, user *user_model.User, onlyPublicGroups bool) ([]string, error) {
|
||||||
orgs, err := db.Find[org_model.Organization](ctx, org_model.FindOrgOptions{
|
orgs, err := db.Find[org_model.Organization](ctx, org_model.FindOrgOptions{
|
||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
IncludePrivate: true,
|
IncludePrivate: !onlyPublicGroups,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("GetUserOrgList: %w", err)
|
return nil, fmt.Errorf("GetUserOrgList: %w", err)
|
||||||
|
35
services/oauth2_provider/additional_scopes_test.go
Normal file
35
services/oauth2_provider/additional_scopes_test.go
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package oauth2_provider //nolint
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGrantAdditionalScopes(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
grantScopes string
|
||||||
|
expectedScopes string
|
||||||
|
}{
|
||||||
|
{"openid profile email", "all"},
|
||||||
|
{"openid profile email groups", "all"},
|
||||||
|
{"openid profile email all", "all"},
|
||||||
|
{"openid profile email read:user all", "all"},
|
||||||
|
{"openid profile email groups read:user", "read:user"},
|
||||||
|
{"read:user read:repository", "read:repository,read:user"},
|
||||||
|
{"read:user write:issue public-only", "public-only,write:issue,read:user"},
|
||||||
|
{"openid profile email read:user", "read:user"},
|
||||||
|
{"read:invalid_scope", "all"},
|
||||||
|
{"read:invalid_scope,write:scope_invalid,just-plain-wrong", "all"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.grantScopes, func(t *testing.T) {
|
||||||
|
result := GrantAdditionalScopes(test.grantScopes)
|
||||||
|
assert.Equal(t, test.expectedScopes, string(result))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
@ -8,8 +8,11 @@
|
|||||||
<div class="ui attached segment">
|
<div class="ui attached segment">
|
||||||
{{template "base/alert" .}}
|
{{template "base/alert" .}}
|
||||||
<p>
|
<p>
|
||||||
|
{{if not .AdditionalScopes}}
|
||||||
<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}}
|
{{end}}
|
||||||
|
{{ctx.Locale.Tr "auth.authorize_application_created_by" .ApplicationCreatorLinkHTML}}<br>
|
||||||
|
{{ctx.Locale.Tr "auth.authorize_application_with_scopes" (HTMLFormat "<b>%s</b>" .Scope)}}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="ui attached segment">
|
<div class="ui attached segment">
|
||||||
|
@ -5,16 +5,25 @@ package integration
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"testing"
|
"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/json"
|
||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
|
api "code.gitea.io/gitea/modules/structs"
|
||||||
oauth2_provider "code.gitea.io/gitea/services/oauth2_provider"
|
oauth2_provider "code.gitea.io/gitea/services/oauth2_provider"
|
||||||
"code.gitea.io/gitea/tests"
|
"code.gitea.io/gitea/tests"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAuthorizeNoClientID(t *testing.T) {
|
func TestAuthorizeNoClientID(t *testing.T) {
|
||||||
@ -477,3 +486,424 @@ func TestOAuthIntrospection(t *testing.T) {
|
|||||||
resp = MakeRequest(t, req, http.StatusUnauthorized)
|
resp = MakeRequest(t, req, http.StatusUnauthorized)
|
||||||
assert.Contains(t, resp.Body.String(), "no valid authorization")
|
assert.Contains(t, resp.Body.String(), "no valid authorization")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOAuth_GrantScopesReadUserFailRepos(t *testing.T) {
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||||
|
appBody := api.CreateOAuth2ApplicationOptions{
|
||||||
|
Name: "oauth-provider-scopes-test",
|
||||||
|
RedirectURIs: []string{
|
||||||
|
"a",
|
||||||
|
},
|
||||||
|
ConfidentialClient: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
req := NewRequestWithJSON(t, "POST", "/api/v1/user/applications/oauth2", &appBody).
|
||||||
|
AddBasicAuth(user.Name)
|
||||||
|
resp := MakeRequest(t, req, http.StatusCreated)
|
||||||
|
|
||||||
|
var app *api.OAuth2Application
|
||||||
|
DecodeJSON(t, resp, &app)
|
||||||
|
|
||||||
|
grant := &auth_model.OAuth2Grant{
|
||||||
|
ApplicationID: app.ID,
|
||||||
|
UserID: user.ID,
|
||||||
|
Scope: "openid read:user",
|
||||||
|
}
|
||||||
|
|
||||||
|
err := db.Insert(db.DefaultContext, grant)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Contains(t, grant.Scope, "openid read:user")
|
||||||
|
|
||||||
|
ctx := loginUser(t, user.Name)
|
||||||
|
|
||||||
|
authorizeURL := fmt.Sprintf("/login/oauth/authorize?client_id=%s&redirect_uri=a&response_type=code&state=thestate", app.ClientID)
|
||||||
|
authorizeReq := NewRequest(t, "GET", authorizeURL)
|
||||||
|
authorizeResp := ctx.MakeRequest(t, authorizeReq, http.StatusSeeOther)
|
||||||
|
|
||||||
|
authcode := strings.Split(strings.Split(authorizeResp.Body.String(), "?code=")[1], "&")[0]
|
||||||
|
|
||||||
|
accessTokenReq := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"client_id": app.ClientID,
|
||||||
|
"client_secret": app.ClientSecret,
|
||||||
|
"redirect_uri": "a",
|
||||||
|
"code": authcode,
|
||||||
|
})
|
||||||
|
accessTokenResp := ctx.MakeRequest(t, accessTokenReq, 200)
|
||||||
|
type response struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
TokenType string `json:"token_type"`
|
||||||
|
ExpiresIn int64 `json:"expires_in"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
}
|
||||||
|
parsed := new(response)
|
||||||
|
|
||||||
|
require.NoError(t, json.Unmarshal(accessTokenResp.Body.Bytes(), parsed))
|
||||||
|
userReq := NewRequest(t, "GET", "/api/v1/user")
|
||||||
|
userReq.SetHeader("Authorization", "Bearer "+parsed.AccessToken)
|
||||||
|
userResp := MakeRequest(t, userReq, http.StatusOK)
|
||||||
|
|
||||||
|
type userResponse struct {
|
||||||
|
Login string `json:"login"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
userParsed := new(userResponse)
|
||||||
|
require.NoError(t, json.Unmarshal(userResp.Body.Bytes(), userParsed))
|
||||||
|
assert.Contains(t, userParsed.Email, "user2@example.com")
|
||||||
|
|
||||||
|
errorReq := NewRequest(t, "GET", "/api/v1/users/user2/repos")
|
||||||
|
errorReq.SetHeader("Authorization", "Bearer "+parsed.AccessToken)
|
||||||
|
errorResp := MakeRequest(t, errorReq, http.StatusForbidden)
|
||||||
|
|
||||||
|
type errorResponse struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
errorParsed := new(errorResponse)
|
||||||
|
require.NoError(t, json.Unmarshal(errorResp.Body.Bytes(), errorParsed))
|
||||||
|
assert.Contains(t, errorParsed.Message, "token does not have at least one of required scope(s): [read:repository]")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOAuth_GrantScopesReadRepositoryFailOrganization(t *testing.T) {
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||||
|
appBody := api.CreateOAuth2ApplicationOptions{
|
||||||
|
Name: "oauth-provider-scopes-test",
|
||||||
|
RedirectURIs: []string{
|
||||||
|
"a",
|
||||||
|
},
|
||||||
|
ConfidentialClient: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
req := NewRequestWithJSON(t, "POST", "/api/v1/user/applications/oauth2", &appBody).
|
||||||
|
AddBasicAuth(user.Name)
|
||||||
|
resp := MakeRequest(t, req, http.StatusCreated)
|
||||||
|
|
||||||
|
var app *api.OAuth2Application
|
||||||
|
DecodeJSON(t, resp, &app)
|
||||||
|
|
||||||
|
grant := &auth_model.OAuth2Grant{
|
||||||
|
ApplicationID: app.ID,
|
||||||
|
UserID: user.ID,
|
||||||
|
Scope: "openid read:user read:repository",
|
||||||
|
}
|
||||||
|
|
||||||
|
err := db.Insert(db.DefaultContext, grant)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Contains(t, grant.Scope, "openid read:user read:repository")
|
||||||
|
|
||||||
|
ctx := loginUser(t, user.Name)
|
||||||
|
|
||||||
|
authorizeURL := fmt.Sprintf("/login/oauth/authorize?client_id=%s&redirect_uri=a&response_type=code&state=thestate", app.ClientID)
|
||||||
|
authorizeReq := NewRequest(t, "GET", authorizeURL)
|
||||||
|
authorizeResp := ctx.MakeRequest(t, authorizeReq, http.StatusSeeOther)
|
||||||
|
|
||||||
|
authcode := strings.Split(strings.Split(authorizeResp.Body.String(), "?code=")[1], "&")[0]
|
||||||
|
accessTokenReq := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"client_id": app.ClientID,
|
||||||
|
"client_secret": app.ClientSecret,
|
||||||
|
"redirect_uri": "a",
|
||||||
|
"code": authcode,
|
||||||
|
})
|
||||||
|
accessTokenResp := ctx.MakeRequest(t, accessTokenReq, http.StatusOK)
|
||||||
|
type response struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
TokenType string `json:"token_type"`
|
||||||
|
ExpiresIn int64 `json:"expires_in"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
}
|
||||||
|
parsed := new(response)
|
||||||
|
|
||||||
|
require.NoError(t, json.Unmarshal(accessTokenResp.Body.Bytes(), parsed))
|
||||||
|
userReq := NewRequest(t, "GET", "/api/v1/users/user2/repos")
|
||||||
|
userReq.SetHeader("Authorization", "Bearer "+parsed.AccessToken)
|
||||||
|
userResp := MakeRequest(t, userReq, http.StatusOK)
|
||||||
|
|
||||||
|
type repo struct {
|
||||||
|
FullRepoName string `json:"full_name"`
|
||||||
|
Private bool `json:"private"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var reposCaptured []repo
|
||||||
|
require.NoError(t, json.Unmarshal(userResp.Body.Bytes(), &reposCaptured))
|
||||||
|
|
||||||
|
reposExpected := []repo{
|
||||||
|
{
|
||||||
|
FullRepoName: "user2/repo1",
|
||||||
|
Private: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
FullRepoName: "user2/repo2",
|
||||||
|
Private: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
FullRepoName: "user2/repo15",
|
||||||
|
Private: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
FullRepoName: "user2/repo16",
|
||||||
|
Private: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
FullRepoName: "user2/repo20",
|
||||||
|
Private: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
FullRepoName: "user2/utf8",
|
||||||
|
Private: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
FullRepoName: "user2/commits_search_test",
|
||||||
|
Private: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
FullRepoName: "user2/git_hooks_test",
|
||||||
|
Private: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
FullRepoName: "user2/glob",
|
||||||
|
Private: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
FullRepoName: "user2/lfs",
|
||||||
|
Private: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
FullRepoName: "user2/scoped_label",
|
||||||
|
Private: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
FullRepoName: "user2/readme-test",
|
||||||
|
Private: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
FullRepoName: "user2/repo-release",
|
||||||
|
Private: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
FullRepoName: "user2/commitsonpr",
|
||||||
|
Private: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
FullRepoName: "user2/test_commit_revert",
|
||||||
|
Private: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
assert.Equal(t, reposExpected, reposCaptured)
|
||||||
|
|
||||||
|
errorReq := NewRequest(t, "GET", "/api/v1/users/user2/orgs")
|
||||||
|
errorReq.SetHeader("Authorization", "Bearer "+parsed.AccessToken)
|
||||||
|
errorResp := MakeRequest(t, errorReq, http.StatusForbidden)
|
||||||
|
|
||||||
|
type errorResponse struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
errorParsed := new(errorResponse)
|
||||||
|
require.NoError(t, json.Unmarshal(errorResp.Body.Bytes(), errorParsed))
|
||||||
|
assert.Contains(t, errorParsed.Message, "token does not have at least one of required scope(s): [read:user read:organization]")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOAuth_GrantScopesClaimPublicOnlyGroups(t *testing.T) {
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2"})
|
||||||
|
|
||||||
|
appBody := api.CreateOAuth2ApplicationOptions{
|
||||||
|
Name: "oauth-provider-scopes-test",
|
||||||
|
RedirectURIs: []string{
|
||||||
|
"a",
|
||||||
|
},
|
||||||
|
ConfidentialClient: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
appReq := NewRequestWithJSON(t, "POST", "/api/v1/user/applications/oauth2", &appBody).
|
||||||
|
AddBasicAuth(user.Name)
|
||||||
|
appResp := MakeRequest(t, appReq, http.StatusCreated)
|
||||||
|
|
||||||
|
var app *api.OAuth2Application
|
||||||
|
DecodeJSON(t, appResp, &app)
|
||||||
|
|
||||||
|
grant := &auth_model.OAuth2Grant{
|
||||||
|
ApplicationID: app.ID,
|
||||||
|
UserID: user.ID,
|
||||||
|
Scope: "openid groups read:user public-only",
|
||||||
|
}
|
||||||
|
|
||||||
|
err := db.Insert(db.DefaultContext, grant)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.ElementsMatch(t, []string{"openid", "groups", "read:user", "public-only"}, strings.Split(grant.Scope, " "))
|
||||||
|
|
||||||
|
ctx := loginUser(t, user.Name)
|
||||||
|
|
||||||
|
authorizeURL := fmt.Sprintf("/login/oauth/authorize?client_id=%s&redirect_uri=a&response_type=code&state=thestate", app.ClientID)
|
||||||
|
authorizeReq := NewRequest(t, "GET", authorizeURL)
|
||||||
|
authorizeResp := ctx.MakeRequest(t, authorizeReq, http.StatusSeeOther)
|
||||||
|
|
||||||
|
authcode := strings.Split(strings.Split(authorizeResp.Body.String(), "?code=")[1], "&")[0]
|
||||||
|
|
||||||
|
accessTokenReq := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"client_id": app.ClientID,
|
||||||
|
"client_secret": app.ClientSecret,
|
||||||
|
"redirect_uri": "a",
|
||||||
|
"code": authcode,
|
||||||
|
})
|
||||||
|
accessTokenResp := ctx.MakeRequest(t, accessTokenReq, http.StatusOK)
|
||||||
|
type response struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
TokenType string `json:"token_type"`
|
||||||
|
ExpiresIn int64 `json:"expires_in"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
IDToken string `json:"id_token,omitempty"`
|
||||||
|
}
|
||||||
|
parsed := new(response)
|
||||||
|
require.NoError(t, json.Unmarshal(accessTokenResp.Body.Bytes(), parsed))
|
||||||
|
parts := strings.Split(parsed.IDToken, ".")
|
||||||
|
|
||||||
|
payload, _ := base64.RawURLEncoding.DecodeString(parts[1])
|
||||||
|
type IDTokenClaims struct {
|
||||||
|
Groups []string `json:"groups"`
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := new(IDTokenClaims)
|
||||||
|
require.NoError(t, json.Unmarshal(payload, claims))
|
||||||
|
|
||||||
|
userinfoReq := NewRequest(t, "GET", "/login/oauth/userinfo")
|
||||||
|
userinfoReq.SetHeader("Authorization", "Bearer "+parsed.AccessToken)
|
||||||
|
userinfoResp := MakeRequest(t, userinfoReq, http.StatusOK)
|
||||||
|
|
||||||
|
type userinfoResponse struct {
|
||||||
|
Login string `json:"login"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Groups []string `json:"groups"`
|
||||||
|
}
|
||||||
|
|
||||||
|
userinfoParsed := new(userinfoResponse)
|
||||||
|
require.NoError(t, json.Unmarshal(userinfoResp.Body.Bytes(), userinfoParsed))
|
||||||
|
assert.Contains(t, userinfoParsed.Email, "user2@example.com")
|
||||||
|
|
||||||
|
// test both id_token and call to /login/oauth/userinfo
|
||||||
|
for _, publicGroup := range []string{
|
||||||
|
"org17",
|
||||||
|
"org17:test_team",
|
||||||
|
"org3",
|
||||||
|
"org3:owners",
|
||||||
|
"org3:team1",
|
||||||
|
"org3:teamcreaterepo",
|
||||||
|
} {
|
||||||
|
assert.Contains(t, claims.Groups, publicGroup)
|
||||||
|
assert.Contains(t, userinfoParsed.Groups, publicGroup)
|
||||||
|
}
|
||||||
|
for _, privateGroup := range []string{
|
||||||
|
"private_org35",
|
||||||
|
"private_org35_team24",
|
||||||
|
} {
|
||||||
|
assert.NotContains(t, claims.Groups, privateGroup)
|
||||||
|
assert.NotContains(t, userinfoParsed.Groups, privateGroup)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOAuth_GrantScopesClaimAllGroups(t *testing.T) {
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2"})
|
||||||
|
|
||||||
|
appBody := api.CreateOAuth2ApplicationOptions{
|
||||||
|
Name: "oauth-provider-scopes-test",
|
||||||
|
RedirectURIs: []string{
|
||||||
|
"a",
|
||||||
|
},
|
||||||
|
ConfidentialClient: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
appReq := NewRequestWithJSON(t, "POST", "/api/v1/user/applications/oauth2", &appBody).
|
||||||
|
AddBasicAuth(user.Name)
|
||||||
|
appResp := MakeRequest(t, appReq, http.StatusCreated)
|
||||||
|
|
||||||
|
var app *api.OAuth2Application
|
||||||
|
DecodeJSON(t, appResp, &app)
|
||||||
|
|
||||||
|
grant := &auth_model.OAuth2Grant{
|
||||||
|
ApplicationID: app.ID,
|
||||||
|
UserID: user.ID,
|
||||||
|
Scope: "openid groups",
|
||||||
|
}
|
||||||
|
|
||||||
|
err := db.Insert(db.DefaultContext, grant)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.ElementsMatch(t, []string{"openid", "groups"}, strings.Split(grant.Scope, " "))
|
||||||
|
|
||||||
|
ctx := loginUser(t, user.Name)
|
||||||
|
|
||||||
|
authorizeURL := fmt.Sprintf("/login/oauth/authorize?client_id=%s&redirect_uri=a&response_type=code&state=thestate", app.ClientID)
|
||||||
|
authorizeReq := NewRequest(t, "GET", authorizeURL)
|
||||||
|
authorizeResp := ctx.MakeRequest(t, authorizeReq, http.StatusSeeOther)
|
||||||
|
|
||||||
|
authcode := strings.Split(strings.Split(authorizeResp.Body.String(), "?code=")[1], "&")[0]
|
||||||
|
|
||||||
|
accessTokenReq := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"client_id": app.ClientID,
|
||||||
|
"client_secret": app.ClientSecret,
|
||||||
|
"redirect_uri": "a",
|
||||||
|
"code": authcode,
|
||||||
|
})
|
||||||
|
accessTokenResp := ctx.MakeRequest(t, accessTokenReq, http.StatusOK)
|
||||||
|
type response struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
TokenType string `json:"token_type"`
|
||||||
|
ExpiresIn int64 `json:"expires_in"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
IDToken string `json:"id_token,omitempty"`
|
||||||
|
}
|
||||||
|
parsed := new(response)
|
||||||
|
require.NoError(t, json.Unmarshal(accessTokenResp.Body.Bytes(), parsed))
|
||||||
|
parts := strings.Split(parsed.IDToken, ".")
|
||||||
|
|
||||||
|
payload, _ := base64.RawURLEncoding.DecodeString(parts[1])
|
||||||
|
type IDTokenClaims struct {
|
||||||
|
Groups []string `json:"groups"`
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := new(IDTokenClaims)
|
||||||
|
require.NoError(t, json.Unmarshal(payload, claims))
|
||||||
|
|
||||||
|
userinfoReq := NewRequest(t, "GET", "/login/oauth/userinfo")
|
||||||
|
userinfoReq.SetHeader("Authorization", "Bearer "+parsed.AccessToken)
|
||||||
|
userinfoResp := MakeRequest(t, userinfoReq, http.StatusOK)
|
||||||
|
|
||||||
|
type userinfoResponse struct {
|
||||||
|
Login string `json:"login"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Groups []string `json:"groups"`
|
||||||
|
}
|
||||||
|
|
||||||
|
userinfoParsed := new(userinfoResponse)
|
||||||
|
require.NoError(t, json.Unmarshal(userinfoResp.Body.Bytes(), userinfoParsed))
|
||||||
|
assert.Contains(t, userinfoParsed.Email, "user2@example.com")
|
||||||
|
|
||||||
|
// test both id_token and call to /login/oauth/userinfo
|
||||||
|
for _, group := range []string{
|
||||||
|
"org17",
|
||||||
|
"org17:test_team",
|
||||||
|
"org3",
|
||||||
|
"org3:owners",
|
||||||
|
"org3:team1",
|
||||||
|
"org3:teamcreaterepo",
|
||||||
|
"private_org35",
|
||||||
|
"private_org35:team24",
|
||||||
|
} {
|
||||||
|
assert.Contains(t, claims.Groups, group)
|
||||||
|
assert.Contains(t, userinfoParsed.Groups, group)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user