feat(api): add token introspection and self-deletion endpoint (#37995)

Adds a /api/v1/token endpoint that allows tokens to introspect and
delete themselves.
partially fixes: https://github.com/go-gitea/gitea/issues/33583

Assisted-by: Mistral Vibe:mistral-medium-3.5

---------

Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
TheFox0x7
2026-06-14 20:05:18 +02:00
committed by GitHub
parent b8ef6a91e6
commit c6167d1ff5
12 changed files with 437 additions and 69 deletions

View File

@@ -20,42 +20,6 @@ import (
"xorm.io/builder"
)
// ErrAccessTokenNotExist represents a "AccessTokenNotExist" kind of error.
type ErrAccessTokenNotExist struct {
Token string
}
// IsErrAccessTokenNotExist checks if an error is a ErrAccessTokenNotExist.
func IsErrAccessTokenNotExist(err error) bool {
_, ok := err.(ErrAccessTokenNotExist)
return ok
}
func (err ErrAccessTokenNotExist) Error() string {
return fmt.Sprintf("access token does not exist [sha: %s]", err.Token)
}
func (err ErrAccessTokenNotExist) Unwrap() error {
return util.ErrNotExist
}
// ErrAccessTokenEmpty represents a "AccessTokenEmpty" kind of error.
type ErrAccessTokenEmpty struct{}
// IsErrAccessTokenEmpty checks if an error is a ErrAccessTokenEmpty.
func IsErrAccessTokenEmpty(err error) bool {
_, ok := err.(ErrAccessTokenEmpty)
return ok
}
func (err ErrAccessTokenEmpty) Error() string {
return "access token is empty"
}
func (err ErrAccessTokenEmpty) Unwrap() error {
return util.ErrInvalidArgument
}
var successfulAccessTokenCache *lru.Cache[string, any]
// AccessToken represents a personal access token.
@@ -134,21 +98,11 @@ func getAccessTokenIDFromCache(token string) int64 {
// GetAccessTokenBySHA returns access token by given token value
func GetAccessTokenBySHA(ctx context.Context, token string) (*AccessToken, error) {
if token == "" {
return nil, ErrAccessTokenEmpty{}
}
// A token is defined as being SHA1 sum these are 40 hexadecimal bytes long
if len(token) != 40 {
return nil, ErrAccessTokenNotExist{token}
}
for _, x := range []byte(token) {
if x < '0' || (x > '9' && x < 'a') || x > 'f' {
return nil, ErrAccessTokenNotExist{token}
}
if len(token) < 8 {
return nil, util.NewNotExistErrorf("access token not found")
}
lastEight := token[len(token)-8:]
if id := getAccessTokenIDFromCache(token); id > 0 {
accessToken := &AccessToken{
TokenLastEight: lastEight,
@@ -169,7 +123,7 @@ func GetAccessTokenBySHA(ctx context.Context, token string) (*AccessToken, error
if err != nil {
return nil, err
} else if len(tokens) == 0 {
return nil, ErrAccessTokenNotExist{token}
return nil, util.NewNotExistErrorf("access token not found")
}
for _, t := range tokens {
@@ -181,7 +135,7 @@ func GetAccessTokenBySHA(ctx context.Context, token string) (*AccessToken, error
return &t, nil
}
}
return nil, ErrAccessTokenNotExist{token}
return nil, util.NewNotExistErrorf("access token not found")
}
// AccessTokenByNameExists checks if a token name has been used already by a user.
@@ -218,13 +172,11 @@ func UpdateAccessToken(ctx context.Context, t *AccessToken) error {
// DeleteAccessTokenByID deletes access token by given ID.
func DeleteAccessTokenByID(ctx context.Context, id, userID int64) error {
cnt, err := db.GetEngine(ctx).ID(id).Delete(&AccessToken{
UID: userID,
})
cnt, err := db.GetEngine(ctx).ID(id).Delete(&AccessToken{UID: userID})
if err != nil {
return err
} else if cnt != 1 {
return ErrAccessTokenNotExist{}
return util.NewNotExistErrorf("access token not found")
}
return nil
}

View File

@@ -9,6 +9,7 @@ import (
auth_model "gitea.dev/models/auth"
"gitea.dev/models/db"
"gitea.dev/models/unittest"
"gitea.dev/modules/util"
"github.com/stretchr/testify/assert"
)
@@ -76,11 +77,11 @@ func TestGetAccessTokenBySHA(t *testing.T) {
_, err = auth_model.GetAccessTokenBySHA(t.Context(), "notahash")
assert.Error(t, err)
assert.True(t, auth_model.IsErrAccessTokenNotExist(err))
assert.ErrorIs(t, err, util.ErrNotExist)
_, err = auth_model.GetAccessTokenBySHA(t.Context(), "")
assert.Error(t, err)
assert.True(t, auth_model.IsErrAccessTokenEmpty(err))
assert.ErrorIs(t, err, util.ErrNotExist)
}
func TestListAccessTokens(t *testing.T) {
@@ -128,5 +129,5 @@ func TestDeleteAccessTokenByID(t *testing.T) {
err = auth_model.DeleteAccessTokenByID(t.Context(), 100, 100)
assert.Error(t, err)
assert.True(t, auth_model.IsErrAccessTokenNotExist(err))
assert.ErrorIs(t, err, util.ErrNotExist)
}

31
modules/structs/token.go Normal file
View File

@@ -0,0 +1,31 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package structs
import "time"
// CurrentAccessToken represents the metadata of the currently authenticated token.
// swagger:model CurrentAccessToken
type CurrentAccessToken struct {
// The unique identifier of the access token
ID int64 `json:"id"`
// The name of the access token
Name string `json:"name"`
// The scopes granted to this access token
Scopes []string `json:"scopes"`
// The timestamp when the token was created
CreatedAt time.Time `json:"created_at"`
// The timestamp when the token was last used
LastUsedAt time.Time `json:"last_used_at"`
// The owner of the access token
User *UserMeta `json:"user"`
}
// UserMeta represents minimal user information for the token owner.
type UserMeta struct {
// The unique identifier of the user
ID int64 `json:"id"`
// The username of the user
Login string `json:"login"`
}

View File

@@ -88,6 +88,7 @@ import (
"gitea.dev/routers/api/v1/packages"
"gitea.dev/routers/api/v1/repo"
"gitea.dev/routers/api/v1/settings"
"gitea.dev/routers/api/v1/token"
"gitea.dev/routers/api/v1/user"
"gitea.dev/routers/common"
"gitea.dev/services/actions"
@@ -976,6 +977,11 @@ func Routes() *web.Router {
})
})
// Token introspection and deletion endpoint
m.Combo("/token").
Get(reqToken(), token.GetCurrentToken).
Delete(reqToken(), token.DeleteCurrentToken)
// Notifications (requires 'notifications' scope)
// The notifications API is not available for public-only tokens because a user's notifications mix
// public and private repository events in the same mailbox.

View File

@@ -20,3 +20,10 @@ type swaggerResponseAccessToken struct {
// in:body
Body api.AccessToken `json:"body"`
}
// CurrentAccessToken represents the currently authenticated access token.
// swagger:response CurrentAccessToken
type swaggerResponseCurrentAccessToken struct {
// in:body
Body api.CurrentAccessToken `json:"body"`
}

View File

@@ -0,0 +1,88 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package token
import (
"errors"
"net/http"
auth_model "gitea.dev/models/auth"
user_model "gitea.dev/models/user"
"gitea.dev/modules/auth/httpauth"
api "gitea.dev/modules/structs"
"gitea.dev/modules/util"
"gitea.dev/services/context"
)
// GetCurrentToken returns metadata about the currently authenticated token.
func GetCurrentToken(ctx *context.APIContext) {
// swagger:operation GET /token miscellaneous getCurrentToken
// ---
// summary: Get the currently authenticated token
// produces:
// - application/json
// responses:
// "200":
// "$ref": "#/responses/CurrentAccessToken"
accessToken, err := getToken(ctx)
if err != nil {
ctx.APIErrorAuto(err)
return
}
// Get user info
user, err := user_model.GetUserByID(ctx, accessToken.UID)
if err != nil {
ctx.APIErrorAuto(err)
return
}
ctx.JSON(http.StatusOK, &api.CurrentAccessToken{
ID: accessToken.ID,
Name: accessToken.Name,
Scopes: accessToken.Scope.StringSlice(),
CreatedAt: accessToken.CreatedUnix.AsTime(),
LastUsedAt: accessToken.UpdatedUnix.AsTime(),
User: &api.UserMeta{
ID: user.ID,
Login: user.Name,
},
})
}
// DeleteCurrentToken deletes the currently authenticated token.
func DeleteCurrentToken(ctx *context.APIContext) {
// swagger:operation DELETE /token miscellaneous deleteCurrentToken
// ---
// summary: Delete the currently authenticated token
// produces:
// - application/json
// responses:
// "204":
// description: token deleted
accessToken, err := getToken(ctx)
if err != nil {
ctx.APIErrorAuto(err)
return
}
// Delete the token
err = auth_model.DeleteAccessTokenByID(ctx, accessToken.ID, accessToken.UID)
if err != nil && !errors.Is(err, util.ErrNotExist) {
ctx.APIErrorAuto(err)
return
}
ctx.Status(http.StatusNoContent)
}
// getToken retrieves an access token from the API context's Authorization header and validates it against the database.
// Returns nil if the token is invalid and handles the response
func getToken(ctx *context.APIContext) (*auth_model.AccessToken, error) {
authHeader := ctx.Req.Header.Get("Authorization")
parsed, ok := httpauth.ParseAuthorizationHeader(authHeader)
if !ok || parsed.BearerToken == nil {
return nil, util.NewNotExistErrorf("invalid access token")
}
return auth_model.GetAccessTokenBySHA(ctx, parsed.BearerToken.Token)
}

View File

@@ -191,17 +191,9 @@ func DeleteAccessToken(ctx *context.APIContext) {
return
}
}
if tokenID == 0 {
ctx.APIErrorInternal(nil)
return
}
if err := auth_model.DeleteAccessTokenByID(ctx, tokenID, ctx.ContextUser.ID); err != nil {
if auth_model.IsErrAccessTokenNotExist(err) {
ctx.APIErrorNotFound()
} else {
ctx.APIErrorInternal(err)
}
ctx.APIErrorAuto(err)
return
}

View File

@@ -5,6 +5,7 @@
package auth
import (
"errors"
"net/http"
actions_model "gitea.dev/models/actions"
@@ -104,8 +105,8 @@ func (b *Basic) VerifyAuthToken(req *http.Request, w http.ResponseWriter, store
store.GetData()["IsApiToken"] = true
store.GetData()["ApiTokenScope"] = token.Scope
return u, nil
} else if !auth_model.IsErrAccessTokenNotExist(err) && !auth_model.IsErrAccessTokenEmpty(err) {
log.Error("GetAccessTokenBySha: %v", err)
} else if !errors.Is(err, util.ErrNotExist) {
log.Error("GetAccessTokenBySHA: %v", err)
}
// check task token

View File

@@ -128,7 +128,7 @@ func (o *OAuth2) userFromToken(ctx context.Context, tokenSHA string, store DataS
}
t, err := auth_model.GetAccessTokenBySHA(ctx, tokenSHA)
if err != nil {
if auth_model.IsErrAccessTokenNotExist(err) {
if errors.Is(err, util.ErrNotExist) {
// check task token
if task, err := actions_model.GetRunningTaskByToken(ctx, tokenSHA); err == nil {
log.Trace("Basic Authorization: Valid AccessToken for task[%d]", task.ID)

View File

@@ -19202,6 +19202,38 @@
}
}
},
"/token": {
"get": {
"produces": [
"application/json"
],
"tags": [
"miscellaneous"
],
"summary": "Get the currently authenticated token",
"operationId": "getCurrentToken",
"responses": {
"200": {
"$ref": "#/responses/CurrentAccessToken"
}
}
},
"delete": {
"produces": [
"application/json"
],
"tags": [
"miscellaneous"
],
"summary": "Delete the currently authenticated token",
"operationId": "deleteCurrentToken",
"responses": {
"204": {
"description": "token deleted"
}
}
}
},
"/topics/search": {
"get": {
"produces": [
@@ -25116,6 +25148,47 @@
},
"x-go-package": "gitea.dev/modules/structs"
},
"CurrentAccessToken": {
"type": "object",
"title": "CurrentAccessToken represents the metadata of the currently authenticated token.",
"properties": {
"created_at": {
"description": "The timestamp when the token was created",
"type": "string",
"format": "date-time",
"x-go-name": "CreatedAt"
},
"id": {
"description": "The unique identifier of the access token",
"type": "integer",
"format": "int64",
"x-go-name": "ID"
},
"last_used_at": {
"description": "The timestamp when the token was last used",
"type": "string",
"format": "date-time",
"x-go-name": "LastUsedAt"
},
"name": {
"description": "The name of the access token",
"type": "string",
"x-go-name": "Name"
},
"scopes": {
"description": "The scopes granted to this access token",
"type": "array",
"items": {
"type": "string"
},
"x-go-name": "Scopes"
},
"user": {
"$ref": "#/definitions/UserMeta"
}
},
"x-go-package": "gitea.dev/modules/structs"
},
"DeleteEmailOption": {
"description": "DeleteEmailOption options when deleting email addresses",
"type": "object",
@@ -30585,6 +30658,24 @@
},
"x-go-package": "gitea.dev/models/activities"
},
"UserMeta": {
"type": "object",
"title": "UserMeta represents minimal user information for the token owner.",
"properties": {
"id": {
"description": "The unique identifier of the user",
"type": "integer",
"format": "int64",
"x-go-name": "ID"
},
"login": {
"description": "The username of the user",
"type": "string",
"x-go-name": "Login"
}
},
"x-go-package": "gitea.dev/modules/structs"
},
"UserSettings": {
"description": "UserSettings represents user settings",
"type": "object",
@@ -31089,6 +31180,12 @@
}
}
},
"CurrentAccessToken": {
"description": "CurrentAccessToken represents the currently authenticated access token.",
"schema": {
"$ref": "#/definitions/CurrentAccessToken"
}
},
"DeployKey": {
"description": "DeployKey",
"schema": {

View File

@@ -399,6 +399,16 @@
},
"description": "CronList"
},
"CurrentAccessToken": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CurrentAccessToken"
}
}
},
"description": "CurrentAccessToken represents the currently authenticated access token."
},
"DeployKey": {
"content": {
"application/json": {
@@ -4952,6 +4962,47 @@
"type": "object",
"x-go-package": "gitea.dev/modules/structs"
},
"CurrentAccessToken": {
"properties": {
"created_at": {
"description": "The timestamp when the token was created",
"format": "date-time",
"type": "string",
"x-go-name": "CreatedAt"
},
"id": {
"description": "The unique identifier of the access token",
"format": "int64",
"type": "integer",
"x-go-name": "ID"
},
"last_used_at": {
"description": "The timestamp when the token was last used",
"format": "date-time",
"type": "string",
"x-go-name": "LastUsedAt"
},
"name": {
"description": "The name of the access token",
"type": "string",
"x-go-name": "Name"
},
"scopes": {
"description": "The scopes granted to this access token",
"items": {
"type": "string"
},
"type": "array",
"x-go-name": "Scopes"
},
"user": {
"$ref": "#/components/schemas/UserMeta"
}
},
"title": "CurrentAccessToken represents the metadata of the currently authenticated token.",
"type": "object",
"x-go-package": "gitea.dev/modules/structs"
},
"DeleteEmailOption": {
"description": "DeleteEmailOption options when deleting email addresses",
"properties": {
@@ -10454,6 +10505,24 @@
"type": "object",
"x-go-package": "gitea.dev/models/activities"
},
"UserMeta": {
"properties": {
"id": {
"description": "The unique identifier of the user",
"format": "int64",
"type": "integer",
"x-go-name": "ID"
},
"login": {
"description": "The username of the user",
"type": "string",
"x-go-name": "Login"
}
},
"title": "UserMeta represents minimal user information for the token owner.",
"type": "object",
"x-go-package": "gitea.dev/modules/structs"
},
"UserSettings": {
"description": "UserSettings represents user settings",
"properties": {
@@ -31385,6 +31454,32 @@
]
}
},
"/token": {
"delete": {
"operationId": "deleteCurrentToken",
"responses": {
"204": {
"description": "token deleted"
}
},
"summary": "Delete the currently authenticated token",
"tags": [
"miscellaneous"
]
},
"get": {
"operationId": "getCurrentToken",
"responses": {
"200": {
"$ref": "#/components/responses/CurrentAccessToken"
}
},
"summary": "Get the currently authenticated token",
"tags": [
"miscellaneous"
]
}
},
"/topics/search": {
"get": {
"operationId": "topicSearch",

View File

@@ -0,0 +1,98 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"net/http"
"testing"
auth_model "gitea.dev/models/auth"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
api "gitea.dev/modules/structs"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
)
// TestAPIGetCurrentToken tests getting metadata of the currently authenticated token
func TestAPIGetCurrentToken(t *testing.T) {
defer tests.PrepareTestEnv(t)()
t.Run("Success with all scopes", func(t *testing.T) {
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
accessToken := createAPIAccessTokenWithoutCleanUp(t, "test-get-current-token-all", user, []auth_model.AccessTokenScope{auth_model.AccessTokenScopeAll})
req := NewRequest(t, "GET", "/api/v1/token").
AddTokenAuth(accessToken.Token)
resp := MakeRequest(t, req, http.StatusOK)
currentToken := DecodeJSON(t, resp, &api.CurrentAccessToken{})
assert.Equal(t, accessToken.ID, currentToken.ID)
assert.Equal(t, accessToken.Name, currentToken.Name)
assert.Equal(t, user.ID, currentToken.User.ID)
assert.Equal(t, user.Name, currentToken.User.Login)
})
t.Run("Success with limited scopes", func(t *testing.T) {
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
accessToken := createAPIAccessTokenWithoutCleanUp(t, "test-get-current-token-limited", user, []auth_model.AccessTokenScope{auth_model.AccessTokenScopeReadRepository})
req := NewRequest(t, "GET", "/api/v1/token").
AddTokenAuth(accessToken.Token)
resp := MakeRequest(t, req, http.StatusOK)
currentToken := DecodeJSON(t, resp, &api.CurrentAccessToken{})
assert.Equal(t, accessToken.ID, currentToken.ID)
assert.Equal(t, accessToken.Name, currentToken.Name)
assert.Equal(t, user.ID, currentToken.User.ID)
assert.Equal(t, user.Name, currentToken.User.Login)
})
t.Run("Bad token", func(t *testing.T) {
req := NewRequest(t, "GET", "/api/v1/token").
AddTokenAuth("this does not exist")
MakeRequest(t, req, http.StatusUnauthorized)
req = NewRequest(t, "GET", "/api/v1/token")
MakeRequest(t, req, http.StatusUnauthorized)
})
}
// TestAPITokenSelfService tests delete operations on token
func TestAPITokenSelfService(t *testing.T) {
defer tests.PrepareTestEnv(t)()
t.Run("Success then verify deleted", func(t *testing.T) {
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
accessToken := createAPIAccessTokenWithoutCleanUp(t, "test-delete-current-token", user, []auth_model.AccessTokenScope{auth_model.AccessTokenScopeAll})
// Delete the token via the endpoint
req := NewRequest(t, "DELETE", "/api/v1/token").
AddTokenAuth(accessToken.Token)
MakeRequest(t, req, http.StatusNoContent)
// Verify the token is deleted
unittest.AssertNotExistsBean(t, &auth_model.AccessToken{ID: accessToken.ID})
// Verify the token can no longer be used for GET
req = NewRequest(t, "GET", "/api/v1/token").
AddTokenAuth(accessToken.Token)
MakeRequest(t, req, http.StatusUnauthorized)
// Verify the token can no longer be used for DELETE
req = NewRequest(t, "DELETE", "/api/v1/token").
AddTokenAuth(accessToken.Token)
MakeRequest(t, req, http.StatusUnauthorized)
})
t.Run("Bad token", func(t *testing.T) {
req := NewRequest(t, "DELETE", "/api/v1/token").
AddTokenAuth("this does not exist")
MakeRequest(t, req, http.StatusUnauthorized)
req = NewRequest(t, "DELETE", "/api/v1/token")
MakeRequest(t, req, http.StatusUnauthorized)
})
}