jojo/routers/api/v1/utils/access_token.go
steven.guiheux ba1c3e0288 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>
2026-05-11 16:55:22 +02:00

234 lines
7.3 KiB
Go

// 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,
})
}