mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2026-05-13 06:20:24 +00:00
# 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>
330 lines
11 KiB
Go
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)
|
|
})
|
|
}
|