feat(api): add admin routes to manage user access tokens (#12323)

# Feature Request: Admin API route to manage access tokens for any user
## Problem
The existing API route to create access tokens (POST /api/v1/users/{username}/tokens) requires Basic authentication (username + password) via the reqBasicOrRevProxyAuth() middleware. This is by design: a token should not be created from another token.

However, this creates a blocker for environments where Basic authentication is disabled (ENABLE_BASIC_AUTHENTICATION = false), typically when authentication is delegated to an external SSO provider (e.g., OpenID Connect).

In such setups, bot/service accounts are provisioned by an external system that needs to:

Create a user via POST /api/v1/admin/users (works fine with an admin token)
Create an access token for that user (currently impossible without Basic auth or direct CLI/DB access)
The only workaround today is to SSH into the Forgejo server and run:

This is not suitable when the provisioning system has no direct access to the Forgejo host.

## Proposed solution
Add new admin-only API routes under the existing /api/v1/admin/users/{username} group to manage access tokens:

| Method |	Route |	Description |
|:-------- |:--------:| --------:|
| GET	| /api/v1/admin/users/{username}/tokens |	List access tokens for a user|
|POST	| /api/v1/admin/users/{username}/tokens |	Create an access token for a user|
|DELETE |	/api/v1/admin/users/{username}/tokens/{id} |	Delete an access token for a user|

These routes would:

Require a site admin token (reqToken() + reqSiteAdmin()) — no Basic auth needed
Use the AccessTokenScopeCategoryAdmin token scope
Reuse the existing handler logic from user.CreateAccessToken / user.ListAccessTokens / user.DeleteAccessToken
Accept the same request/response payloads as the existing user-facing routes

### Why this belongs in the admin API
It follows the existing pattern: admins can already create users, repos, orgs, SSH keys, and emails for any user via the admin API
It does not weaken security: only site administrators can call it, and it requires a valid admin-scoped token
It fills a gap: the admin CLI command forgejo admin user generate-access-token already provides this capability, but only locally

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

## Release notes
<!--URL:https://codeberg.org/forgejo/forgejo-->
- Features
  - [PR](https://codeberg.org/forgejo/forgejo/pulls/12323): <!--number 12323 --><!--line 0 --><!--description ZmVhdChhcGkpOiBhZGQgYWRtaW4gcm91dGVzIHRvIG1hbmFnZSB1c2VyIGFjY2VzcyB0b2tlbnM=-->feat(api): add admin routes to manage user access tokens<!--description-->
<!--end release-notes-assistant-->

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/12323
Reviewed-by: Mathieu Fenniak <mfenniak@noreply.codeberg.org>
This commit is contained in:
steven.guiheux 2026-05-11 16:55:22 +02:00 committed by Mathieu Fenniak
parent 03312e4f46
commit ba1c3e0288
6 changed files with 808 additions and 205 deletions

View file

@ -0,0 +1,107 @@
// Copyright 2026 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package admin
import (
"forgejo.org/routers/api/v1/utils"
"forgejo.org/services/context"
)
// ListUserAccessTokens lists all access tokens for a given user.
// This endpoint is admin-only and does not require Basic auth.
func ListUserAccessTokens(ctx *context.APIContext) {
// swagger:operation GET /admin/users/{username}/tokens admin adminListUserAccessTokens
// ---
// summary: List the specified user's access tokens
// produces:
// - application/json
// parameters:
// - name: username
// in: path
// description: username of user
// type: string
// required: true
// - name: page
// in: query
// description: page number of results to return (1-based)
// type: integer
// - name: limit
// in: query
// description: page size of results
// type: integer
// responses:
// "200":
// "$ref": "#/responses/AccessTokenList"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
utils.ListAccessTokens(ctx)
}
// CreateUserAccessToken creates a new access token for a given user.
// This endpoint is admin-only and does not require Basic auth.
func CreateUserAccessToken(ctx *context.APIContext) {
// swagger:operation POST /admin/users/{username}/tokens admin adminCreateUserAccessToken
// ---
// summary: Create an access token for the specified user
// consumes:
// - application/json
// produces:
// - application/json
// parameters:
// - name: username
// in: path
// description: username of user
// required: true
// type: string
// - name: body
// in: body
// schema:
// "$ref": "#/definitions/CreateAccessTokenOption"
// responses:
// "201":
// "$ref": "#/responses/AccessToken"
// "400":
// "$ref": "#/responses/error"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
utils.CreateAccessToken(ctx)
}
// DeleteUserAccessToken deletes an access token for a given user.
// This endpoint is admin-only and does not require Basic auth.
func DeleteUserAccessToken(ctx *context.APIContext) {
// swagger:operation DELETE /admin/users/{username}/tokens/{token} admin adminDeleteUserAccessToken
// ---
// summary: Delete an access token for the specified user
// produces:
// - application/json
// parameters:
// - name: username
// in: path
// description: username of user
// type: string
// required: true
// - name: token
// in: path
// description: token to be deleted, identified by ID and if not available by name
// type: string
// required: true
// responses:
// "204":
// "$ref": "#/responses/empty"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
// "422":
// "$ref": "#/responses/error"
utils.DeleteAccessToken(ctx)
}

View file

@ -1697,6 +1697,11 @@ func Routes() *web.Route {
m.Combo("/emails").
Get(admin.ListUserEmails).
Delete(bind(api.DeleteEmailOption{}), admin.DeleteUserEmails)
m.Group("/tokens", func() {
m.Combo("").Get(admin.ListUserAccessTokens).
Post(bind(api.CreateAccessTokenOption{}), admin.CreateUserAccessToken)
m.Combo("/{id}").Delete(admin.DeleteUserAccessToken)
})
if setting.Quota.Enabled {
m.Group("/quota", func() {
m.Get("", admin.GetUserQuota)

View file

@ -5,24 +5,13 @@
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"
api "forgejo.org/modules/structs"
"forgejo.org/modules/web"
"forgejo.org/routers/api/v1/utils"
"forgejo.org/routers/web/shared/user"
"forgejo.org/services/authz"
"forgejo.org/services/context"
"forgejo.org/services/convert"
)
@ -56,63 +45,7 @@ func ListAccessTokens(ctx *context.APIContext) {
// "404":
// "$ref": "#/responses/notFound"
opts := auth_model.ListAccessTokensOptions{UserID: ctx.ContextUser.ID, ListOptions: utils.GetListOptions(ctx)}
tokens, count, err := db.FindAndCount[auth_model.AccessToken](ctx, opts)
if err != nil {
ctx.InternalServerError(err)
return
}
// Load all the AccessTokenResourceRepo for the tokens that we're returning:
repoModelsByTokenID, err := repo_model.BulkGetRepositoriesForAccessTokens(ctx, tokens,
func(repo *repo_model.Repository) (bool, error) {
// Repos associated with a repo-specific access token should already be visible to the token owner, but it's
// possible that access has changed, such as a removed collaborator on a repo -- don't provide info on that
// repo if so.
permission, err := access_model.GetUserRepoPermissionWithReducer(ctx, repo, ctx.Doer, ctx.Reducer)
if err != nil {
return false, err
}
return permission.HasAccess(), nil
})
if err != nil {
ctx.InternalServerError(err)
return
}
// Convert map[int64]*Repository -> map[int64]*RepositoryMeta...
reposByTokenID := make(map[int64][]*api.RepositoryMeta)
for tokenID, repoModels := range repoModelsByTokenID {
repos := make([]*api.RepositoryMeta, len(repoModels))
for i, repo := range repoModels {
repos[i] = &api.RepositoryMeta{
ID: repo.ID,
Name: repo.Name,
Owner: repo.OwnerName,
FullName: repo.FullName(),
}
}
reposByTokenID[tokenID] = repos
}
apiTokens := make([]*api.AccessToken, len(tokens))
for i := range tokens {
apiTokens[i] = &api.AccessToken{
ID: tokens[i].ID,
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)
utils.ListAccessTokens(ctx)
}
// CreateAccessToken creates an access token
@ -144,104 +77,7 @@ func CreateAccessToken(ctx *context.APIContext) {
// "404":
// "$ref": "#/responses/notFound"
form := web.GetForm(ctx).(*api.CreateAccessTokenOption)
t := &auth_model.AccessToken{
UID: ctx.ContextUser.ID,
Name: form.Name,
}
exist, err := auth_model.AccessTokenByNameExists(ctx, t)
if err != nil {
ctx.InternalServerError(err)
return
}
if exist {
ctx.Error(http.StatusBadRequest, "AccessTokenByNameExists", errors.New("access token name has been used already"))
return
}
scope, err := auth_model.AccessTokenScope(strings.Join(form.Scopes, ",")).Normalize()
if err != nil {
ctx.Error(http.StatusBadRequest, "AccessTokenScope.Normalize", fmt.Errorf("invalid access token scope provided: %w", err))
return
}
if scope == "" {
ctx.Error(http.StatusBadRequest, "AccessTokenScope", "access token must have a scope")
return
}
t.Scope = scope
var resourceRepos []*auth_model.AccessTokenResourceRepo
var tokenRepositories []*api.RepositoryMeta
if form.Repositories != nil {
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 := user.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 err
}
return auth_model.InsertAccessTokenResourceRepos(ctx, t.ID, resourceRepos)
})
if err != nil {
ctx.Error(http.StatusInternalServerError, "NewAccessToken", err)
return
}
ctx.JSON(http.StatusCreated, &api.AccessToken{
Name: t.Name,
Token: t.Token,
ID: t.ID,
TokenLastEight: t.TokenLastEight,
Scopes: t.Scope.StringSlice(),
Repositories: tokenRepositories,
})
utils.CreateAccessToken(ctx)
}
// DeleteAccessToken deletes an access token
@ -272,45 +108,7 @@ func DeleteAccessToken(ctx *context.APIContext) {
// "422":
// "$ref": "#/responses/error"
token := ctx.Params(":id")
tokenID, _ := strconv.ParseInt(token, 0, 64)
if tokenID == 0 {
tokens, err := db.Find[auth_model.AccessToken](ctx, auth_model.ListAccessTokensOptions{
Name: token,
UserID: ctx.ContextUser.ID,
})
if err != nil {
ctx.Error(http.StatusInternalServerError, "ListAccessTokens", err)
return
}
switch len(tokens) {
case 0:
ctx.NotFound()
return
case 1:
tokenID = tokens[0].ID
default:
ctx.Error(http.StatusUnprocessableEntity, "DeleteAccessTokenByID", fmt.Errorf("multiple matches for token name '%s'", token))
return
}
}
if tokenID == 0 {
ctx.Error(http.StatusInternalServerError, "Invalid TokenID", nil)
return
}
if err := auth_model.DeleteAccessTokenByID(ctx, tokenID, ctx.ContextUser.ID); err != nil {
if auth_model.IsErrAccessTokenNotExist(err) {
ctx.NotFound()
} else {
ctx.Error(http.StatusInternalServerError, "DeleteAccessTokenByID", err)
}
return
}
ctx.Status(http.StatusNoContent)
utils.DeleteAccessToken(ctx)
}
// CreateOauth2Application is the handler to create a new OAuth2 Application for the authenticated user

View file

@ -0,0 +1,234 @@
// Copyright 2026 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package utils
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"
api "forgejo.org/modules/structs"
"forgejo.org/modules/web"
"forgejo.org/routers/web/shared/user"
"forgejo.org/services/authz"
"forgejo.org/services/context"
)
// DeleteAccessToken deletes an access token for a user identified by ctx.ContextUser.
// Shared logic between user and admin token deletion endpoints.
func DeleteAccessToken(ctx *context.APIContext) {
token := ctx.Params(":id")
tokenID, _ := strconv.ParseInt(token, 0, 64)
if tokenID == 0 {
tokens, err := db.Find[auth_model.AccessToken](ctx, auth_model.ListAccessTokensOptions{
Name: token,
UserID: ctx.ContextUser.ID,
})
if err != nil {
ctx.Error(http.StatusInternalServerError, "ListAccessTokens", err)
return
}
switch len(tokens) {
case 0:
ctx.NotFound()
return
case 1:
tokenID = tokens[0].ID
default:
ctx.Error(http.StatusUnprocessableEntity, "DeleteAccessTokenByID", fmt.Errorf("multiple matches for token name '%s'", token))
return
}
}
if tokenID == 0 {
ctx.Error(http.StatusInternalServerError, "Invalid TokenID", nil)
return
}
if err := auth_model.DeleteAccessTokenByID(ctx, tokenID, ctx.ContextUser.ID); err != nil {
if auth_model.IsErrAccessTokenNotExist(err) {
ctx.NotFound()
} else {
ctx.Error(http.StatusInternalServerError, "DeleteAccessTokenByID", err)
}
return
}
ctx.Status(http.StatusNoContent)
}
// ListAccessTokens lists access tokens for a user identified by ctx.ContextUser.
// Shared logic between user and admin token listing endpoints.
func ListAccessTokens(ctx *context.APIContext) {
opts := auth_model.ListAccessTokensOptions{UserID: ctx.ContextUser.ID, ListOptions: GetListOptions(ctx)}
tokens, count, err := db.FindAndCount[auth_model.AccessToken](ctx, opts)
if err != nil {
ctx.InternalServerError(err)
return
}
// Load all the AccessTokenResourceRepo for the tokens that we're returning:
repoModelsByTokenID, err := repo_model.BulkGetRepositoriesForAccessTokens(ctx, tokens,
func(repo *repo_model.Repository) (bool, error) {
// Repos associated with a repo-specific access token should already be visible to the token owner, but it's
// possible that access has changed, such as a removed collaborator on a repo -- don't provide info on that
// repo if so.
permission, err := access_model.GetUserRepoPermissionWithReducer(ctx, repo, ctx.Doer, ctx.Reducer)
if err != nil {
return false, err
}
return permission.HasAccess(), nil
})
if err != nil {
ctx.InternalServerError(err)
return
}
// Convert map[int64]*Repository -> map[int64]*RepositoryMeta...
reposByTokenID := make(map[int64][]*api.RepositoryMeta)
for tokenID, repoModels := range repoModelsByTokenID {
repos := make([]*api.RepositoryMeta, len(repoModels))
for i, repo := range repoModels {
repos[i] = &api.RepositoryMeta{
ID: repo.ID,
Name: repo.Name,
Owner: repo.OwnerName,
FullName: repo.FullName(),
}
}
reposByTokenID[tokenID] = repos
}
apiTokens := make([]*api.AccessToken, len(tokens))
for i := range tokens {
apiTokens[i] = &api.AccessToken{
ID: tokens[i].ID,
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)
}
// CreateAccessToken creates an access token for a user identified by ctx.ContextUser.
// Shared logic between user and admin token creation endpoints.
func CreateAccessToken(ctx *context.APIContext) {
form := web.GetForm(ctx).(*api.CreateAccessTokenOption)
t := &auth_model.AccessToken{
UID: ctx.ContextUser.ID,
Name: form.Name,
}
exist, err := auth_model.AccessTokenByNameExists(ctx, t)
if err != nil {
ctx.InternalServerError(err)
return
}
if exist {
ctx.Error(http.StatusBadRequest, "AccessTokenByNameExists", errors.New("access token name has been used already"))
return
}
scope, err := auth_model.AccessTokenScope(strings.Join(form.Scopes, ",")).Normalize()
if err != nil {
ctx.Error(http.StatusBadRequest, "AccessTokenScope.Normalize", fmt.Errorf("invalid access token scope provided: %w", err))
return
}
if scope == "" {
ctx.Error(http.StatusBadRequest, "AccessTokenScope", "access token must have a scope")
return
}
t.Scope = scope
var resourceRepos []*auth_model.AccessTokenResourceRepo
var tokenRepositories []*api.RepositoryMeta
if form.Repositories != nil {
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 := user.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 err
}
return auth_model.InsertAccessTokenResourceRepos(ctx, t.ID, resourceRepos)
})
if err != nil {
ctx.Error(http.StatusInternalServerError, "NewAccessToken", err)
return
}
ctx.JSON(http.StatusCreated, &api.AccessToken{
Name: t.Name,
Token: t.Token,
ID: t.ID,
TokenLastEight: t.TokenLastEight,
Scopes: t.Scope.StringSlice(),
Repositories: tokenRepositories,
})
}

View file

@ -2043,6 +2043,135 @@
}
}
},
"/admin/users/{username}/tokens": {
"get": {
"produces": [
"application/json"
],
"tags": [
"admin"
],
"summary": "List the specified user's access tokens",
"operationId": "adminListUserAccessTokens",
"parameters": [
{
"type": "string",
"description": "username of user",
"name": "username",
"in": "path",
"required": true
},
{
"type": "integer",
"description": "page number of results to return (1-based)",
"name": "page",
"in": "query"
},
{
"type": "integer",
"description": "page size of results",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"$ref": "#/responses/AccessTokenList"
},
"403": {
"$ref": "#/responses/forbidden"
},
"404": {
"$ref": "#/responses/notFound"
}
}
},
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"admin"
],
"summary": "Create an access token for the specified user",
"operationId": "adminCreateUserAccessToken",
"parameters": [
{
"type": "string",
"description": "username of user",
"name": "username",
"in": "path",
"required": true
},
{
"name": "body",
"in": "body",
"schema": {
"$ref": "#/definitions/CreateAccessTokenOption"
}
}
],
"responses": {
"201": {
"$ref": "#/responses/AccessToken"
},
"400": {
"$ref": "#/responses/error"
},
"403": {
"$ref": "#/responses/forbidden"
},
"404": {
"$ref": "#/responses/notFound"
}
}
}
},
"/admin/users/{username}/tokens/{token}": {
"delete": {
"produces": [
"application/json"
],
"tags": [
"admin"
],
"summary": "Delete an access token for the specified user",
"operationId": "adminDeleteUserAccessToken",
"parameters": [
{
"type": "string",
"description": "username of user",
"name": "username",
"in": "path",
"required": true
},
{
"type": "string",
"description": "token to be deleted, identified by ID and if not available by name",
"name": "token",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"$ref": "#/responses/empty"
},
"403": {
"$ref": "#/responses/forbidden"
},
"404": {
"$ref": "#/responses/notFound"
},
"422": {
"$ref": "#/responses/error"
}
}
}
},
"/gitignore/templates": {
"get": {
"produces": [

View file

@ -0,0 +1,330 @@
// Copyright 2026 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"fmt"
"net/http"
"testing"
auth_model "forgejo.org/models/auth"
"forgejo.org/models/unittest"
user_model "forgejo.org/models/user"
api "forgejo.org/modules/structs"
"forgejo.org/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAPIAdminCreateUserAccessToken(t *testing.T) {
defer tests.PrepareTestEnv(t)()
adminUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user1"})
targetUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2"})
token := getUserToken(t, adminUser.Name, auth_model.AccessTokenScopeWriteAdmin)
t.Run("Create token for another user", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
urlStr := fmt.Sprintf("/api/v1/admin/users/%s/tokens", targetUser.Name)
req := NewRequestWithJSON(t, "POST", urlStr, api.CreateAccessTokenOption{
Name: "admin-created-token",
Scopes: []string{"all"},
}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusCreated)
var newToken api.AccessToken
DecodeJSON(t, resp, &newToken)
assert.Equal(t, "admin-created-token", newToken.Name)
assert.NotEmpty(t, newToken.Token)
assert.NotEmpty(t, newToken.TokenLastEight)
assert.Contains(t, newToken.Scopes, "all")
// Verify the token exists in DB
unittest.AssertExistsAndLoadBean(t, &auth_model.AccessToken{
ID: newToken.ID,
Name: newToken.Name,
UID: targetUser.ID,
})
})
t.Run("Create token with duplicate name", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
urlStr := fmt.Sprintf("/api/v1/admin/users/%s/tokens", targetUser.Name)
req := NewRequestWithJSON(t, "POST", urlStr, api.CreateAccessTokenOption{
Name: "admin-created-token",
Scopes: []string{"all"},
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusBadRequest)
})
t.Run("Create token without scopes", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
urlStr := fmt.Sprintf("/api/v1/admin/users/%s/tokens", targetUser.Name)
req := NewRequestWithJSON(t, "POST", urlStr, api.CreateAccessTokenOption{
Name: "empty-scope-token",
Scopes: []string{},
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusBadRequest)
})
t.Run("Create token with invalid scope", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
urlStr := fmt.Sprintf("/api/v1/admin/users/%s/tokens", targetUser.Name)
req := NewRequestWithJSON(t, "POST", urlStr, api.CreateAccessTokenOption{
Name: "invalid-scope-token",
Scopes: []string{"invalid-scope"},
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusBadRequest)
})
t.Run("Create token for nonexistent user", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
urlStr := "/api/v1/admin/users/nonexistentuser/tokens"
req := NewRequestWithJSON(t, "POST", urlStr, api.CreateAccessTokenOption{
Name: "some-token",
Scopes: []string{"all"},
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
})
t.Run("Non-admin cannot create token", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
normalToken := getUserToken(t, "user2", auth_model.AccessTokenScopeWriteAdmin)
urlStr := fmt.Sprintf("/api/v1/admin/users/%s/tokens", targetUser.Name)
req := NewRequestWithJSON(t, "POST", urlStr, api.CreateAccessTokenOption{
Name: "unauthorized-token",
Scopes: []string{"all"},
}).AddTokenAuth(normalToken)
MakeRequest(t, req, http.StatusForbidden)
})
}
func TestAPIAdminListUserAccessTokens(t *testing.T) {
defer tests.PrepareTestEnv(t)()
adminUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user1"})
targetUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2"})
token := getUserToken(t, adminUser.Name, auth_model.AccessTokenScopeWriteAdmin)
// First, create a token for the target user
createURL := fmt.Sprintf("/api/v1/admin/users/%s/tokens", targetUser.Name)
req := NewRequestWithJSON(t, "POST", createURL, api.CreateAccessTokenOption{
Name: "list-test-token",
Scopes: []string{"all"},
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusCreated)
t.Run("List tokens for user", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
listURL := fmt.Sprintf("/api/v1/admin/users/%s/tokens", targetUser.Name)
req := NewRequest(t, "GET", listURL).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var tokens []*api.AccessToken
DecodeJSON(t, resp, &tokens)
// user2 has at least the token we just created plus any fixture tokens
require.NotEmpty(t, tokens)
found := false
for _, tk := range tokens {
if tk.Name == "list-test-token" {
found = true
assert.NotEmpty(t, tk.TokenLastEight)
break
}
}
assert.True(t, found, "should find the admin-created token in the list")
})
t.Run("Non-admin cannot list tokens", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
normalToken := getUserToken(t, "user2", auth_model.AccessTokenScopeWriteAdmin)
listURL := fmt.Sprintf("/api/v1/admin/users/%s/tokens", targetUser.Name)
req := NewRequest(t, "GET", listURL).AddTokenAuth(normalToken)
MakeRequest(t, req, http.StatusForbidden)
})
}
func TestAPIAdminCreateRepoSpecificToken(t *testing.T) {
defer tests.PrepareTestEnv(t)()
adminUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user1"})
targetUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2"})
token := getUserToken(t, adminUser.Name, auth_model.AccessTokenScopeWriteAdmin)
t.Run("Create repo-specific token", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
urlStr := fmt.Sprintf("/api/v1/admin/users/%s/tokens", targetUser.Name)
req := NewRequestWithJSON(t, "POST", urlStr, api.CreateAccessTokenOption{
Name: "admin-repo-specific-token",
Scopes: []string{string(auth_model.AccessTokenScopeReadRepository)},
Repositories: []*api.RepoTargetOption{
{
Owner: "user2",
Name: "repo2",
},
},
}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusCreated)
var newToken api.AccessToken
DecodeJSON(t, resp, &newToken)
assert.Equal(t, "admin-repo-specific-token", newToken.Name)
assert.NotEmpty(t, newToken.Token)
assert.NotEmpty(t, newToken.Repositories)
})
t.Run("Create token targeting invalid repo", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
urlStr := fmt.Sprintf("/api/v1/admin/users/%s/tokens", targetUser.Name)
req := NewRequestWithJSON(t, "POST", urlStr, api.CreateAccessTokenOption{
Name: "admin-invalid-repo-token",
Scopes: []string{string(auth_model.AccessTokenScopeReadRepository)},
Repositories: []*api.RepoTargetOption{
{
Owner: "user10000",
Name: "repo70000",
},
},
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusBadRequest)
})
t.Run("List repo-specific token returns repositories", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
// Create a repo-specific token
createURL := fmt.Sprintf("/api/v1/admin/users/%s/tokens", targetUser.Name)
req := NewRequestWithJSON(t, "POST", createURL, api.CreateAccessTokenOption{
Name: "admin-list-repo-token",
Scopes: []string{string(auth_model.AccessTokenScopeReadRepository)},
Repositories: []*api.RepoTargetOption{
{
Owner: "user2",
Name: "repo2",
},
},
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusCreated)
// List tokens and verify repositories are returned
listURL := fmt.Sprintf("/api/v1/admin/users/%s/tokens", targetUser.Name)
req = NewRequest(t, "GET", listURL).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var tokens []*api.AccessToken
DecodeJSON(t, resp, &tokens)
found := false
for _, tk := range tokens {
if tk.Name == "admin-list-repo-token" {
found = true
assert.NotEmpty(t, tk.Repositories, "admin-listed token should have repositories populated")
break
}
}
assert.True(t, found, "should find the repo-specific token in the admin list")
})
}
func TestAPIAdminDeleteUserAccessToken(t *testing.T) {
defer tests.PrepareTestEnv(t)()
adminUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user1"})
targetUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2"})
token := getUserToken(t, adminUser.Name, auth_model.AccessTokenScopeWriteAdmin)
t.Run("Delete token by ID", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
// Create a token first
createURL := fmt.Sprintf("/api/v1/admin/users/%s/tokens", targetUser.Name)
req := NewRequestWithJSON(t, "POST", createURL, api.CreateAccessTokenOption{
Name: "delete-by-id-token",
Scopes: []string{"all"},
}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusCreated)
var newToken api.AccessToken
DecodeJSON(t, resp, &newToken)
// Delete it
deleteURL := fmt.Sprintf("/api/v1/admin/users/%s/tokens/%d", targetUser.Name, newToken.ID)
req = NewRequest(t, "DELETE", deleteURL).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNoContent)
// Verify it's gone
unittest.AssertNotExistsBean(t, &auth_model.AccessToken{ID: newToken.ID})
})
t.Run("Delete token by name", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
// Create a token first
createURL := fmt.Sprintf("/api/v1/admin/users/%s/tokens", targetUser.Name)
req := NewRequestWithJSON(t, "POST", createURL, api.CreateAccessTokenOption{
Name: "delete-by-name-token",
Scopes: []string{"all"},
}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusCreated)
var newToken api.AccessToken
DecodeJSON(t, resp, &newToken)
// Delete by name
deleteURL := fmt.Sprintf("/api/v1/admin/users/%s/tokens/%s", targetUser.Name, "delete-by-name-token")
req = NewRequest(t, "DELETE", deleteURL).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNoContent)
// Verify it's gone
unittest.AssertNotExistsBean(t, &auth_model.AccessToken{ID: newToken.ID})
})
t.Run("Delete nonexistent token", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
deleteURL := fmt.Sprintf("/api/v1/admin/users/%s/tokens/%d", targetUser.Name, 999999)
req := NewRequest(t, "DELETE", deleteURL).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
})
t.Run("Non-admin cannot delete token", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
// Create a token as admin
createURL := fmt.Sprintf("/api/v1/admin/users/%s/tokens", targetUser.Name)
req := NewRequestWithJSON(t, "POST", createURL, api.CreateAccessTokenOption{
Name: "non-admin-delete-token",
Scopes: []string{"all"},
}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusCreated)
var newToken api.AccessToken
DecodeJSON(t, resp, &newToken)
// Try to delete as non-admin
normalToken := getUserToken(t, "user2", auth_model.AccessTokenScopeWriteAdmin)
deleteURL := fmt.Sprintf("/api/v1/admin/users/%s/tokens/%d", targetUser.Name, newToken.ID)
req = NewRequest(t, "DELETE", deleteURL).AddTokenAuth(normalToken)
MakeRequest(t, req, http.StatusForbidden)
})
}