From c6167d1ff58874d823f2ad6b28b338196f9c4eda Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Sun, 14 Jun 2026 20:05:18 +0200 Subject: [PATCH] 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 Co-authored-by: wxiaoguang --- models/auth/access_token.go | 60 ++------------- models/auth/access_token_test.go | 7 +- modules/structs/token.go | 31 ++++++++ routers/api/v1/api.go | 6 ++ routers/api/v1/swagger/app.go | 7 ++ routers/api/v1/token/token.go | 88 +++++++++++++++++++++ routers/api/v1/user/app.go | 10 +-- services/auth/basic.go | 5 +- services/auth/oauth2.go | 2 +- templates/swagger/v1_json.tmpl | 97 +++++++++++++++++++++++ templates/swagger/v1_openapi3_json.tmpl | 95 +++++++++++++++++++++++ tests/integration/api_token_self_test.go | 98 ++++++++++++++++++++++++ 12 files changed, 437 insertions(+), 69 deletions(-) create mode 100644 modules/structs/token.go create mode 100644 routers/api/v1/token/token.go create mode 100644 tests/integration/api_token_self_test.go diff --git a/models/auth/access_token.go b/models/auth/access_token.go index da451fb044..63a345dfcd 100644 --- a/models/auth/access_token.go +++ b/models/auth/access_token.go @@ -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 } diff --git a/models/auth/access_token_test.go b/models/auth/access_token_test.go index 504600cd08..acab8b3ab5 100644 --- a/models/auth/access_token_test.go +++ b/models/auth/access_token_test.go @@ -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) } diff --git a/modules/structs/token.go b/modules/structs/token.go new file mode 100644 index 0000000000..af72aca487 --- /dev/null +++ b/modules/structs/token.go @@ -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"` +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 3bac1eac91..4715ca1d67 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -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. diff --git a/routers/api/v1/swagger/app.go b/routers/api/v1/swagger/app.go index dc30cda699..3097035e45 100644 --- a/routers/api/v1/swagger/app.go +++ b/routers/api/v1/swagger/app.go @@ -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"` +} diff --git a/routers/api/v1/token/token.go b/routers/api/v1/token/token.go new file mode 100644 index 0000000000..7712a7c8c2 --- /dev/null +++ b/routers/api/v1/token/token.go @@ -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) +} diff --git a/routers/api/v1/user/app.go b/routers/api/v1/user/app.go index a410909e0e..87aef1d10d 100644 --- a/routers/api/v1/user/app.go +++ b/routers/api/v1/user/app.go @@ -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 } diff --git a/services/auth/basic.go b/services/auth/basic.go index c7db14e6e7..ed2a2e1945 100644 --- a/services/auth/basic.go +++ b/services/auth/basic.go @@ -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 diff --git a/services/auth/oauth2.go b/services/auth/oauth2.go index a2f7d5d1e7..cb622c2258 100644 --- a/services/auth/oauth2.go +++ b/services/auth/oauth2.go @@ -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) diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 286bec3a50..861cb88c2a 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -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": { diff --git a/templates/swagger/v1_openapi3_json.tmpl b/templates/swagger/v1_openapi3_json.tmpl index 6fd253528e..782e9bce42 100644 --- a/templates/swagger/v1_openapi3_json.tmpl +++ b/templates/swagger/v1_openapi3_json.tmpl @@ -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", diff --git a/tests/integration/api_token_self_test.go b/tests/integration/api_token_self_test.go new file mode 100644 index 0000000000..2720c59d2c --- /dev/null +++ b/tests/integration/api_token_self_test.go @@ -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) + }) +}