From ba1c3e0288bc35d82812275b3d1b31a10aea011d Mon Sep 17 00:00:00 2001 From: "steven.guiheux" Date: Mon, 11 May 2026 16:55:22 +0200 Subject: [PATCH] feat(api): add admin routes to manage user access tokens (#12323) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # 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 ## Release notes - Features - [PR](https://codeberg.org/forgejo/forgejo/pulls/12323): feat(api): add admin routes to manage user access tokens Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/12323 Reviewed-by: Mathieu Fenniak --- routers/api/v1/admin/user_token.go | 107 ++++++ routers/api/v1/api.go | 5 + routers/api/v1/user/app.go | 208 +---------- routers/api/v1/utils/access_token.go | 234 +++++++++++++ templates/swagger/v1_json.tmpl | 129 +++++++ .../integration/api_admin_user_token_test.go | 330 ++++++++++++++++++ 6 files changed, 808 insertions(+), 205 deletions(-) create mode 100644 routers/api/v1/admin/user_token.go create mode 100644 routers/api/v1/utils/access_token.go create mode 100644 tests/integration/api_admin_user_token_test.go diff --git a/routers/api/v1/admin/user_token.go b/routers/api/v1/admin/user_token.go new file mode 100644 index 0000000000..852c29c69d --- /dev/null +++ b/routers/api/v1/admin/user_token.go @@ -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) +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index d1488d2fc2..2ec7617215 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -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) diff --git a/routers/api/v1/user/app.go b/routers/api/v1/user/app.go index 8d8ead9234..90d72c76aa 100644 --- a/routers/api/v1/user/app.go +++ b/routers/api/v1/user/app.go @@ -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 diff --git a/routers/api/v1/utils/access_token.go b/routers/api/v1/utils/access_token.go new file mode 100644 index 0000000000..a0f6b7449b --- /dev/null +++ b/routers/api/v1/utils/access_token.go @@ -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, + }) +} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index a0e6cc8e4a..fa9750db72 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -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": [ diff --git a/tests/integration/api_admin_user_token_test.go b/tests/integration/api_admin_user_token_test.go new file mode 100644 index 0000000000..5f9f26208b --- /dev/null +++ b/tests/integration/api_admin_user_token_test.go @@ -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) + }) +}