Skip to content

Commit

Permalink
Merge pull request #152 from jfrog/add-support-for-expiring-token-and…
Browse files Browse the repository at this point in the history
…-access-token-config

Add support for expiring token and access token config
  • Loading branch information
alexhung authored Feb 14, 2024
2 parents b93107a + 54aacdd commit 473c146
Show file tree
Hide file tree
Showing 12 changed files with 219 additions and 55 deletions.
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ usertoken:
vault write $(PLUGIN_VAULT_PATH)/config/admin url=$(JFROG_URL) access_token=$(JFROG_ACCESS_TOKEN)
vault write $(PLUGIN_VAULT_PATH)/config/user_token default_description="Vault Test"
vault read $(PLUGIN_VAULT_PATH)/config/user_token
vault read $(PLUGIN_VAULT_PATH)/user_token/test refreshable=true include_reference_token=true
vault read $(PLUGIN_VAULT_PATH)/user_token/test refreshable=true include_reference_token=true use_expiring_tokens=true

testrole:
vault write $(PLUGIN_VAULT_PATH)/roles/test scope="$(ARTIFACTORY_SCOPE)" max_ttl=3h default_ttl=2h
Expand Down
34 changes: 30 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ Using this plugin, you can limit the accidental exposure window of Artifactory t

This backend creates access tokens in Artifactory using the admin credentials provided. Note that if you provide non-administrative credentials, then the "username" must match the username of the credential owner.

