feat: read, create, & delete repo-specific access tokens via API (#11504)

This PR is part of a series (#11311).

Adds support for reading and creating repo-secific access tokens through the API via the `GET /users/{username}/tokens`, `POST /users/{username}/tokens`, and `DELETE /users/{username}/tokens/{id}` APIs.

Validation rules are included to [restrict repo-specific access tokens to specific scopes](https://codeberg.org/forgejo/design/issues/50#issuecomment-11093951).

## Checklist

The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org).

### Tests for Go changes

(can be removed for JavaScript changes)

- I added test coverage for Go changes...
  - [x] in their respective `*_test.go` for unit tests.
  - [x] in the `tests/integration` directory if it involves interactions with a live Forgejo server.
- I ran...
  - [x] `make pr-go` before pushing

### Documentation

- [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change.
- [x] I did not document these changes and I do not expect someone else to do it.

### Release notes

- [x] This change will be noticed by a Forgejo user or admin (feature, bug fix, performance, etc.). I suggest to include a release note for this change.
- [ ] This change is not visible to a Forgejo user or admin (refactor, dependency upgrade, etc.). I think there is no need to add a release note for this change.

<!--start release-notes-assistant-->

## Release notes
<!--URL:https://codeberg.org/forgejo/forgejo-->
- Features
  - [PR](https://codeberg.org/forgejo/forgejo/pulls/11504): <!--number 11504 --><!--line 0 --><!--description cmVhZCwgY3JlYXRlLCAmIGRlbGV0ZSByZXBvLXNwZWNpZmljIGFjY2VzcyB0b2tlbnMgdmlhIEFQSQ==-->read, create, & delete repo-specific access tokens via API<!--description-->
<!--end release-notes-assistant-->

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/11504
Reviewed-by: Andreas Ahlenstorf <aahlenst@noreply.codeberg.org>
Co-authored-by: Mathieu Fenniak <mathieu@fenniak.net>
Co-committed-by: Mathieu Fenniak <mathieu@fenniak.net>
This commit is contained in:
Mathieu Fenniak
2026-03-07 21:55:08 +01:00
committed by Mathieu Fenniak
parent 48d2af5561
commit 2db6210f69
12 changed files with 615 additions and 27 deletions
@@ -0,0 +1,17 @@
- token_id: 3
repo_id: 1
created_unix: 1772158384
- token_id: 3
repo_id: 2
created_unix: 1772158384
- token_id: 3
repo_id: 3
created_unix: 1772158384
- token_id: 2
repo_id: 1
created_unix: 1772158384
# (no repo 2 for token 2)
- token_id: 2
repo_id: 3
created_unix: 1772158384
+16 -8
View File
@@ -224,15 +224,23 @@ func (opts ListAccessTokensOptions) ToOrders() string {
// DeleteAccessTokenByID deletes access token by given ID.
func DeleteAccessTokenByID(ctx context.Context, id, userID int64) error {
cnt, err := db.GetEngine(ctx).ID(id).Delete(&AccessToken{
UID: userID,
return db.WithTx(ctx, func(ctx context.Context) error {
if err := db.DeleteBeans(ctx,
&AccessTokenResourceRepo{TokenID: id},
); err != nil {
return fmt.Errorf("DeleteBeans: %w", err)
}
cnt, err := db.GetEngine(ctx).ID(id).Delete(&AccessToken{
UID: userID,
})
if err != nil {
return err
} else if cnt != 1 {
return ErrAccessTokenNotExist{}
}
return nil
})
if err != nil {
return err
} else if cnt != 1 {
return ErrAccessTokenNotExist{}
}
return nil
}
// RegenerateAccessTokenByID regenerates access token by given ID.
+32
View File
@@ -34,3 +34,35 @@ func GetRepositoriesAccessibleWithToken(ctx context.Context, accessTokenID int64
}
return resources, nil
}
func GetRepositoriesAccessibleWithTokens(ctx context.Context, accessTokens []*AccessToken) (map[int64][]*AccessTokenResourceRepo, error) {
accessTokenIDs := make([]int64, len(accessTokens))
for i, at := range accessTokens {
accessTokenIDs[i] = at.ID
}
var resources []*AccessTokenResourceRepo
err := db.GetEngine(ctx).
In("token_id", accessTokenIDs).
Find(&resources)
if err != nil {
return nil, err
}
retval := make(map[int64][]*AccessTokenResourceRepo)
for _, resource := range resources {
retval[resource.TokenID] = append(retval[resource.TokenID], resource)
}
return retval, nil
}
func InsertAccessTokenResourceRepos(ctx context.Context, accessTokenID int64, resources []*AccessTokenResourceRepo) error {
return db.WithTx(ctx, func(ctx context.Context) error {
for _, resourceRepo := range resources {
resourceRepo.TokenID = accessTokenID
if err := db.Insert(ctx, resourceRepo); err != nil {
return err
}
}
return nil
})
}
+85 -3
View File
@@ -7,7 +7,6 @@ import (
"testing"
auth_model "forgejo.org/models/auth"
"forgejo.org/models/db"
"forgejo.org/models/unittest"
"github.com/stretchr/testify/assert"
@@ -19,13 +18,13 @@ func TestGetRepositoriesAccessibleWithToken(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
t.Run("No Resources", func(t *testing.T) {
resources, err := auth_model.GetRepositoriesAccessibleWithToken(db.DefaultContext, 999)
resources, err := auth_model.GetRepositoriesAccessibleWithToken(t.Context(), 999)
require.NoError(t, err)
assert.Empty(t, resources)
})
t.Run("Has Resources", func(t *testing.T) {
resources, err := auth_model.GetRepositoriesAccessibleWithToken(db.DefaultContext, 3)
resources, err := auth_model.GetRepositoriesAccessibleWithToken(t.Context(), 3)
require.NoError(t, err)
require.Len(t, resources, 3)
@@ -39,3 +38,86 @@ func TestGetRepositoriesAccessibleWithToken(t *testing.T) {
assert.Contains(t, repoIDs, int64(3))
})
}
func TestGetRepositoriesAccessibleWithTokens(t *testing.T) {
defer unittest.OverrideFixtures("models/auth/TestGetRepositoriesAccessibleWithTokens")()
require.NoError(t, unittest.PrepareTestDatabase())
token1 := unittest.AssertExistsAndLoadBean(t, &auth_model.AccessToken{ID: 1})
token2 := unittest.AssertExistsAndLoadBean(t, &auth_model.AccessToken{ID: 2})
token3 := unittest.AssertExistsAndLoadBean(t, &auth_model.AccessToken{ID: 3})
t.Run("No Tokens", func(t *testing.T) {
resources, err := auth_model.GetRepositoriesAccessibleWithTokens(t.Context(), []*auth_model.AccessToken{})
require.NoError(t, err)
assert.Empty(t, resources)
})
t.Run("Multiple Access Tokens", func(t *testing.T) {
resources, err := auth_model.GetRepositoriesAccessibleWithTokens(t.Context(), []*auth_model.AccessToken{token1, token2, token3})
require.NoError(t, err)
repos1, ok := resources[token1.ID]
assert.False(t, ok)
assert.Empty(t, repos1)
repos2, ok := resources[token2.ID]
assert.True(t, ok)
assert.Len(t, repos2, 2)
repos3, ok := resources[token3.ID]
assert.True(t, ok)
assert.Len(t, repos3, 3)
})
}
func TestInsertAccessTokenResourceRepos(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
token1 := unittest.AssertExistsAndLoadBean(t, &auth_model.AccessToken{ID: 1})
token2 := unittest.AssertExistsAndLoadBean(t, &auth_model.AccessToken{ID: 2})
token3 := unittest.AssertExistsAndLoadBean(t, &auth_model.AccessToken{ID: 3})
t.Run("blank insert", func(t *testing.T) {
err := auth_model.InsertAccessTokenResourceRepos(t.Context(), token1.ID, nil)
require.NoError(t, err)
})
t.Run("multiple insert", func(t *testing.T) {
resRepo1 := &auth_model.AccessTokenResourceRepo{
TokenID: token2.ID,
RepoID: 1,
}
resRepo3 := &auth_model.AccessTokenResourceRepo{
TokenID: token2.ID,
RepoID: 3,
}
err := auth_model.InsertAccessTokenResourceRepos(t.Context(), token2.ID,
[]*auth_model.AccessTokenResourceRepo{resRepo1, resRepo3})
require.NoError(t, err)
unittest.AssertCount(t, &auth_model.AccessTokenResourceRepo{TokenID: token2.ID}, 2)
})
t.Run("in tx", func(t *testing.T) {
// Pre-condition: count is 0.
unittest.AssertCount(t, &auth_model.AccessTokenResourceRepo{TokenID: token3.ID}, 0)
// Verify that InsertAccessTokenResourceRepos performs inserts in a TX by having a second one with an invalid
// RepoID, causing a foreign key violation
resRepo1 := &auth_model.AccessTokenResourceRepo{
TokenID: token3.ID,
RepoID: 1,
}
resRepo3 := &auth_model.AccessTokenResourceRepo{
TokenID: token3.ID,
RepoID: 30000, // invalid
}
err := auth_model.InsertAccessTokenResourceRepos(t.Context(), token3.ID,
[]*auth_model.AccessTokenResourceRepo{resRepo1, resRepo3})
require.ErrorContains(t, err, "foreign key")
// Count remains 0; the first record was not inserted.
unittest.AssertCount(t, &auth_model.AccessTokenResourceRepo{TokenID: token3.ID}, 0)
})
}
+9
View File
@@ -436,3 +436,12 @@ type UpdateRepoAvatarOption struct {
// image must be base64 encoded
Image string `json:"image" binding:"Required"`
}
type RepoTargetOption struct {
// Name of user or organisation that owns the repository
// required: true
Owner string `json:"owner"`
// Name of repository
// required: true
Name string `json:"name"`
}
+5
View File
@@ -16,6 +16,9 @@ type AccessToken struct {
Token string `json:"sha1"`
TokenLastEight string `json:"token_last_eight"`
Scopes []string `json:"scopes"`
// Indicates that an access token only has access to the specified repositories. Will be null if the access token
// is not limited to a set of specified repositories.
Repositories []*RepositoryMeta `json:"repositories"`
}
// AccessTokenList represents a list of API access token.
@@ -28,6 +31,8 @@ type CreateAccessTokenOption struct {
Name string `json:"name" binding:"Required"`
// example: ["all", "read:activitypub","read:issue", "write:misc", "read:notification", "read:organization", "read:package", "read:repository", "read:user"]
Scopes []string `json:"scopes"`
// If provided and not-empty, creates an access token with access only to specified repositories.
Repositories []*RepoTargetOption `json:"repositories"`
}
// CreateOAuth2ApplicationOptions holds options to create an oauth2 application
+3
View File
@@ -395,6 +395,9 @@
"packages.owner.settings.chef.title": "Chef registry",
"packages.owner.settings.chef.keypair": "Generate key pair",
"packages.owner.settings.chef.keypair.description": "Requests sent to the Chef registry must be cryptographically signed as a means of authentication. When generating a keypair, only the public key is stored on Forgejo. The private key is provided to you to be used with knife. Generating a new keypair will overwrite the previous one.",
"access_token.error.specified_repos_none": "Access tokens with specified repositories must have at least one repository.",
"access_token.error.specified_repos_and_public_only": "Access tokens with specified repositories cannot be combined with the public-only scope.",
"access_token.error.specified_repos_and_invalid_scope": "Access tokens with specified repositories can only be used with the read:issue, write:issue, read:repository, and write:repository scopes.",
"actions.status.diagnostics.waiting": {
"one": "Waiting for a runner with the following label: %s",
"other": "Waiting for a runner with the following labels: %s"
+128 -4
View File
@@ -5,17 +5,25 @@
package user
import (
"cmp"
stdCtx "context"
"errors"
"fmt"
"net/http"
"slices"
"strconv"
"strings"
auth_model "forgejo.org/models/auth"
"forgejo.org/models/db"
access_model "forgejo.org/models/perm/access"
repo_model "forgejo.org/models/repo"
"forgejo.org/modules/container"
"forgejo.org/modules/optional"
api "forgejo.org/modules/structs"
"forgejo.org/modules/web"
"forgejo.org/routers/api/v1/utils"
"forgejo.org/services/authz"
"forgejo.org/services/context"
"forgejo.org/services/convert"
)
@@ -57,6 +65,45 @@ func ListAccessTokens(ctx *context.APIContext) {
return
}
// Load all the AccessTokenResourceRepo for the tokens that we're returning:
allRepoIDs := container.Set[int64]{}
repoResourcesByTokenID, err := auth_model.GetRepositoriesAccessibleWithTokens(ctx, tokens)
if err != nil {
ctx.InternalServerError(err)
return
}
// Load all the Repository models that are referenced by the AccessTokenResourceRepo's:
for _, repoResources := range repoResourcesByTokenID {
for _, repoResource := range repoResources {
allRepoIDs.Add(repoResource.RepoID)
}
}
reposByID, err := repo_model.GetRepositoriesMapByIDs(ctx, allRepoIDs.Slice())
if err != nil {
ctx.InternalServerError(err)
return
}
// Prepare a lookup map to access the repositories by token ID:
reposByTokenID := make(map[int64][]*api.RepositoryMeta)
for tokenID, repoResources := range repoResourcesByTokenID {
for _, repoResource := range repoResources {
repo, ok := reposByID[repoResource.RepoID]
if !ok {
// Shouldn't be possible with the foreign key on `AccessTokenResourceRepo` to the repository table.
ctx.Error(http.StatusInternalServerError, "reposById", "missing repository")
return
}
reposByTokenID[tokenID] = append(reposByTokenID[tokenID], &api.RepositoryMeta{
ID: repo.ID,
Name: repo.Name,
Owner: repo.OwnerName,
FullName: repo.FullName(),
})
}
}
apiTokens := make([]*api.AccessToken, len(tokens))
for i := range tokens {
apiTokens[i] = &api.AccessToken{
@@ -64,13 +111,32 @@ func ListAccessTokens(ctx *context.APIContext) {
Name: tokens[i].Name,
TokenLastEight: tokens[i].TokenLastEight,
Scopes: tokens[i].Scope.StringSlice(),
Repositories: reposByTokenID[tokens[i].ID],
}
// Provide a consistent sort order on repositories, helpful for test consistency. Hard to do any earlier
// because of the bulk loading maps.
slices.SortFunc(apiTokens[i].Repositories, func(a, b *api.RepositoryMeta) int {
return cmp.Compare(a.ID, b.ID)
})
}
ctx.SetTotalCountHeader(count)
ctx.JSON(http.StatusOK, &apiTokens)
}
func translateAccessTokenValidationError(ctx *context.Base, err error) optional.Option[string] {
switch {
case errors.Is(err, authz.ErrSpecifiedReposNone):
return optional.Some[string](ctx.Locale.TrString("access_token.error.specified_repos_none"))
case errors.Is(err, authz.ErrSpecifiedReposNoPublicOnly):
return optional.Some[string](ctx.Locale.TrString("access_token.error.specified_repos_and_public_only"))
case errors.Is(err, authz.ErrSpecifiedReposInvalidScope):
return optional.Some[string](ctx.Locale.TrString("access_token.error.specified_repos_and_invalid_scope"))
default:
return optional.None[string]()
}
}
// CreateAccessToken creates an access token
func CreateAccessToken(ctx *context.APIContext) {
// swagger:operation POST /users/{username}/tokens user userCreateToken
@@ -128,11 +194,68 @@ func CreateAccessToken(ctx *context.APIContext) {
}
t.Scope = scope
// maintain legacy behaviour until new API options are added -- token has access to all resources, is not
// fine-grained
t.ResourceAllRepos = true
var resourceRepos []*auth_model.AccessTokenResourceRepo
var tokenRepositories []*api.RepositoryMeta
if err := auth_model.NewAccessToken(ctx, t); err != nil {
if len(form.Repositories) != 0 {
repos := make([]*repo_model.Repository, len(form.Repositories))
for i, repoTarget := range form.Repositories {
repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, repoTarget.Owner, repoTarget.Name)
if err != nil && repo_model.IsErrRepoNotExist(err) {
ctx.Error(http.StatusBadRequest, "GetRepositoryByOwnerAndName", fmt.Errorf("repository %s/%s does not exist", repoTarget.Owner, repoTarget.Name))
return
} else if err != nil {
ctx.ServerError("GetRepositoryByOwnerAndName", err)
return
}
permission, err := access_model.GetUserRepoPermissionWithReducer(ctx, repo, ctx.Doer, ctx.Reducer)
if err != nil {
ctx.ServerError("GetUserRepoPermissionWithReducer", err)
return
} else if !permission.HasAccess() {
// Prevent data existence probing -- ensure this error is the exact same as the !IsErrRepoNotExist case above
ctx.Error(http.StatusBadRequest, "GetRepositoryByOwnerAndName", fmt.Errorf("repository %s/%s does not exist", repoTarget.Owner, repoTarget.Name))
return
}
repos[i] = repo
}
for _, repo := range repos {
resourceRepos = append(resourceRepos, &auth_model.AccessTokenResourceRepo{RepoID: repo.ID})
tokenRepositories = append(tokenRepositories, &api.RepositoryMeta{
ID: repo.ID,
Name: repo.Name,
Owner: repo.OwnerName,
FullName: repo.FullName(),
})
}
t.ResourceAllRepos = false
} else {
// token has access to all repository resources
t.ResourceAllRepos = true
}
if err := authz.ValidateAccessToken(t, resourceRepos); err != nil {
s := translateAccessTokenValidationError(ctx.Base, err)
if has, str := s.Get(); has {
ctx.Error(http.StatusBadRequest, "ValidateAccessToken", str)
return
}
ctx.ServerError("ValidateAccessToken", err)
return
}
err = db.WithTx(ctx, func(ctx stdCtx.Context) error {
if err := auth_model.NewAccessToken(ctx, t); err != nil {
return nil
}
if err := auth_model.InsertAccessTokenResourceRepos(ctx, t.ID, resourceRepos); err != nil {
return nil
}
return nil
})
if err != nil {
ctx.Error(http.StatusInternalServerError, "NewAccessToken", err)
return
}
@@ -142,6 +265,7 @@ func CreateAccessToken(ctx *context.APIContext) {
ID: t.ID,
TokenLastEight: t.TokenLastEight,
Scopes: t.Scope.StringSlice(),
Repositories: tokenRepositories,
})
}
+54
View File
@@ -5,6 +5,7 @@ package authz
import (
"context"
"errors"
"fmt"
auth_model "forgejo.org/models/auth"
@@ -26,3 +27,56 @@ func GetAuthorizationReducerForAccessToken(ctx context.Context, token *auth_mode
}
return &SpecificReposAuthorizationReducer{resourceRepos: repos}, nil
}
// A locale lookup string for the error -- eg. `access_token.error.invalid_something`
type AccessTokenValidationFailure string
// Validate that an access token's state is valid for creation. For example, that it doesn't have a conflicting set of
// resources (public-only and specific repositories), and other similar checks.
func ValidateAccessToken(token *auth_model.AccessToken, repoResources []*auth_model.AccessTokenResourceRepo) error {
// Other validations may be added here in the future.
return validateRepositoryResource(token, repoResources)
}
var (
ErrSpecifiedReposNone = errors.New("specified repository access token: must have at least one repository")
ErrSpecifiedReposNoPublicOnly = errors.New("specified repository access token: cannot be combined with public-only scope")
ErrSpecifiedReposInvalidScope = errors.New("specified repository access token: invalid scope")
)
func validateRepositoryResource(token *auth_model.AccessToken, repoResources []*auth_model.AccessTokenResourceRepo) error {
// Access tokens with broad access to all resources don't have any relevant validation rules to apply.
if token.ResourceAllRepos {
return nil
}
// Repo-specific access token must have at least one repository.
if len(repoResources) == 0 {
return ErrSpecifiedReposNone
}
// Can't have public-only and specified repos -- that's a combination that doesn't make sense.
if publicOnly, err := token.Scope.PublicOnly(); err != nil {
return err
} else if publicOnly {
return ErrSpecifiedReposNoPublicOnly
}
// Repo-specific access tokens are only effective at restricting permissions if they are limited to the scopes that
// support repositories as a resource. For example, if you had a repo-specific token but then gave it
// `write:organization`, it would be able to do operations like delete an organization -- permission checks on the
// repository resources wouldn't be applicable to the organization resources.
for _, scope := range token.Scope.StringSlice() {
switch auth_model.AccessTokenScope(scope) {
case auth_model.AccessTokenScopeReadIssue,
auth_model.AccessTokenScopeWriteIssue,
auth_model.AccessTokenScopeReadRepository,
auth_model.AccessTokenScopeWriteRepository:
continue
default:
return fmt.Errorf("%w: cannot be combined with scope %s", ErrSpecifiedReposInvalidScope, scope)
}
}
return nil
}
+53
View File
@@ -4,6 +4,7 @@
package authz
import (
"strings"
"testing"
"forgejo.org/models/auth"
@@ -44,3 +45,55 @@ func TestGetAuthorizationReducerForAccessToken(t *testing.T) {
assert.EqualValues(t, 1, specific.resourceRepos[0].RepoID)
})
}
func TestValidateAccessToken(t *testing.T) {
t.Run("valid - all access", func(t *testing.T) {
token := &auth.AccessToken{
ResourceAllRepos: true,
Scope: auth.AccessTokenScopeReadRepository,
}
err := ValidateAccessToken(token, nil)
require.NoError(t, err)
})
t.Run("valid - specified repos", func(t *testing.T) {
token := &auth.AccessToken{
ResourceAllRepos: false,
Scope: auth.AccessTokenScopeReadRepository,
}
resources := []*auth.AccessTokenResourceRepo{{RepoID: 12}}
err := ValidateAccessToken(token, resources)
require.NoError(t, err)
})
t.Run("invalid - no specified repos", func(t *testing.T) {
token := &auth.AccessToken{
ResourceAllRepos: false,
Scope: auth.AccessTokenScopeReadRepository,
}
resources := []*auth.AccessTokenResourceRepo{}
err := ValidateAccessToken(token, resources)
require.ErrorIs(t, err, ErrSpecifiedReposNone)
})
t.Run("invalid - specified repos & public-only", func(t *testing.T) {
token := &auth.AccessToken{
ResourceAllRepos: false,
Scope: auth.AccessTokenScope(strings.Join([]string{string(auth.AccessTokenScopePublicOnly), string(auth.AccessTokenScopeReadRepository)}, ",")),
}
resources := []*auth.AccessTokenResourceRepo{{RepoID: 12}}
err := ValidateAccessToken(token, resources)
require.ErrorIs(t, err, ErrSpecifiedReposNoPublicOnly)
})
t.Run("invalid - specified repos unsupported scopes", func(t *testing.T) {
token := &auth.AccessToken{
ResourceAllRepos: false,
Scope: auth.AccessTokenScopeReadAdmin,
}
resources := []*auth.AccessTokenResourceRepo{{RepoID: 12}}
err := ValidateAccessToken(token, resources)
require.ErrorIs(t, err, ErrSpecifiedReposInvalidScope)
require.ErrorContains(t, err, string(auth.AccessTokenScopeReadAdmin))
})
}
+36
View File
@@ -22167,6 +22167,14 @@
"type": "string",
"x-go-name": "Name"
},
"repositories": {
"description": "Indicates that an access token only has access to the specified repositories. Will be null if the access token\nis not limited to a set of specified repositories.",
"type": "array",
"items": {
"$ref": "#/definitions/RepositoryMeta"
},
"x-go-name": "Repositories"
},
"scopes": {
"type": "array",
"items": {
@@ -23528,6 +23536,14 @@
"type": "string",
"x-go-name": "Name"
},
"repositories": {
"description": "If provided and not-empty, creates an access token with access only to specified repositories.",
"type": "array",
"items": {
"$ref": "#/definitions/RepoTargetOption"
},
"x-go-name": "Repositories"
},
"scopes": {
"type": "array",
"items": {
@@ -28742,6 +28758,26 @@
},
"x-go-package": "forgejo.org/modules/structs"
},
"RepoTargetOption": {
"type": "object",
"required": [
"owner",
"name"
],
"properties": {
"name": {
"description": "Name of repository",
"type": "string",
"x-go-name": "Name"
},
"owner": {
"description": "Name of user or organisation that owns the repository",
"type": "string",
"x-go-name": "Owner"
}
},
"x-go-package": "forgejo.org/modules/structs"
},
"RepoTopicOptions": {
"description": "RepoTopicOptions a collection of repo topic names",
"type": "object",
+177 -12
View File
@@ -8,6 +8,7 @@ import (
"fmt"
"net/http"
"net/url"
"strings"
"testing"
auth_model "forgejo.org/models/auth"
@@ -38,17 +39,78 @@ func TestAPIGetTokens(t *testing.T) {
defer tests.PrepareTestEnv(t)()
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
// with basic auth...
req := NewRequest(t, "GET", "/api/v1/users/user2/tokens").
AddBasicAuth(user.Name)
MakeRequest(t, req, http.StatusOK)
t.Run("GET w/ basic auth", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
// ... or with a token.
newAccessToken := createAPIAccessTokenWithoutCleanUp(t, "test-key-1", user, []auth_model.AccessTokenScope{auth_model.AccessTokenScopeAll})
req = NewRequest(t, "GET", "/api/v1/users/user2/tokens").
AddTokenAuth(newAccessToken.Token)
MakeRequest(t, req, http.StatusOK)
deleteAPIAccessToken(t, newAccessToken, user)
// with basic auth...
req := NewRequest(t, "GET", "/api/v1/users/user2/tokens").
AddBasicAuth(user.Name)
resp := MakeRequest(t, req, http.StatusOK)
var accessTokens api.AccessTokenList
DecodeJSON(t, resp, &accessTokens)
require.Len(t, accessTokens, 1)
at := accessTokens[0]
assert.EqualValues(t, 3, at.ID)
assert.Equal(t, "Token A", at.Name)
assert.Equal(t, []string{""}, at.Scopes)
assert.Empty(t, at.Token)
assert.Equal(t, "69d28c91", at.TokenLastEight)
assert.Nil(t, at.Repositories)
})
t.Run("GET w/ token auth", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
// ... or with a token.
newAccessToken := createAPIAccessTokenWithoutCleanUp(t, "test-key-1", user, []auth_model.AccessTokenScope{auth_model.AccessTokenScopeAll})
req := NewRequest(t, "GET", "/api/v1/users/user2/tokens").
AddTokenAuth(newAccessToken.Token)
resp := MakeRequest(t, req, http.StatusOK)
var accessTokens api.AccessTokenList
DecodeJSON(t, resp, &accessTokens)
deleteAPIAccessToken(t, newAccessToken, user)
})
t.Run("GET fine-grained token", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
repo2OnlyToken := createFineGrainedRepoAccessToken(t, "user2",
[]auth_model.AccessTokenScope{auth_model.AccessTokenScopeReadUser},
[]int64{2, 3},
)
req := NewRequest(t, "GET", "/api/v1/users/user2/tokens").
AddBasicAuth(user.Name)
resp := MakeRequest(t, req, http.StatusOK)
var accessTokens api.AccessTokenList
DecodeJSON(t, resp, &accessTokens)
found := false
for _, token := range accessTokens {
if strings.HasSuffix(repo2OnlyToken, token.TokenLastEight) {
found = true
assert.Len(t, token.Repositories, 2)
repo2 := token.Repositories[0]
assert.Equal(t, &api.RepositoryMeta{
ID: 2,
Name: "repo2",
Owner: "user2",
FullName: "user2/repo2",
}, repo2)
repo3 := token.Repositories[1]
assert.Equal(t, &api.RepositoryMeta{
ID: 3,
Name: "repo3",
Owner: "org3",
FullName: "org3/repo3",
}, repo3)
}
}
assert.True(t, found)
})
}
// TestAPIDeleteMissingToken ensures that error is thrown when token not found
@@ -658,8 +720,111 @@ func TestAPITokenCreation(t *testing.T) {
"name": "new-new-token",
"scopes": []auth_model.AccessTokenScope{auth_model.AccessTokenScopeWriteUser},
})
req.Request.Header.Set("Authorization", "basic "+base64.StdEncoding.EncodeToString([]byte("user4:"+userPassword)))
req.AddBasicAuth("user4")
MakeRequest(t, req, http.StatusCreated)
resp := MakeRequest(t, req, http.StatusCreated)
var token api.AccessToken
DecodeJSON(t, resp, &token)
})
t.Run("repo-specific", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
t.Run("valid", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequestWithJSON(t, "POST", "/api/v1/users/user2/tokens", &api.CreateAccessTokenOption{
Name: "even-newer-token",
Scopes: []string{string(auth_model.AccessTokenScopeReadRepository)},
Repositories: []*api.RepoTargetOption{
{
Owner: "user2",
Name: "repo2",
},
},
})
req.AddBasicAuth("user2")
resp := MakeRequest(t, req, http.StatusCreated)
var token api.AccessToken
DecodeJSON(t, resp, &token)
assert.NotEmpty(t, token.Repositories)
})
t.Run("target other user's private repo", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequestWithJSON(t, "POST", "/api/v1/users/user2/tokens", &api.CreateAccessTokenOption{
Name: "not-a-valid-token",
Scopes: []string{string(auth_model.AccessTokenScopeReadRepository)},
Repositories: []*api.RepoTargetOption{
{
Owner: "user10",
Name: "repo7", // private repo owned by another user
},
},
})
req.AddBasicAuth("user2")
MakeRequest(t, req, http.StatusBadRequest)
})
t.Run("target invalid repo", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequestWithJSON(t, "POST", "/api/v1/users/user2/tokens", &api.CreateAccessTokenOption{
Name: "not-a-valid-token",
Scopes: []string{string(auth_model.AccessTokenScopeReadRepository)},
Repositories: []*api.RepoTargetOption{
{
// doesn't exist:
Owner: "user10000",
Name: "repo70000",
},
},
})
req.AddBasicAuth("user2")
MakeRequest(t, req, http.StatusBadRequest)
})
t.Run("invalid scopes", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequestWithJSON(t, "POST", "/api/v1/users/user2/tokens", &api.CreateAccessTokenOption{
Name: "not-a-valid-token",
Scopes: []string{string(auth_model.AccessTokenScopeReadAdmin)},
Repositories: []*api.RepoTargetOption{
{
Owner: "user2",
Name: "repo2",
},
},
})
req.AddBasicAuth("user2")
MakeRequest(t, req, http.StatusBadRequest)
})
})
}
func TestAPITokenDelete(t *testing.T) {
defer tests.PrepareTestEnv(t)()
req := NewRequestWithJSON(t, "POST", "/api/v1/users/user2/tokens", &api.CreateAccessTokenOption{
Name: "delete-this-token",
Scopes: []string{string(auth_model.AccessTokenScopeReadRepository)},
Repositories: []*api.RepoTargetOption{
{
Owner: "user2",
Name: "repo2",
},
},
})
req.AddBasicAuth("user2")
resp := MakeRequest(t, req, http.StatusCreated)
var token api.AccessToken
DecodeJSON(t, resp, &token)
unittest.AssertExistsAndLoadBean(t, &auth_model.AccessToken{ID: token.ID})
req = NewRequestf(t, "DELETE", "/api/v1/users/user2/tokens/%d", token.ID)
req.AddBasicAuth("user2")
MakeRequest(t, req, http.StatusNoContent)
unittest.AssertNotExistsBean(t, &auth_model.AccessToken{ID: token.ID})
}