2019-03-08 17:42:50 +01:00
|
|
|
// Copyright 2019 The Gitea Authors. All rights reserved.
|
2022-11-27 19:20:29 +01:00
|
|
|
// SPDX-License-Identifier: MIT
|
2019-03-08 17:42:50 +01:00
|
|
|
|
2022-09-02 21:18:23 +02:00
|
|
|
package integration
|
2019-03-08 17:42:50 +01:00
|
|
|
|
|
|
|
import (
|
2020-11-13 13:51:07 +01:00
|
|
|
"bytes"
|
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>
2024-11-22 05:06:41 +01:00
|
|
|
"encoding/base64"
|
|
|
|
"fmt"
|
2021-09-22 07:38:34 +02:00
|
|
|
"io"
|
2022-03-23 05:54:07 +01:00
|
|
|
"net/http"
|
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>
2024-11-22 05:06:41 +01:00
|
|
|
"strings"
|
2019-03-08 17:42:50 +01:00
|
|
|
"testing"
|
|
|
|
|
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>
2024-11-22 05:06:41 +01:00
|
|
|
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"
|
2021-07-24 18:03:58 +02:00
|
|
|
"code.gitea.io/gitea/modules/json"
|
2019-04-12 09:50:21 +02:00
|
|
|
"code.gitea.io/gitea/modules/setting"
|
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>
2024-11-22 05:06:41 +01:00
|
|
|
api "code.gitea.io/gitea/modules/structs"
|
2024-10-02 02:03:19 +02:00
|
|
|
oauth2_provider "code.gitea.io/gitea/services/oauth2_provider"
|
2022-09-02 21:18:23 +02:00
|
|
|
"code.gitea.io/gitea/tests"
|
2019-04-12 09:50:21 +02:00
|
|
|
|
2019-03-08 17:42:50 +01:00
|
|
|
"github.com/stretchr/testify/assert"
|
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>
2024-11-22 05:06:41 +01:00
|
|
|
"github.com/stretchr/testify/require"
|
2019-03-08 17:42:50 +01:00
|
|
|
)
|
|
|
|
|
2022-10-12 06:22:43 +02:00
|
|
|
func TestAuthorizeNoClientID(t *testing.T) {
|
2022-09-02 21:18:23 +02:00
|
|
|
defer tests.PrepareTestEnv(t)()
|
2019-03-08 17:42:50 +01:00
|
|
|
req := NewRequest(t, "GET", "/login/oauth/authorize")
|
|
|
|
ctx := loginUser(t, "user2")
|
2022-10-12 06:22:43 +02:00
|
|
|
resp := ctx.MakeRequest(t, req, http.StatusBadRequest)
|
|
|
|
assert.Contains(t, resp.Body.String(), "Client ID not registered")
|
|
|
|
}
|
|
|
|
|
|
|
|
func TestAuthorizeUnregisteredRedirect(t *testing.T) {
|
|
|
|
defer tests.PrepareTestEnv(t)()
|
|
|
|
req := NewRequest(t, "GET", "/login/oauth/authorize?client_id=da7da3ba-9a13-4167-856f-3899de0b0138&redirect_uri=UNREGISTERED&response_type=code&state=thestate")
|
|
|
|
ctx := loginUser(t, "user1")
|
|
|
|
resp := ctx.MakeRequest(t, req, http.StatusBadRequest)
|
|
|
|
assert.Contains(t, resp.Body.String(), "Unregistered Redirect URI")
|
|
|
|
}
|
|
|
|
|
|
|
|
func TestAuthorizeUnsupportedResponseType(t *testing.T) {
|
|
|
|
defer tests.PrepareTestEnv(t)()
|
|
|
|
req := NewRequest(t, "GET", "/login/oauth/authorize?client_id=da7da3ba-9a13-4167-856f-3899de0b0138&redirect_uri=a&response_type=UNEXPECTED&state=thestate")
|
|
|
|
ctx := loginUser(t, "user1")
|
|
|
|
resp := ctx.MakeRequest(t, req, http.StatusSeeOther)
|
|
|
|
u, err := resp.Result().Location()
|
|
|
|
assert.NoError(t, err)
|
|
|
|
assert.Equal(t, "unsupported_response_type", u.Query().Get("error"))
|
|
|
|
assert.Equal(t, "Only code response type is supported.", u.Query().Get("error_description"))
|
|
|
|
}
|
|
|
|
|
|
|
|
func TestAuthorizeUnsupportedCodeChallengeMethod(t *testing.T) {
|
|
|
|
defer tests.PrepareTestEnv(t)()
|
|
|
|
req := NewRequest(t, "GET", "/login/oauth/authorize?client_id=da7da3ba-9a13-4167-856f-3899de0b0138&redirect_uri=a&response_type=code&state=thestate&code_challenge_method=UNEXPECTED")
|
|
|
|
ctx := loginUser(t, "user1")
|
|
|
|
resp := ctx.MakeRequest(t, req, http.StatusSeeOther)
|
|
|
|
u, err := resp.Result().Location()
|
|
|
|
assert.NoError(t, err)
|
|
|
|
assert.Equal(t, "invalid_request", u.Query().Get("error"))
|
|
|
|
assert.Equal(t, "unsupported code challenge method", u.Query().Get("error_description"))
|
2019-03-08 17:42:50 +01:00
|
|
|
}
|
|
|
|
|
2022-10-12 06:22:43 +02:00
|
|
|
func TestAuthorizeLoginRedirect(t *testing.T) {
|
2022-09-02 21:18:23 +02:00
|
|
|
defer tests.PrepareTestEnv(t)()
|
2019-03-08 17:42:50 +01:00
|
|
|
req := NewRequest(t, "GET", "/login/oauth/authorize")
|
2022-03-23 05:54:07 +01:00
|
|
|
assert.Contains(t, MakeRequest(t, req, http.StatusSeeOther).Body.String(), "/user/login")
|
2019-03-08 17:42:50 +01:00
|
|
|
}
|
|
|
|
|
2022-10-12 06:22:43 +02:00
|
|
|
func TestAuthorizeShow(t *testing.T) {
|
2022-09-02 21:18:23 +02:00
|
|
|
defer tests.PrepareTestEnv(t)()
|
2022-10-12 06:22:43 +02:00
|
|
|
req := NewRequest(t, "GET", "/login/oauth/authorize?client_id=da7da3ba-9a13-4167-856f-3899de0b0138&redirect_uri=a&response_type=code&state=thestate")
|
2019-03-08 17:42:50 +01:00
|
|
|
ctx := loginUser(t, "user4")
|
2022-03-23 05:54:07 +01:00
|
|
|
resp := ctx.MakeRequest(t, req, http.StatusOK)
|
2019-03-08 17:42:50 +01:00
|
|
|
|
|
|
|
htmlDoc := NewHTMLParser(t, resp.Body)
|
|
|
|
htmlDoc.AssertElement(t, "#authorize-app", true)
|
|
|
|
htmlDoc.GetCSRF()
|
|
|
|
}
|
|
|
|
|
2022-10-12 06:22:43 +02:00
|
|
|
func TestAuthorizeRedirectWithExistingGrant(t *testing.T) {
|
2022-09-02 21:18:23 +02:00
|
|
|
defer tests.PrepareTestEnv(t)()
|
2022-10-12 06:22:43 +02:00
|
|
|
req := NewRequest(t, "GET", "/login/oauth/authorize?client_id=da7da3ba-9a13-4167-856f-3899de0b0138&redirect_uri=https%3A%2F%2Fexample.com%2Fxyzzy&response_type=code&state=thestate")
|
2019-03-08 17:42:50 +01:00
|
|
|
ctx := loginUser(t, "user1")
|
2022-03-23 05:54:07 +01:00
|
|
|
resp := ctx.MakeRequest(t, req, http.StatusSeeOther)
|
2019-03-08 17:42:50 +01:00
|
|
|
u, err := resp.Result().Location()
|
|
|
|
assert.NoError(t, err)
|
|
|
|
assert.Equal(t, "thestate", u.Query().Get("state"))
|
|
|
|
assert.Truef(t, len(u.Query().Get("code")) > 30, "authorization code '%s' should be longer then 30", u.Query().Get("code"))
|
2022-10-12 06:22:43 +02:00
|
|
|
u.RawQuery = ""
|
|
|
|
assert.Equal(t, "https://example.com/xyzzy", u.String())
|
2019-03-08 17:42:50 +01:00
|
|
|
}
|
|
|
|
|
2022-10-24 09:59:24 +02:00
|
|
|
func TestAuthorizePKCERequiredForPublicClient(t *testing.T) {
|
|
|
|
defer tests.PrepareTestEnv(t)()
|
|
|
|
req := NewRequest(t, "GET", "/login/oauth/authorize?client_id=ce5a1322-42a7-11ed-b878-0242ac120002&redirect_uri=http%3A%2F%2F127.0.0.1&response_type=code&state=thestate")
|
|
|
|
ctx := loginUser(t, "user1")
|
|
|
|
resp := ctx.MakeRequest(t, req, http.StatusSeeOther)
|
|
|
|
u, err := resp.Result().Location()
|
|
|
|
assert.NoError(t, err)
|
|
|
|
assert.Equal(t, "invalid_request", u.Query().Get("error"))
|
|
|
|
assert.Equal(t, "PKCE is required for public clients", u.Query().Get("error_description"))
|
|
|
|
}
|
|
|
|
|
2019-03-08 17:42:50 +01:00
|
|
|
func TestAccessTokenExchange(t *testing.T) {
|
2022-09-02 21:18:23 +02:00
|
|
|
defer tests.PrepareTestEnv(t)()
|
2019-03-08 17:42:50 +01:00
|
|
|
req := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
|
|
|
|
"grant_type": "authorization_code",
|
|
|
|
"client_id": "da7da3ba-9a13-4167-856f-3899de0b0138",
|
|
|
|
"client_secret": "4MK8Na6R55smdCY0WuCCumZ6hjRPnGY5saWVRHHjJiA=",
|
|
|
|
"redirect_uri": "a",
|
|
|
|
"code": "authcode",
|
2022-10-12 06:22:43 +02:00
|
|
|
"code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt",
|
2019-03-08 17:42:50 +01:00
|
|
|
})
|
2022-03-23 05:54:07 +01:00
|
|
|
resp := MakeRequest(t, req, http.StatusOK)
|
2019-03-08 17:42:50 +01:00
|
|
|
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)
|
2021-03-01 22:08:10 +01:00
|
|
|
|
2019-03-08 17:42:50 +01:00
|
|
|
assert.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsed))
|
2023-06-03 05:59:28 +02:00
|
|
|
assert.True(t, len(parsed.AccessToken) > 10)
|
|
|
|
assert.True(t, len(parsed.RefreshToken) > 10)
|
|
|
|
}
|
|
|
|
|
|
|
|
func TestAccessTokenExchangeWithPublicClient(t *testing.T) {
|
|
|
|
defer tests.PrepareTestEnv(t)()
|
|
|
|
req := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
|
|
|
|
"grant_type": "authorization_code",
|
|
|
|
"client_id": "ce5a1322-42a7-11ed-b878-0242ac120002",
|
|
|
|
"redirect_uri": "http://127.0.0.1",
|
|
|
|
"code": "authcodepublic",
|
|
|
|
"code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt",
|
|
|
|
})
|
|
|
|
resp := MakeRequest(t, req, 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)
|
|
|
|
|
|
|
|
assert.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsed))
|
2019-03-08 17:42:50 +01:00
|
|
|
assert.True(t, len(parsed.AccessToken) > 10)
|
|
|
|
assert.True(t, len(parsed.RefreshToken) > 10)
|
|
|
|
}
|
|
|
|
|
2022-10-12 06:22:43 +02:00
|
|
|
func TestAccessTokenExchangeJSON(t *testing.T) {
|
2022-09-02 21:18:23 +02:00
|
|
|
defer tests.PrepareTestEnv(t)()
|
2019-04-15 17:54:50 +02:00
|
|
|
req := NewRequestWithJSON(t, "POST", "/login/oauth/access_token", map[string]string{
|
|
|
|
"grant_type": "authorization_code",
|
|
|
|
"client_id": "da7da3ba-9a13-4167-856f-3899de0b0138",
|
|
|
|
"client_secret": "4MK8Na6R55smdCY0WuCCumZ6hjRPnGY5saWVRHHjJiA=",
|
|
|
|
"redirect_uri": "a",
|
|
|
|
"code": "authcode",
|
2022-10-12 06:22:43 +02:00
|
|
|
"code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt",
|
2019-04-15 17:54:50 +02:00
|
|
|
})
|
2022-03-23 05:54:07 +01:00
|
|
|
resp := MakeRequest(t, req, http.StatusOK)
|
2019-04-15 17:54:50 +02:00
|
|
|
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)
|
2021-03-01 22:08:10 +01:00
|
|
|
|
2019-04-15 17:54:50 +02:00
|
|
|
assert.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsed))
|
|
|
|
assert.True(t, len(parsed.AccessToken) > 10)
|
|
|
|
assert.True(t, len(parsed.RefreshToken) > 10)
|
|
|
|
}
|
|
|
|
|
2022-10-12 06:22:43 +02:00
|
|
|
func TestAccessTokenExchangeWithoutPKCE(t *testing.T) {
|
2022-09-02 21:18:23 +02:00
|
|
|
defer tests.PrepareTestEnv(t)()
|
2022-10-12 06:22:43 +02:00
|
|
|
req := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
|
2019-03-08 17:42:50 +01:00
|
|
|
"grant_type": "authorization_code",
|
|
|
|
"client_id": "da7da3ba-9a13-4167-856f-3899de0b0138",
|
|
|
|
"client_secret": "4MK8Na6R55smdCY0WuCCumZ6hjRPnGY5saWVRHHjJiA=",
|
|
|
|
"redirect_uri": "a",
|
|
|
|
"code": "authcode",
|
|
|
|
})
|
2022-10-12 06:22:43 +02:00
|
|
|
resp := MakeRequest(t, req, http.StatusBadRequest)
|
2024-10-02 02:03:19 +02:00
|
|
|
parsedError := new(oauth2_provider.AccessTokenError)
|
2022-10-12 06:22:43 +02:00
|
|
|
assert.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError))
|
|
|
|
assert.Equal(t, "unauthorized_client", string(parsedError.ErrorCode))
|
|
|
|
assert.Equal(t, "failed PKCE code challenge", parsedError.ErrorDescription)
|
2019-03-08 17:42:50 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
func TestAccessTokenExchangeWithInvalidCredentials(t *testing.T) {
|
2022-09-02 21:18:23 +02:00
|
|
|
defer tests.PrepareTestEnv(t)()
|
2019-03-08 17:42:50 +01:00
|
|
|
// invalid client id
|
|
|
|
req := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
|
|
|
|
"grant_type": "authorization_code",
|
|
|
|
"client_id": "???",
|
|
|
|
"client_secret": "4MK8Na6R55smdCY0WuCCumZ6hjRPnGY5saWVRHHjJiA=",
|
|
|
|
"redirect_uri": "a",
|
|
|
|
"code": "authcode",
|
2022-10-12 06:22:43 +02:00
|
|
|
"code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt",
|
2019-03-08 17:42:50 +01:00
|
|
|
})
|
2022-10-12 06:22:43 +02:00
|
|
|
resp := MakeRequest(t, req, http.StatusBadRequest)
|
2024-10-02 02:03:19 +02:00
|
|
|
parsedError := new(oauth2_provider.AccessTokenError)
|
2022-10-12 06:22:43 +02:00
|
|
|
assert.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError))
|
|
|
|
assert.Equal(t, "invalid_client", string(parsedError.ErrorCode))
|
|
|
|
assert.Equal(t, "cannot load client with client id: '???'", parsedError.ErrorDescription)
|
|
|
|
|
2019-03-08 17:42:50 +01:00
|
|
|
// invalid client secret
|
|
|
|
req = NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
|
|
|
|
"grant_type": "authorization_code",
|
|
|
|
"client_id": "da7da3ba-9a13-4167-856f-3899de0b0138",
|
|
|
|
"client_secret": "???",
|
|
|
|
"redirect_uri": "a",
|
|
|
|
"code": "authcode",
|
2022-10-12 06:22:43 +02:00
|
|
|
"code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt",
|
2019-03-08 17:42:50 +01:00
|
|
|
})
|
2022-10-12 06:22:43 +02:00
|
|
|
resp = MakeRequest(t, req, http.StatusBadRequest)
|
2024-10-02 02:03:19 +02:00
|
|
|
parsedError = new(oauth2_provider.AccessTokenError)
|
2022-10-12 06:22:43 +02:00
|
|
|
assert.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError))
|
|
|
|
assert.Equal(t, "unauthorized_client", string(parsedError.ErrorCode))
|
|
|
|
assert.Equal(t, "invalid client secret", parsedError.ErrorDescription)
|
|
|
|
|
2019-03-08 17:42:50 +01:00
|
|
|
// invalid redirect uri
|
|
|
|
req = NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
|
|
|
|
"grant_type": "authorization_code",
|
|
|
|
"client_id": "da7da3ba-9a13-4167-856f-3899de0b0138",
|
|
|
|
"client_secret": "4MK8Na6R55smdCY0WuCCumZ6hjRPnGY5saWVRHHjJiA=",
|
|
|
|
"redirect_uri": "???",
|
|
|
|
"code": "authcode",
|
2022-10-12 06:22:43 +02:00
|
|
|
"code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt",
|
2019-03-08 17:42:50 +01:00
|
|
|
})
|
2022-10-12 06:22:43 +02:00
|
|
|
resp = MakeRequest(t, req, http.StatusBadRequest)
|
2024-10-02 02:03:19 +02:00
|
|
|
parsedError = new(oauth2_provider.AccessTokenError)
|
2022-10-12 06:22:43 +02:00
|
|
|
assert.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError))
|
|
|
|
assert.Equal(t, "unauthorized_client", string(parsedError.ErrorCode))
|
|
|
|
assert.Equal(t, "unexpected redirect URI", parsedError.ErrorDescription)
|
|
|
|
|
2019-03-08 17:42:50 +01:00
|
|
|
// invalid authorization code
|
|
|
|
req = NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
|
|
|
|
"grant_type": "authorization_code",
|
|
|
|
"client_id": "da7da3ba-9a13-4167-856f-3899de0b0138",
|
|
|
|
"client_secret": "4MK8Na6R55smdCY0WuCCumZ6hjRPnGY5saWVRHHjJiA=",
|
|
|
|
"redirect_uri": "a",
|
|
|
|
"code": "???",
|
2022-10-12 06:22:43 +02:00
|
|
|
"code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt",
|
2019-03-08 17:42:50 +01:00
|
|
|
})
|
2022-10-12 06:22:43 +02:00
|
|
|
resp = MakeRequest(t, req, http.StatusBadRequest)
|
2024-10-02 02:03:19 +02:00
|
|
|
parsedError = new(oauth2_provider.AccessTokenError)
|
2022-10-12 06:22:43 +02:00
|
|
|
assert.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError))
|
|
|
|
assert.Equal(t, "unauthorized_client", string(parsedError.ErrorCode))
|
|
|
|
assert.Equal(t, "client is not authorized", parsedError.ErrorDescription)
|
|
|
|
|
2019-03-08 17:42:50 +01:00
|
|
|
// invalid grant_type
|
|
|
|
req = NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
|
|
|
|
"grant_type": "???",
|
|
|
|
"client_id": "da7da3ba-9a13-4167-856f-3899de0b0138",
|
|
|
|
"client_secret": "4MK8Na6R55smdCY0WuCCumZ6hjRPnGY5saWVRHHjJiA=",
|
|
|
|
"redirect_uri": "a",
|
|
|
|
"code": "authcode",
|
2022-10-12 06:22:43 +02:00
|
|
|
"code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt",
|
2019-03-08 17:42:50 +01:00
|
|
|
})
|
2022-10-12 06:22:43 +02:00
|
|
|
resp = MakeRequest(t, req, http.StatusBadRequest)
|
2024-10-02 02:03:19 +02:00
|
|
|
parsedError = new(oauth2_provider.AccessTokenError)
|
2022-10-12 06:22:43 +02:00
|
|
|
assert.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError))
|
|
|
|
assert.Equal(t, "unsupported_grant_type", string(parsedError.ErrorCode))
|
|
|
|
assert.Equal(t, "Only refresh_token or authorization_code grant type is supported", parsedError.ErrorDescription)
|
2019-03-08 17:42:50 +01:00
|
|
|
}
|
2019-03-11 03:54:59 +01:00
|
|
|
|
|
|
|
func TestAccessTokenExchangeWithBasicAuth(t *testing.T) {
|
2022-09-02 21:18:23 +02:00
|
|
|
defer tests.PrepareTestEnv(t)()
|
2019-03-11 03:54:59 +01:00
|
|
|
req := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
|
|
|
|
"grant_type": "authorization_code",
|
|
|
|
"redirect_uri": "a",
|
|
|
|
"code": "authcode",
|
2022-10-12 06:22:43 +02:00
|
|
|
"code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt",
|
2019-03-11 03:54:59 +01:00
|
|
|
})
|
|
|
|
req.Header.Add("Authorization", "Basic ZGE3ZGEzYmEtOWExMy00MTY3LTg1NmYtMzg5OWRlMGIwMTM4OjRNSzhOYTZSNTVzbWRDWTBXdUNDdW1aNmhqUlBuR1k1c2FXVlJISGpKaUE9")
|
2022-03-23 05:54:07 +01:00
|
|
|
resp := MakeRequest(t, req, http.StatusOK)
|
2019-03-11 03:54:59 +01:00
|
|
|
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)
|
2021-03-01 22:08:10 +01:00
|
|
|
|
2019-03-11 03:54:59 +01:00
|
|
|
assert.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsed))
|
|
|
|
assert.True(t, len(parsed.AccessToken) > 10)
|
|
|
|
assert.True(t, len(parsed.RefreshToken) > 10)
|
|
|
|
|
|
|
|
// use wrong client_secret
|
|
|
|
req = NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
|
|
|
|
"grant_type": "authorization_code",
|
|
|
|
"redirect_uri": "a",
|
|
|
|
"code": "authcode",
|
2022-10-12 06:22:43 +02:00
|
|
|
"code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt",
|
2019-03-11 03:54:59 +01:00
|
|
|
})
|
|
|
|
req.Header.Add("Authorization", "Basic ZGE3ZGEzYmEtOWExMy00MTY3LTg1NmYtMzg5OWRlMGIwMTM4OmJsYWJsYQ==")
|
2022-10-12 06:22:43 +02:00
|
|
|
resp = MakeRequest(t, req, http.StatusBadRequest)
|
2024-10-02 02:03:19 +02:00
|
|
|
parsedError := new(oauth2_provider.AccessTokenError)
|
2022-10-12 06:22:43 +02:00
|
|
|
assert.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError))
|
|
|
|
assert.Equal(t, "unauthorized_client", string(parsedError.ErrorCode))
|
|
|
|
assert.Equal(t, "invalid client secret", parsedError.ErrorDescription)
|
2019-03-11 03:54:59 +01:00
|
|
|
|
|
|
|
// missing header
|
|
|
|
req = NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
|
|
|
|
"grant_type": "authorization_code",
|
|
|
|
"redirect_uri": "a",
|
|
|
|
"code": "authcode",
|
2022-10-12 06:22:43 +02:00
|
|
|
"code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt",
|
|
|
|
})
|
|
|
|
resp = MakeRequest(t, req, http.StatusBadRequest)
|
2024-10-02 02:03:19 +02:00
|
|
|
parsedError = new(oauth2_provider.AccessTokenError)
|
2022-10-12 06:22:43 +02:00
|
|
|
assert.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError))
|
|
|
|
assert.Equal(t, "invalid_client", string(parsedError.ErrorCode))
|
|
|
|
assert.Equal(t, "cannot load client with client id: ''", parsedError.ErrorDescription)
|
|
|
|
|
|
|
|
// client_id inconsistent with Authorization header
|
|
|
|
req = NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
|
|
|
|
"grant_type": "authorization_code",
|
|
|
|
"redirect_uri": "a",
|
|
|
|
"code": "authcode",
|
|
|
|
"client_id": "inconsistent",
|
|
|
|
})
|
|
|
|
req.Header.Add("Authorization", "Basic ZGE3ZGEzYmEtOWExMy00MTY3LTg1NmYtMzg5OWRlMGIwMTM4OjRNSzhOYTZSNTVzbWRDWTBXdUNDdW1aNmhqUlBuR1k1c2FXVlJISGpKaUE9")
|
|
|
|
resp = MakeRequest(t, req, http.StatusBadRequest)
|
2024-10-02 02:03:19 +02:00
|
|
|
parsedError = new(oauth2_provider.AccessTokenError)
|
2022-10-12 06:22:43 +02:00
|
|
|
assert.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError))
|
|
|
|
assert.Equal(t, "invalid_request", string(parsedError.ErrorCode))
|
|
|
|
assert.Equal(t, "client_id in request body inconsistent with Authorization header", parsedError.ErrorDescription)
|
|
|
|
|
|
|
|
// client_secret inconsistent with Authorization header
|
|
|
|
req = NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
|
|
|
|
"grant_type": "authorization_code",
|
|
|
|
"redirect_uri": "a",
|
|
|
|
"code": "authcode",
|
|
|
|
"client_secret": "inconsistent",
|
2019-03-11 03:54:59 +01:00
|
|
|
})
|
2022-10-12 06:22:43 +02:00
|
|
|
req.Header.Add("Authorization", "Basic ZGE3ZGEzYmEtOWExMy00MTY3LTg1NmYtMzg5OWRlMGIwMTM4OjRNSzhOYTZSNTVzbWRDWTBXdUNDdW1aNmhqUlBuR1k1c2FXVlJISGpKaUE9")
|
2022-10-23 07:28:46 +02:00
|
|
|
resp = MakeRequest(t, req, http.StatusBadRequest)
|
2024-10-02 02:03:19 +02:00
|
|
|
parsedError = new(oauth2_provider.AccessTokenError)
|
2022-10-12 06:22:43 +02:00
|
|
|
assert.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError))
|
|
|
|
assert.Equal(t, "invalid_request", string(parsedError.ErrorCode))
|
2022-10-23 07:28:46 +02:00
|
|
|
assert.Equal(t, "client_secret in request body inconsistent with Authorization header", parsedError.ErrorDescription)
|
2019-03-11 03:54:59 +01:00
|
|
|
}
|
2019-04-12 09:50:21 +02:00
|
|
|
|
|
|
|
func TestRefreshTokenInvalidation(t *testing.T) {
|
2022-09-02 21:18:23 +02:00
|
|
|
defer tests.PrepareTestEnv(t)()
|
2019-04-12 09:50:21 +02:00
|
|
|
req := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
|
|
|
|
"grant_type": "authorization_code",
|
|
|
|
"client_id": "da7da3ba-9a13-4167-856f-3899de0b0138",
|
|
|
|
"client_secret": "4MK8Na6R55smdCY0WuCCumZ6hjRPnGY5saWVRHHjJiA=",
|
|
|
|
"redirect_uri": "a",
|
|
|
|
"code": "authcode",
|
2022-10-12 06:22:43 +02:00
|
|
|
"code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt",
|
2019-04-12 09:50:21 +02:00
|
|
|
})
|
2022-03-23 05:54:07 +01:00
|
|
|
resp := MakeRequest(t, req, http.StatusOK)
|
2019-04-12 09:50:21 +02:00
|
|
|
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)
|
2021-03-01 22:08:10 +01:00
|
|
|
|
2019-04-12 09:50:21 +02:00
|
|
|
assert.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsed))
|
|
|
|
|
|
|
|
// test without invalidation
|
|
|
|
setting.OAuth2.InvalidateRefreshTokens = false
|
|
|
|
|
2022-10-23 07:28:46 +02:00
|
|
|
req = NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
|
|
|
|
"grant_type": "refresh_token",
|
|
|
|
"client_id": "da7da3ba-9a13-4167-856f-3899de0b0138",
|
|
|
|
// omit secret
|
|
|
|
"redirect_uri": "a",
|
|
|
|
"refresh_token": parsed.RefreshToken,
|
|
|
|
})
|
|
|
|
resp = MakeRequest(t, req, http.StatusBadRequest)
|
2024-10-02 02:03:19 +02:00
|
|
|
parsedError := new(oauth2_provider.AccessTokenError)
|
2022-10-23 07:28:46 +02:00
|
|
|
assert.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError))
|
|
|
|
assert.Equal(t, "invalid_client", string(parsedError.ErrorCode))
|
|
|
|
assert.Equal(t, "invalid empty client secret", parsedError.ErrorDescription)
|
|
|
|
|
|
|
|
req = NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
|
|
|
|
"grant_type": "refresh_token",
|
|
|
|
"client_id": "da7da3ba-9a13-4167-856f-3899de0b0138",
|
|
|
|
"client_secret": "4MK8Na6R55smdCY0WuCCumZ6hjRPnGY5saWVRHHjJiA=",
|
|
|
|
"redirect_uri": "a",
|
|
|
|
"refresh_token": "UNEXPECTED",
|
|
|
|
})
|
|
|
|
resp = MakeRequest(t, req, http.StatusBadRequest)
|
2024-10-02 02:03:19 +02:00
|
|
|
parsedError = new(oauth2_provider.AccessTokenError)
|
2022-10-23 07:28:46 +02:00
|
|
|
assert.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError))
|
|
|
|
assert.Equal(t, "unauthorized_client", string(parsedError.ErrorCode))
|
|
|
|
assert.Equal(t, "unable to parse refresh token", parsedError.ErrorDescription)
|
|
|
|
|
|
|
|
req = NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
|
2019-04-12 09:50:21 +02:00
|
|
|
"grant_type": "refresh_token",
|
|
|
|
"client_id": "da7da3ba-9a13-4167-856f-3899de0b0138",
|
|
|
|
"client_secret": "4MK8Na6R55smdCY0WuCCumZ6hjRPnGY5saWVRHHjJiA=",
|
|
|
|
"redirect_uri": "a",
|
|
|
|
"refresh_token": parsed.RefreshToken,
|
|
|
|
})
|
2021-01-29 16:35:30 +01:00
|
|
|
|
2022-10-23 07:28:46 +02:00
|
|
|
bs, err := io.ReadAll(req.Body)
|
2020-11-13 13:51:07 +01:00
|
|
|
assert.NoError(t, err)
|
|
|
|
|
2022-10-23 07:28:46 +02:00
|
|
|
req.Body = io.NopCloser(bytes.NewReader(bs))
|
|
|
|
MakeRequest(t, req, http.StatusOK)
|
2020-11-13 13:51:07 +01:00
|
|
|
|
2022-10-23 07:28:46 +02:00
|
|
|
req.Body = io.NopCloser(bytes.NewReader(bs))
|
|
|
|
MakeRequest(t, req, http.StatusOK)
|
2019-04-12 09:50:21 +02:00
|
|
|
|
|
|
|
// test with invalidation
|
|
|
|
setting.OAuth2.InvalidateRefreshTokens = true
|
2022-10-23 07:28:46 +02:00
|
|
|
req.Body = io.NopCloser(bytes.NewReader(bs))
|
|
|
|
MakeRequest(t, req, http.StatusOK)
|
2020-11-13 13:51:07 +01:00
|
|
|
|
2022-10-12 06:22:43 +02:00
|
|
|
// repeat request should fail
|
2022-10-23 07:28:46 +02:00
|
|
|
req.Body = io.NopCloser(bytes.NewReader(bs))
|
|
|
|
resp = MakeRequest(t, req, http.StatusBadRequest)
|
2024-10-02 02:03:19 +02:00
|
|
|
parsedError = new(oauth2_provider.AccessTokenError)
|
2022-10-12 06:22:43 +02:00
|
|
|
assert.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError))
|
|
|
|
assert.Equal(t, "unauthorized_client", string(parsedError.ErrorCode))
|
|
|
|
assert.Equal(t, "token was already used", parsedError.ErrorDescription)
|
2019-04-12 09:50:21 +02:00
|
|
|
}
|
2024-07-23 14:43:03 +02:00
|
|
|
|
|
|
|
func TestOAuthIntrospection(t *testing.T) {
|
|
|
|
defer tests.PrepareTestEnv(t)()
|
|
|
|
req := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
|
|
|
|
"grant_type": "authorization_code",
|
|
|
|
"client_id": "da7da3ba-9a13-4167-856f-3899de0b0138",
|
|
|
|
"client_secret": "4MK8Na6R55smdCY0WuCCumZ6hjRPnGY5saWVRHHjJiA=",
|
|
|
|
"redirect_uri": "a",
|
|
|
|
"code": "authcode",
|
|
|
|
"code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt",
|
|
|
|
})
|
|
|
|
resp := MakeRequest(t, req, 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)
|
|
|
|
|
|
|
|
assert.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsed))
|
|
|
|
assert.True(t, len(parsed.AccessToken) > 10)
|
|
|
|
assert.True(t, len(parsed.RefreshToken) > 10)
|
|
|
|
|
|
|
|
// successful request with a valid client_id/client_secret and a valid token
|
|
|
|
req = NewRequestWithValues(t, "POST", "/login/oauth/introspect", map[string]string{
|
|
|
|
"token": parsed.AccessToken,
|
|
|
|
})
|
|
|
|
req.Header.Add("Authorization", "Basic ZGE3ZGEzYmEtOWExMy00MTY3LTg1NmYtMzg5OWRlMGIwMTM4OjRNSzhOYTZSNTVzbWRDWTBXdUNDdW1aNmhqUlBuR1k1c2FXVlJISGpKaUE9")
|
|
|
|
resp = MakeRequest(t, req, http.StatusOK)
|
|
|
|
type introspectResponse struct {
|
2024-07-25 14:36:05 +02:00
|
|
|
Active bool `json:"active"`
|
|
|
|
Scope string `json:"scope,omitempty"`
|
|
|
|
Username string `json:"username"`
|
2024-07-23 14:43:03 +02:00
|
|
|
}
|
|
|
|
introspectParsed := new(introspectResponse)
|
|
|
|
assert.NoError(t, json.Unmarshal(resp.Body.Bytes(), introspectParsed))
|
|
|
|
assert.True(t, introspectParsed.Active)
|
2024-07-25 14:36:05 +02:00
|
|
|
assert.Equal(t, "user1", introspectParsed.Username)
|
2024-07-23 14:43:03 +02:00
|
|
|
|
|
|
|
// successful request with a valid client_id/client_secret, but an invalid token
|
|
|
|
req = NewRequestWithValues(t, "POST", "/login/oauth/introspect", map[string]string{
|
|
|
|
"token": "xyzzy",
|
|
|
|
})
|
|
|
|
req.Header.Add("Authorization", "Basic ZGE3ZGEzYmEtOWExMy00MTY3LTg1NmYtMzg5OWRlMGIwMTM4OjRNSzhOYTZSNTVzbWRDWTBXdUNDdW1aNmhqUlBuR1k1c2FXVlJISGpKaUE9")
|
|
|
|
resp = MakeRequest(t, req, http.StatusOK)
|
|
|
|
introspectParsed = new(introspectResponse)
|
|
|
|
assert.NoError(t, json.Unmarshal(resp.Body.Bytes(), introspectParsed))
|
|
|
|
assert.False(t, introspectParsed.Active)
|
|
|
|
|
|
|
|
// unsuccessful request with an invalid client_id/client_secret
|
|
|
|
req = NewRequestWithValues(t, "POST", "/login/oauth/introspect", map[string]string{
|
|
|
|
"token": parsed.AccessToken,
|
|
|
|
})
|
|
|
|
req.Header.Add("Authorization", "Basic ZGE3ZGEzYmEtOWExMy00MTY3LTg1NmYtMzg5OWRlMGIwMTM4OjRNSzhOYTZSNTVzbWRDWTBXdUNDdW1aNmhqUlBuR1k1c2FXVlJISGpK")
|
|
|
|
resp = MakeRequest(t, req, http.StatusUnauthorized)
|
|
|
|
assert.Contains(t, resp.Body.String(), "no valid authorization")
|
|
|
|
}
|
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>
2024-11-22 05:06:41 +01:00
|
|
|
|
|
|
|
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))
|
2024-11-26 03:03:02 +01:00
|
|
|
assert.Contains(t, errorParsed.Message, "token does not have at least one of required scope(s), required=[read:repository]")
|
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>
2024-11-22 05:06:41 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
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))
|
2024-11-26 03:03:02 +01:00
|
|
|
assert.Contains(t, errorParsed.Message, "token does not have at least one of required scope(s), required=[read:user read:organization]")
|
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>
2024-11-22 05:06:41 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
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)
|
|
|
|
}
|
|
|
|
}
|