jojo/tests/integration/api_admin_user_token_test.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

330 lines
11 KiB
Go

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