From 0ef80f6b0f711d9d4d3c416c64a99ba10183c854 Mon Sep 17 00:00:00 2001 From: "steven.guiheux" Date: Wed, 20 May 2026 18:45:38 +0200 Subject: [PATCH] feat: expose access token creation date in API responses (#12620) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Checklist Following the previous contribution that added admin-level management of user access tokens (particularly useful for bot/service accounts), this change exposes the created_at field in the API response when listing or retrieving access tokens. This information is needed to implement token rotation policies for these users — knowing when a token was created allows administrators to identify and revoke stale tokens. ### Tests for Go changes - I added test coverage for Go changes... - [X] in their respective `*_test.go` for unit tests. - [X] `make pr-go` before pushing ### Documentation - [X] I did not document these changes and I do not expect someone else to do it. ### Release notes - [X] This change will be noticed by a Forgejo user or admin (feature, bug fix, performance, etc.). I suggest to include a release note for this change. ## Release notes - Features - [PR](https://codeberg.org/forgejo/forgejo/pulls/12620): expose access token creation date in API responses Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/12620 Reviewed-by: Andreas Ahlenstorf --- modules/structs/user_app.go | 11 ++++++----- routers/api/v1/utils/access_token.go | 2 ++ templates/swagger/v1_json.tmpl | 5 +++++ tests/integration/api_admin_user_token_test.go | 2 ++ tests/integration/api_token_test.go | 2 ++ 5 files changed, 17 insertions(+), 5 deletions(-) diff --git a/modules/structs/user_app.go b/modules/structs/user_app.go index 357f3aed0a..5e27e5b0f4 100644 --- a/modules/structs/user_app.go +++ b/modules/structs/user_app.go @@ -11,11 +11,12 @@ import ( // AccessToken represents an API access token. // swagger:response AccessToken type AccessToken struct { - ID int64 `json:"id"` - Name string `json:"name"` - Token string `json:"sha1"` - TokenLastEight string `json:"token_last_eight"` - Scopes []string `json:"scopes"` + ID int64 `json:"id"` + Name string `json:"name"` + Token string `json:"sha1"` + TokenLastEight string `json:"token_last_eight"` + Scopes []string `json:"scopes"` + Created time.Time `json:"created_at"` // Indicates that an access token only has access to the specified repositories. Will be null if the access token // is not limited to a set of specified repositories. Repositories []*RepositoryMeta `json:"repositories"` diff --git a/routers/api/v1/utils/access_token.go b/routers/api/v1/utils/access_token.go index a0f6b7449b..532ba1646e 100644 --- a/routers/api/v1/utils/access_token.go +++ b/routers/api/v1/utils/access_token.go @@ -117,6 +117,7 @@ func ListAccessTokens(ctx *context.APIContext) { Name: tokens[i].Name, TokenLastEight: tokens[i].TokenLastEight, Scopes: tokens[i].Scope.StringSlice(), + Created: tokens[i].CreatedUnix.AsTime(), Repositories: reposByTokenID[tokens[i].ID], } // Provide a consistent sort order on repositories, helpful for test consistency. Hard to do any earlier @@ -229,6 +230,7 @@ func CreateAccessToken(ctx *context.APIContext) { ID: t.ID, TokenLastEight: t.TokenLastEight, Scopes: t.Scope.StringSlice(), + Created: t.CreatedUnix.AsTime(), Repositories: tokenRepositories, }) } diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index e70ed513fb..be81056880 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -22792,6 +22792,11 @@ "type": "object", "title": "AccessToken represents an API access token.", "properties": { + "created_at": { + "type": "string", + "format": "date-time", + "x-go-name": "Created" + }, "id": { "type": "integer", "format": "int64", diff --git a/tests/integration/api_admin_user_token_test.go b/tests/integration/api_admin_user_token_test.go index 5f9f26208b..a12eac131d 100644 --- a/tests/integration/api_admin_user_token_test.go +++ b/tests/integration/api_admin_user_token_test.go @@ -43,6 +43,7 @@ func TestAPIAdminCreateUserAccessToken(t *testing.T) { assert.NotEmpty(t, newToken.Token) assert.NotEmpty(t, newToken.TokenLastEight) assert.Contains(t, newToken.Scopes, "all") + assert.NotZero(t, newToken.Created) // Verify the token exists in DB unittest.AssertExistsAndLoadBean(t, &auth_model.AccessToken{ @@ -143,6 +144,7 @@ func TestAPIAdminListUserAccessTokens(t *testing.T) { if tk.Name == "list-test-token" { found = true assert.NotEmpty(t, tk.TokenLastEight) + assert.NotZero(t, tk.Created) break } } diff --git a/tests/integration/api_token_test.go b/tests/integration/api_token_test.go index 2a66bc7a98..6d83d8f33a 100644 --- a/tests/integration/api_token_test.go +++ b/tests/integration/api_token_test.go @@ -57,6 +57,7 @@ func TestAPIGetTokens(t *testing.T) { assert.Equal(t, []string{""}, at.Scopes) assert.Empty(t, at.Token) assert.Equal(t, "69d28c91", at.TokenLastEight) + assert.NotZero(t, at.Created) assert.Nil(t, at.Repositories) // not repo-specific access token - nil expected, not an empty array }) @@ -753,6 +754,7 @@ func TestAPITokenCreation(t *testing.T) { resp := MakeRequest(t, req, http.StatusCreated) var token api.AccessToken DecodeJSON(t, resp, &token) + assert.NotZero(t, token.Created) }) t.Run("repo-specific", func(t *testing.T) {