Visit [JFrog Help Center](https://jfrog.com/help/r/jfrog-platform-administration-documentation/introduction-to-access-tokens) for more information on Access Tokens.

### Admin Token Expiration Notice

> [!IMPORTANT]
Expand Down Expand Up @@ -46,8 +48,8 @@ By default, the Vault generated Artifactory tokens will not show an expiration d
automatically revoke them. Vault will revoke the token when its lease expires due to logout or timeout (ttl/max_ttl). The reason
for this is because of the [default Revocable/Persistency Thresholds][artifactory-token-thresholds] in Artifactory. If you would
like the artifactory token itself to show an expiration, and you are using Artifactory v7.50.3 or higher, you can write
`use_expiring_tokens=true` to the `/artifactory/config/admin` endpoint. This will set the `force_revocable=true` parameter and
set `expires_in` to either max lease TTL or role max_ttl, whichever is lower, when a token is created, overriding the default
`use_expiring_tokens=true` to the `/artifactory/config/admin` path. This will set the `force_revocable=true` parameter and
set `expires_in` to either max lease TTL or role's `max_ttl`, whichever is lower, when a token is created, overriding the default
thresholds mentioned above.

Example:
Expand Down Expand Up @@ -87,6 +89,7 @@ Token claims
### Artifactory Version Detection

Some of the functionality of this plugin requires certain versions of Artifactory. For example, as of Artifactory 7.50.3, we can optionally set the `force_revocable` flag and set the expiration of the token to `max_ttl`.

If you have upgraded Artifactory after installing this plugin, and would like to take advantage of newer features, you can issue an empty write to the `artifactory/config/admin` endpoint to re-detect the version, or it will re-detect upon reload.

Example:
Expand Down Expand Up @@ -251,6 +254,17 @@ username vault-admin
version 7.55.6
```

#### Use expiring tokens

To enable creation of token that expires using TTL (system default, system max TTL, or config overrides), set `use_expiring_tokens` to `true`, e.g.

```sh
vault write artifactory/config/admin \
url=https://artifactory.example.org \
access_token=$TOKEN \
use_expiring_tokens=true
```

## Usage

Create a role (scope for artifactory >= 7.21.1)
Expand Down Expand Up @@ -314,15 +328,21 @@ username v-jenkins-x4mohTA8

### User Token Path

User tokens may be obtained from the `/artifactory/user_token/<user-name>` endpoint. This is useful in conjunction with [ACL Policy Path Templating](https://developer.hashicorp.com/vault/tutorials/policies/policy-templating) to allow users authenticated to Vault to obtain API tokens in Artfactory for their own account. Be careful to ensure that Vault authentication methods & policies align with user account names in Artifactory. For example the following policy allows users authenticated to the `azure-ad-oidc` authentication mount to obtain a token for Artifactory for themselves, assuming the `upn` metadata is populated in Vault during authentication.
User tokens may be obtained from the `/artifactory/user_token/<user-name>` endpoint. This is useful in conjunction with [ACL Policy Path Templating](https://developer.hashicorp.com/vault/tutorials/policies/policy-templating) to allow users authenticated to Vault to obtain API tokens in Artfactory for their own account. Be careful to ensure that Vault authentication methods & policies align with user account names in Artifactory.

For example the following policy allows users authenticated to the `azure-ad-oidc` authentication mount to obtain a token for Artifactory for themselves, assuming the `upn` metadata is populated in Vault during authentication.

```
path "artifactory/user_token/{{identity.entity.aliases.azure-ad-oidc.metadata.upn}}" {
capabilities = [ "read" ]
}
```

Default values for the token's `description`, `ttl`, `max_ttl`, `audience`, `refreshable`, and `include_reference_token` may be configured at the `/artifactory/config/user_token` endpoint. TTL rules follow Vault's [general cases](https://developer.hashicorp.com/vault/docs/concepts/tokens#the-general-case) and [token hierarchy](https://developer.hashicorp.com/vault/docs/concepts/tokens#token-hierarchies-and-orphan-tokens). The desired lease TTL will be determined by the most specific TTL value specified with the request ttl parameter being highest precedence, followed by the plugin configuration, secret mount tuning, or system default ttl. The maximum TTL value allowed is limited to the lowest value of the `max_ttl` setting set on the system, secret mount tuning, plugin configuration, or the specific request.
Default values for the token's `access_token`, `description`, `ttl`, `max_ttl`, `audience`, `refreshable`, `include_reference_token`, and `use_expiring_tokens` may be configured at the `/artifactory/config/user_token` path.

`access_token` field allows the use of user's identity token in place of the admin access token from the `/artifactory/config/admin` path, enabling creating access token scoped to that user only.

TTL rules follow Vault's [general cases](https://developer.hashicorp.com/vault/docs/concepts/tokens#the-general-case) and [token hierarchy](https://developer.hashicorp.com/vault/docs/concepts/tokens#token-hierarchies-and-orphan-tokens). The desired lease TTL will be determined by the most specific TTL value specified with the request ttl parameter being highest precedence, followed by the plugin configuration, secret mount tuning, or system default ttl. The maximum TTL value allowed is limited to the lowest value of the `max_ttl` setting set on the system, secret mount tuning, plugin configuration, or the specific request.

Example Token Configuration:

Expand Down Expand Up @@ -409,6 +429,12 @@ make setup
make admin
```

Generate an user token:

```sh
make usertoken
```

NOTE: Each time you rebuild (`make`), vault will restart, so you will need to run `make setup` again, since vault is in dev mode.

* Once you are done testing, you can destroy the local artifactory instance:
Expand Down
34 changes: 16 additions & 18 deletions artifactory.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ func (b *backend) RevokeToken(config adminConfiguration, secret logical.Secret)
values := url.Values{}
values.Set("token", accessToken)

resp, err = b.performArtifactoryPost(config, u.Path+"/api/security/token/revoke", values)
resp, err = b.performArtifactoryPost(config, u.Path+"/artifactory/api/security/token/revoke", values)
if err != nil {
b.Logger().Error("error deleting token", "tokenId", tokenId, "response", resp, "err", err)
return err
Expand All @@ -62,15 +62,13 @@ func (b *backend) RevokeToken(config adminConfiguration, secret logical.Secret)
defer resp.Body.Close()

if resp.StatusCode >= http.StatusBadRequest {
e := fmt.Errorf("could not revoke tokenID: %v - HTTP response %v", tokenId, resp.StatusCode)

var errResp errorResponse
if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil {
b.Logger().Error("revokenToken could not parse error response body", "err", err)
return e
body, err := io.ReadAll(resp.Body)
if err != nil {
b.Logger().Error("revokenToken could not read error response body", "err", err)
return fmt.Errorf("could not parse response body. Err: %v", err)
}
b.Logger().Error("revokeToken got bad http status code", "statusCode", resp.StatusCode, "body", errResp)
return fmt.Errorf("could not revoke tokenID: %v - %s", tokenId, errResp.Detail)
b.Logger().Error("revokenToken got non-200 status code", "statusCode", resp.StatusCode, "body", string(body))
return fmt.Errorf("could not revoke tokenID: %v - HTTP response %v", tokenId, body)
}

return nil
Expand Down Expand Up @@ -116,7 +114,7 @@ func (b *backend) CreateToken(config adminConfiguration, role artifactoryRole) (

u, err := url.Parse(config.ArtifactoryURL)
if err != nil {
b.Logger().Error("could not parse artifactory url", "url", u, "err", err)
b.Logger().Error("could not parse artifactory url", "url", config.ArtifactoryURL, "err", err)
return nil, err
}

Expand All @@ -125,7 +123,7 @@ func (b *backend) CreateToken(config adminConfiguration, role artifactoryRole) (
if b.useNewAccessAPI() {
path = "/access/api/v1/tokens"
} else {
path = u.Path + "/api/security/token"
path = u.Path + "/artifactory/api/security/token"
}

jsonReq, err := json.Marshal(request)
Expand All @@ -145,19 +143,19 @@ func (b *backend) CreateToken(config adminConfiguration, role artifactoryRole) (
if resp.StatusCode != http.StatusOK {
e := fmt.Errorf("could not create access token: HTTP response %v", resp.StatusCode)

var errResp errorResponse
if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil {
b.Logger().Error("revokenToken could not parse error response body", "err", err)
return nil, e
body, err := io.ReadAll(resp.Body)
if err != nil {
b.Logger().Error("createToken could not read error response body", "err", err)
return nil, fmt.Errorf("could not parse response body. Err: %v", e)
}
b.Logger().Error("createToken got non-200 status code", "statusCode", resp.StatusCode, "body", errResp)
return nil, fmt.Errorf("could not create access token: HTTP response: %s", errResp.Detail)
b.Logger().Error("createToken got non-200 status code", "statusCode", resp.StatusCode, "body", string(body))
return nil, fmt.Errorf("could not create access token. HTTP response: %s", body)
}

var createdToken createTokenResponse
if err := json.NewDecoder(resp.Body).Decode(&createdToken); err != nil {
b.Logger().Error("could not parse response", "response", resp, "err", err)
return nil, err
return nil, fmt.Errorf("could not create access token. Err: %v", err)
}

return &createdToken, nil
Expand Down
6 changes: 3 additions & 3 deletions artifactory_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ func TestBackend_CreateTokenSuccess(t *testing.T) {

b, config := configuredBackend(t, map[string]interface{}{
"access_token": "test-access-token",
"url": "http://myserver.com:80/artifactory",
"url": "http://myserver.com:80",
})

// Setup a role
Expand Down Expand Up @@ -184,7 +184,7 @@ func TestBackend_CreateTokenArtifactoryMisconfigured(t *testing.T) {

b, config := configuredBackend(t, map[string]interface{}{
"access_token": "test-access-token",
"url": "http://myserver.com:80/artifactory",
"url": "http://myserver.com:80",
})

// Setup a role
Expand Down Expand Up @@ -235,7 +235,7 @@ func TestBackend_RevokeToken(t *testing.T) {

b, config := configuredBackend(t, map[string]interface{}{
"access_token": "test-access-token",
"url": "http://myserver.com:80/artifactory",
"url": "http://myserver.com:80",
})

// Setup a role
Expand Down
2 changes: 2 additions & 0 deletions path_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ func (b *backend) pathConfig() *framework.Path {
},
"use_expiring_tokens": {
Type: framework.TypeBool,
Default: false,
Description: "Optional. If Artifactory version >= 7.50.3, set expires_in to max_ttl and force_revocable.",
},
"bypass_artifactory_tls_verification": {
Expand Down Expand Up @@ -195,6 +196,7 @@ func (b *backend) pathConfigRead(ctx context.Context, req *logical.Request, _ *f
"access_token_sha256": fmt.Sprintf("%x", accessTokenHash[:]),
"url": config.ArtifactoryURL,
"version": b.version,
"use_expiring_tokens": config.UseExpiringTokens,
"bypass_artifactory_tls_verification": config.BypassArtifactoryTLSVerification,
}

Expand Down
38 changes: 31 additions & 7 deletions path_config_user_token.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package artifactory

import (
"context"
"crypto/sha256"
"fmt"
"time"

"github.com/hashicorp/vault/sdk/framework"
Expand All @@ -12,6 +14,10 @@ func (b *backend) pathConfigUserToken() *framework.Path {
return &framework.Path{
Pattern: "config/user_token",
Fields: map[string]*framework.FieldSchema{
"access_token": {
Type: framework.TypeString,
Description: "User identity token to access Artifactory",
},
"audience": {
Type: framework.TypeString,
Description: `Optional. See the JFrog Artifactory REST documentation on "Create Token" for a full and up to date description.`,
Expand All @@ -26,6 +32,11 @@ func (b *backend) pathConfigUserToken() *framework.Path {
Default: false,
Description: `Optional. Defaults to 'false'. Generate a Reference Token (alias to Access Token) in addition to the full token (available from Artifactory 7.38.10). A reference token is a shorter, 64-character string, which can be used as a bearer token, a password, or with the ״X-JFrog-Art-Api״ header. Note: Using the reference token might have performance implications over a full length token.`,
},
"use_expiring_tokens": {
Type: framework.TypeBool,
Default: false,
Description: "Optional. If Artifactory version >= 7.50.3, set expires_in to max_ttl and force_revocable.",
},
"default_ttl": {
Type: framework.TypeDurationSecond,
Description: `Optional. Default TTL for issued user access tokens. If unset, uses the backend's default_ttl. Cannot exceed max_ttl.`,
Expand Down Expand Up @@ -55,9 +66,11 @@ func (b *backend) pathConfigUserToken() *framework.Path {
}

type userTokenConfiguration struct {
AccessToken string `json:"access_token"`
Audience string `json:"audience,omitempty"`
Refreshable bool `json:"refreshable,omitempty"`
IncludeReferenceToken bool `json:"include_reference_token,omitempty"`
UseExpiringTokens bool `json:"use_expiring_tokens,omitempty"`
DefaultTTL time.Duration `json:"default_ttl,omitempty"`
MaxTTL time.Duration `json:"max_ttl,omitempty"`
DefaultDescription string `json:"default_description,omitempty"`
Expand Down Expand Up @@ -104,6 +117,10 @@ func (b *backend) pathConfigUserTokenUpdate(ctx context.Context, req *logical.Re
return nil, err
}

if val, ok := data.GetOk("access_token"); ok {
userTokenConfig.AccessToken = val.(string)
}

if val, ok := data.GetOk("audience"); ok {
userTokenConfig.Audience = val.(string)
}
Expand All @@ -116,6 +133,10 @@ func (b *backend) pathConfigUserTokenUpdate(ctx context.Context, req *logical.Re
userTokenConfig.IncludeReferenceToken = val.(bool)
}

if val, ok := data.GetOk("use_expiring_tokens"); ok {
userTokenConfig.UseExpiringTokens = val.(bool)
}

if val, ok := data.GetOk("default_ttl"); ok {
userTokenConfig.DefaultTTL = time.Duration(val.(int)) * time.Second
}
Expand Down Expand Up @@ -145,43 +166,46 @@ func (b *backend) pathConfigUserTokenRead(ctx context.Context, req *logical.Requ
b.configMutex.RLock()
defer b.configMutex.RUnlock()

config, err := b.fetchAdminConfiguration(ctx, req.Storage)
adminConfig, err := b.fetchAdminConfiguration(ctx, req.Storage)
if err != nil {
return nil, err
}

if config == nil {
if adminConfig == nil {
return logical.ErrorResponse("backend not configured"), nil
}

go b.sendUsage(*config, "pathConfigUserTokenRead")
go b.sendUsage(*adminConfig, "pathConfigUserTokenRead")

userTokenConfig, err := b.fetchUserTokenConfiguration(ctx, req.Storage)
if err != nil {
return nil, err
}

accessTokenHash := sha256.Sum256([]byte(userTokenConfig.AccessToken))

configMap := map[string]interface{}{
"access_token_sha256": fmt.Sprintf("%x", accessTokenHash[:]),
"audience": userTokenConfig.Audience,
"refreshable": userTokenConfig.Refreshable,
"include_reference_token": userTokenConfig.IncludeReferenceToken,
"use_expiring_tokens": userTokenConfig.UseExpiringTokens,
"default_ttl": userTokenConfig.DefaultTTL.Seconds(),
"max_ttl": userTokenConfig.MaxTTL.Seconds(),
"default_description": userTokenConfig.DefaultDescription,
}

// Optionally include token info if it parses properly
token, err := b.getTokenInfo(*config, config.AccessToken)
token, err := b.getTokenInfo(*adminConfig, adminConfig.AccessToken)
if err != nil {
b.Logger().Warn("Error parsing AccessToken: " + err.Error())
b.Logger().Warn("Error parsing AccessToken", "err", err.Error())
} else {
configMap["token_id"] = token.TokenID
configMap["username"] = token.Username
configMap["scope"] = token.Scope
if token.Expires > 0 {
configMap["exp"] = token.Expires
tm := time.Unix(token.Expires, 0)
configMap["expires"] = tm.Local()
configMap["expires"] = time.Unix(token.Expires, 0).Local()
}
}

Expand Down
21 changes: 21 additions & 0 deletions path_config_user_token_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,31 @@ func TestAcceptanceBackend_PathConfigUserToken(t *testing.T) {
}
accTestEnv := NewConfiguredAcceptanceTestEnv(t)

t.Run("update access_token", accTestEnv.PathConfigAccessTokenUpdate)
t.Run("update default_description", accTestEnv.PathConfigDefaultDescriptionUpdate)
t.Run("update audience", accTestEnv.PathConfigAudienceUpdate)
t.Run("update refreshable", accTestEnv.PathConfigRefreshableUpdate)
t.Run("update include_reference_token", accTestEnv.PathConfigIncludeReferenceTokenUpdate)
t.Run("update use_expiring_tokens", accTestEnv.PathConfigUseExpiringTokensUpdate)
t.Run("update default_ttl", accTestEnv.PathConfigDefaultTTLUpdate)
t.Run("update max_ttl", accTestEnv.PathConfigMaxTTLUpdate)
}

func (e *accTestEnv) PathConfigAccessTokenUpdate(t *testing.T) {
e.UpdateConfigUserToken(t, testData{
"access_token": "test123",
})
data := e.ReadConfigUserToken(t)
accessTokenHash := data["access_token_sha256"]
assert.NotEmpty(t, "access_token_sha256")

e.UpdateConfigUserToken(t, testData{
"access_token": "test456",
})
data = e.ReadConfigUserToken(t)
assert.NotEqual(t, data["access_token_sha256"], accessTokenHash)
}

func (e *accTestEnv) PathConfigDefaultDescriptionUpdate(t *testing.T) {
e.pathConfigUserTokenUpdateStringField(t, "default_description")
}
Expand All @@ -36,6 +53,10 @@ func (e *accTestEnv) PathConfigIncludeReferenceTokenUpdate(t *testing.T) {
e.pathConfigUserTokenUpdateBoolField(t, "include_reference_token")
}

func (e *accTestEnv) PathConfigUseExpiringTokensUpdate(t *testing.T) {
e.pathConfigUserTokenUpdateBoolField(t, "use_expiring_tokens")
}

func (e *accTestEnv) PathConfigDefaultTTLUpdate(t *testing.T) {
e.pathConfigUserTokenUpdateDurationField(t, "default_ttl")
}
Expand Down
3 changes: 3 additions & 0 deletions path_token_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ func (b *backend) pathTokenCreatePerform(ctx context.Context, req *logical.Reque
"access_token": resp.AccessToken,
"refresh_token": resp.RefreshToken,
"role": roleName,
"expires_in": resp.ExpiresIn,
"scope": resp.Scope,
"token_id": resp.TokenId,
"username": role.Username,
Expand All @@ -132,6 +133,8 @@ func (b *backend) pathTokenCreatePerform(ctx context.Context, req *logical.Reque
"role": roleName,
"access_token": resp.AccessToken,
"refresh_token": resp.RefreshToken,
"expires_in": resp.ExpiresIn,
"scope": resp.Scope,
"token_id": resp.TokenId,
"username": role.Username,
"reference_token": resp.ReferenceToken,
Expand Down
Loading

0 comments on commit 473c146

Please sign in to comment.