From ed3ff878cf746e308dc58e4c99ccd0fd913c37ca Mon Sep 17 00:00:00 2001 From: Ryoma Kai Date: Wed, 17 Dec 2025 18:49:23 +0900 Subject: [PATCH] feat: Add GetTokens, GetTokensByID, RevokeTokenByID methods --- README.md | 34 ++++++ access/manager.go | 18 +++ access/services/accesstoken.go | 108 +++++++++++++++++- tests/accesstokens_test.go | 195 +++++++++++++++++++++++++++++++++ 4 files changed, 354 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6ab92bd43..b49e7fbe1 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,9 @@ - [Creating an Access Token](#creating-an-access-token) - [Refreshing an Access Token](#refreshing-an-access-token) - [Exchanging an OIDC Access Token](#exchanging-an-oidc-access-token) + - [Getting Access Tokens](#getting-access-tokens) + - [Getting an Access Token by ID](#getting-an-access-token-by-id) + - [Revoking an Access Token by ID](#revoking-an-access-token-by-id) - [Distribution APIs](#distribution-apis) - [Creating Distribution Service Manager](#creating-distribution-service-manager) - [Creating Distribution Details](#creating-distribution-details) @@ -1879,6 +1882,37 @@ params := services.CreateOidcTokenParams{ response, err = servicesManager.ExchangeOidcToken(params) ``` +#### Getting Access Tokens + +```go +params := services.GetTokensParams{ + // Optional filters + Description: "my-token-description", // Filter by token description + Username: "admin", // Filter by username + Refreshable: utils.Pointer(true), // Filter by refreshable status + TokenId: "token-id", // Filter by specific token ID + OrderBy: "token_id", // Order by field (created|token_id|owner|subject|expiry) + DescendingOrder: utils.Pointer(false), // Sort order (true for descending) +} + +tokens, err := accessManager.GetTokens(params) +``` + +#### Getting an Access Token by ID + +```go +token, err := accessManager.GetTokenByID("my-token-id") + +# currently used token +token, err := accessManager.GetTokenByID("me") +``` + +#### Revoking an Access Token by ID + +```go +err := accessManager.RevokeTokenByID("my-token-id") +``` + ## Distribution APIs ### Creating Distribution Service Manager diff --git a/access/manager.go b/access/manager.go index e96ad8004..b5814e9bf 100644 --- a/access/manager.go +++ b/access/manager.go @@ -143,3 +143,21 @@ func (sm *AccessServicesManager) ExchangeOidcToken(params services.CreateOidcTok tokenService.ServiceDetails = sm.config.GetServiceDetails() return tokenService.ExchangeOidcToken(params) } + +func (sm *AccessServicesManager) GetTokens(params services.GetTokensParams) ([]services.TokenInfo, error) { + tokenService := services.NewTokenService(sm.client) + tokenService.ServiceDetails = sm.config.GetServiceDetails() + return tokenService.GetTokens(params) +} + +func (sm *AccessServicesManager) GetTokenByID(tokenId string) (*services.TokenInfo, error) { + tokenService := services.NewTokenService(sm.client) + tokenService.ServiceDetails = sm.config.GetServiceDetails() + return tokenService.GetTokenByID(tokenId) +} + +func (sm *AccessServicesManager) RevokeTokenByID(tokenId string) error { + tokenService := services.NewTokenService(sm.client) + tokenService.ServiceDetails = sm.config.GetServiceDetails() + return tokenService.RevokeTokenByID(tokenId) +} diff --git a/access/services/accesstoken.go b/access/services/accesstoken.go index d848ebbca..14e22ce4f 100644 --- a/access/services/accesstoken.go +++ b/access/services/accesstoken.go @@ -3,12 +3,15 @@ package services import ( "encoding/json" "fmt" + "net/http" + "net/url" + "strconv" + "github.com/jfrog/jfrog-client-go/auth" "github.com/jfrog/jfrog-client-go/http/jfroghttpclient" clientutils "github.com/jfrog/jfrog-client-go/utils" "github.com/jfrog/jfrog-client-go/utils/errorutils" "github.com/jfrog/jfrog-client-go/utils/io/httputils" - "net/http" ) // #nosec G101 -- False positive - no hardcoded credentials. @@ -50,6 +53,31 @@ type CreateOidcTokenParams struct { ApplicationKey string `json:"application_key,omitempty"` } +type GetTokensParams struct { + Description string `url:"description,omitempty"` + Username string `url:"username,omitempty"` + Refreshable *bool `url:"refreshable,omitempty"` + TokenId string `url:"token_id,omitempty"` + OrderBy string `url:"order_by,omitempty"` + DescendingOrder *bool `url:"descending_order,omitempty"` +} + +type TokenInfos struct { + Tokens []TokenInfo `json:"tokens"` +} + +type TokenInfo struct { + TokenId string `json:"token_id"` + Subject string `json:"subject"` + Expiry int64 `json:"expiry,omitempty"` + IssuedAt int64 `json:"issued_at"` + Issuer string `json:"issuer"` + Description string `json:"description,omitempty"` + Refreshable bool `json:"refreshable,omitempty"` + Scope string `json:"scope,omitempty"` + LastUsed int64 `json:"last_used,omitempty"` +} + func NewCreateTokenParams(params CreateTokenParams) CreateTokenParams { return CreateTokenParams{CommonTokenParams: params.CommonTokenParams, IncludeReferenceToken: params.IncludeReferenceToken} } @@ -127,6 +155,84 @@ func (ps *TokenService) ExchangeOidcToken(params CreateOidcTokenParams) (auth.Oi return tokenInfo, errorutils.CheckError(err) } +func (ps *TokenService) GetTokens(params GetTokensParams) ([]TokenInfo, error) { + httpDetails := ps.ServiceDetails.CreateHttpClientDetails() + requestUrl := fmt.Sprintf("%s%s", ps.ServiceDetails.GetUrl(), tokensApi) + + // Build query parameters manually + queryParams := url.Values{} + if params.Description != "" { + queryParams.Add("description", params.Description) + } + if params.Username != "" { + queryParams.Add("username", params.Username) + } + if params.Refreshable != nil { + queryParams.Add("refreshable", strconv.FormatBool(*params.Refreshable)) + } + if params.TokenId != "" { + queryParams.Add("token_id", params.TokenId) + } + if params.OrderBy != "" { + queryParams.Add("order_by", params.OrderBy) + } + if params.DescendingOrder != nil { + queryParams.Add("descending_order", strconv.FormatBool(*params.DescendingOrder)) + } + + if queryString := queryParams.Encode(); queryString != "" { + requestUrl += "?" + queryString + } + + resp, body, _, err := ps.client.SendGet(requestUrl, true, &httpDetails) + if err != nil { + return nil, err + } + if err = errorutils.CheckResponseStatusWithBody(resp, body, http.StatusOK); err != nil { + return nil, err + } + + var tokenInfos TokenInfos + err = json.Unmarshal(body, &tokenInfos) + return tokenInfos.Tokens, errorutils.CheckError(err) +} + +func (ps *TokenService) GetTokenByID(tokenId string) (*TokenInfo, error) { + if tokenId == "" { + return nil, errorutils.CheckErrorf("token ID cannot be empty") + } + + httpDetails := ps.ServiceDetails.CreateHttpClientDetails() + url := fmt.Sprintf("%s%s/%s", ps.ServiceDetails.GetUrl(), tokensApi, tokenId) + + resp, body, _, err := ps.client.SendGet(url, true, &httpDetails) + if err != nil { + return nil, err + } + if err = errorutils.CheckResponseStatusWithBody(resp, body, http.StatusOK); err != nil { + return nil, err + } + + var tokenInfo TokenInfo + err = json.Unmarshal(body, &tokenInfo) + return &tokenInfo, errorutils.CheckError(err) +} + +func (ps *TokenService) RevokeTokenByID(tokenId string) error { + if tokenId == "" { + return errorutils.CheckErrorf("token ID cannot be empty") + } + + httpDetails := ps.ServiceDetails.CreateHttpClientDetails() + url := fmt.Sprintf("%s%s/%s", ps.ServiceDetails.GetUrl(), tokensApi, tokenId) + + resp, body, err := ps.client.SendDelete(url, nil, &httpDetails) + if err != nil { + return err + } + return errorutils.CheckResponseStatusWithBody(resp, body, http.StatusOK, http.StatusNoContent) +} + func prepareForRefresh(p CreateTokenParams) (*CreateTokenParams, error) { // Validate provided parameters if p.RefreshToken == "" { diff --git a/tests/accesstokens_test.go b/tests/accesstokens_test.go index b2efd9aae..250388472 100644 --- a/tests/accesstokens_test.go +++ b/tests/accesstokens_test.go @@ -26,6 +26,9 @@ func TestAccessTokens(t *testing.T) { t.Run("createAccessTokenWithReference", testAccessTokenWithReference) t.Run("refreshToken", testRefreshTokenTest) t.Run("exchangeOIDCToken", testExchangeOidcToken) + t.Run("getTokens", testGetTokens) + t.Run("getTokenByID", testGetTokenByID) + t.Run("revokeTokenByID", testRevokeTokenByID) } // This test uses a mock response because the subject_token (TokenID) is not available in the test environment @@ -158,6 +161,198 @@ func testRefreshTokenTest(t *testing.T) { assert.Empty(t, token.ReferenceToken) } +func testGetTokens(t *testing.T) { + initAccessTest(t) + + // Create mock server + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/access/api/v1/tokens", r.URL.Path) + + // Check query parameters + query := r.URL.Query() + if len(query) > 0 { + assert.Equal(t, "admin", query.Get("username")) + assert.Equal(t, "true", query.Get("refreshable")) + assert.Equal(t, "description", query.Get("order_by")) + assert.Equal(t, "true", query.Get("descending_order")) + } + + // Mock response - wrapped in TokenInfos structure + response := services.TokenInfos{ + Tokens: []services.TokenInfo{ + { + TokenId: "test-token-1", + Subject: "jfrt@test/users/admin", + IssuedAt: 1640995200, + Issuer: "jfrt@test", + Description: "Test token 1", + Refreshable: true, + Scope: "applied-permissions/admin", + }, + { + TokenId: "test-token-2", + Subject: "jfrt@test/users/user1", + IssuedAt: 1640995200, + Issuer: "jfrt@test", + Description: "Test token 2", + Refreshable: false, + Scope: "applied-permissions/user", + }, + }, + } + + responseBody, err := json.Marshal(response) + assert.NoError(t, err) + + w.WriteHeader(http.StatusOK) + _, err = w.Write(responseBody) + assert.NoError(t, err) + }) + ts := httptest.NewServer(handler) + defer ts.Close() + + // Setup JFrog client + client, err := jfroghttpclient.JfrogClientBuilder(). + SetInsecureTls(true). + Build() + assert.NoError(t, err, "Failed to create JFrog client") + + // Setup TokenService + service := services.NewTokenService(client) + serverDetails := accessAuth.NewAccessDetails() + serverDetails.SetUrl(ts.URL + "/access") + service.ServiceDetails = serverDetails + + // Test GetTokens without parameters + params := services.GetTokensParams{} + tokens, err := service.GetTokens(params) + + // Verify response + assert.NoError(t, err) + assert.NotNil(t, tokens) + assert.Len(t, tokens, 2) + assert.Equal(t, "test-token-1", tokens[0].TokenId) + assert.Equal(t, "jfrt@test/users/admin", tokens[0].Subject) + assert.Equal(t, "jfrt@test", tokens[0].Issuer) + assert.True(t, tokens[0].Refreshable) + assert.Equal(t, "test-token-2", tokens[1].TokenId) + assert.Equal(t, "jfrt@test/users/user1", tokens[1].Subject) + assert.Equal(t, "jfrt@test", tokens[1].Issuer) + assert.False(t, tokens[1].Refreshable) + + // Test GetTokens with parameters + paramsWithFilters := services.GetTokensParams{ + Username: "admin", + Refreshable: utils.Pointer(true), + OrderBy: "description", + DescendingOrder: utils.Pointer(true), + } + tokens, err = service.GetTokens(paramsWithFilters) + + // Verify response + assert.NoError(t, err) + assert.NotNil(t, tokens) + assert.Len(t, tokens, 2) +} + +func testGetTokenByID(t *testing.T) { + initAccessTest(t) + + // Create mock server + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/access/api/v1/tokens/test-token-1", r.URL.Path) + + // Mock response for single token + token := services.TokenInfo{ + TokenId: "test-token-1", + Subject: "jfrt@test/users/admin", + IssuedAt: 1640995200, + Issuer: "jfrt@test", + Description: "Test token 1", + Refreshable: true, + Scope: "applied-permissions/admin", + } + + responseBody, err := json.Marshal(token) + assert.NoError(t, err) + + w.WriteHeader(http.StatusOK) + _, err = w.Write(responseBody) + assert.NoError(t, err) + }) + ts := httptest.NewServer(handler) + defer ts.Close() + + // Setup JFrog client + client, err := jfroghttpclient.JfrogClientBuilder(). + SetInsecureTls(true). + Build() + assert.NoError(t, err, "Failed to create JFrog client") + + // Setup TokenService + service := services.NewTokenService(client) + serverDetails := accessAuth.NewAccessDetails() + serverDetails.SetUrl(ts.URL + "/access") + service.ServiceDetails = serverDetails + + // Test GetTokenByID + token, err := service.GetTokenByID("test-token-1") + + // Verify response + assert.NoError(t, err) + assert.NotNil(t, token) + assert.Equal(t, "test-token-1", token.TokenId) + assert.Equal(t, "jfrt@test/users/admin", token.Subject) + assert.Equal(t, "jfrt@test", token.Issuer) + assert.True(t, token.Refreshable) + assert.Equal(t, "Test token 1", token.Description) + + // Test error case with empty token ID + _, err = service.GetTokenByID("") + assert.Error(t, err) + assert.Contains(t, err.Error(), "token ID cannot be empty") +} + +func testRevokeTokenByID(t *testing.T) { + initAccessTest(t) + + // Create mock server + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + assert.Equal(t, "/access/api/v1/tokens/test-token-1", r.URL.Path) + + // Mock successful deletion response + w.WriteHeader(http.StatusOK) + }) + ts := httptest.NewServer(handler) + defer ts.Close() + + // Setup JFrog client + client, err := jfroghttpclient.JfrogClientBuilder(). + SetInsecureTls(true). + Build() + assert.NoError(t, err, "Failed to create JFrog client") + + // Setup TokenService + service := services.NewTokenService(client) + serverDetails := accessAuth.NewAccessDetails() + serverDetails.SetUrl(ts.URL + "/access") + service.ServiceDetails = serverDetails + + // Test RevokeTokenByID + err = service.RevokeTokenByID("test-token-1") + + // Verify response + assert.NoError(t, err) + + // Test error case with empty token ID + err = service.RevokeTokenByID("") + assert.Error(t, err) + assert.Contains(t, err.Error(), "token ID cannot be empty") +} + func createRefreshableAccessTokenParams(expiredIn uint) services.CreateTokenParams { tokenParams := services.CreateTokenParams{} tokenParams.ExpiresIn = &expiredIn