Skip to content
This repository has been archived by the owner on Nov 18, 2024. It is now read-only.

Commit

Permalink
Merge pull request #30 from puppetlabs/features/client-creds-exchange
Browse files Browse the repository at this point in the history
Implement client credentials flow
  • Loading branch information
impl authored Jan 19, 2021
2 parents c3b6dc1 + a338bc1 commit 0eef83a
Show file tree
Hide file tree
Showing 20 changed files with 1,175 additions and 338 deletions.
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,21 @@ Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

* The OAuth 2.0 client credentials flow is now supported using the new `self`
endpoints.

### Fixed

* Errors caused by configuration problems in the OIDC provider are now correctly
propagated to the HTTP response with a 400 status code.
* The `nonce` provider option for the OIDC authorization code exchange is now
passed to the ID token verification routine.
* Nonce validation is only performed during OIDC authorization code exchange or
refresh token flow if the plugin user specifies a nonce to validate against;
otherwise, it is assumed that the nonce data is invalid or non-conforming to
the OpenID Connect Core specification.

### Changed

Expand Down
89 changes: 80 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ Success! Data written to: oauth2/bitbucket/config

Once the client secret has been written, it will never be exposed again.

### Authorization code exchange flow

From a Vault client, request an authorization code URL:

```console
Expand Down Expand Up @@ -69,18 +71,43 @@ skip the auth_code_url step and pass the token directly to the creds
write instead of the response code:

```console
$ vault write oauth2/bitbucket/creds/my-user-tokens refresh_token=eyJhbGciOiJub25lIn0.eyJqdGkiOiI4YTE0NDRjMi0wYjQxLTRiZDUtODI3My0yZDBkMDczODQ4MDAifQ.
Success! Data written to: oauth2/bitbucket/creds/my-user-tokens
$ vault write oauth2/bitbucket/creds/my-user-auth \
refresh_token=TGUgZ3JpbGxlPw==
Success! Data written to: oauth2/bitbucket/creds/my-user-auth
```

### Client credentials flow

From a Vault client, simply read an arbitrary token using the `self` endpoints:

```console
$ vault read oauth2/bitbucket/self/my-machine-auth
Key Value
--- -----
access_token SSBhbSBzbyBzbWFydC4gUy1NLVItVC4=
expire_time 2021-01-16T15:38:21.105335834Z
type Bearer
```

You can configure the parameters of the identity provider's token endpoint if
needed:

```console
$ vault write oauth2/bitbucket/self/my-machine-auth/config \
scopes=repositories:read
Success! Data written to: oauth2/bitbucket/self/my-machine-auth/config
```

## Tips

For some operations, you may find that you need to provide a map of data for a
field. When using the CLI, you can repeat the name of the field for each
key-value pair of the map and use `=` to separate keys from values. For example:

```console
$ vault write oauth2/oidc/config [...] \
provider_options=issuer_url=https://login.example.com \
provider_options=extra_data_fields=id_token_claims
$ vault write oauth2/oidc/config \
provider_options=issuer_url=https://login.example.com \
provider_options=extra_data_fields=id_token_claims
```

## Endpoints
Expand Down Expand Up @@ -125,10 +152,14 @@ Retrieve an authorization code URL for the given state.

### `creds/:name`

This path is for tokens to be obtained using the OAuth 2.0 authorization code
and refresh token flows.

#### `GET` (`read`)

Retrieve a current access token for the given credential.
Reuses previous token if it is not yet expired or close to it.
Retrieve a current access token for the given credential. Reuses previous token
if it is not yet expired or close to it. Otherwise, requests a new credential
using the `refresh_token` grant type if possible.

| Name | Description | Type | Default | Required |
|------|-------------|------|---------|----------|
Expand All @@ -139,8 +170,9 @@ Reuses previous token if it is not yet expired or close to it.

#### `PUT` (`write`)

Create or update a credential after an authorization flow has returned to the
application.
Create or update a credential after an authorization code flow has returned to
the application. This request will make a request for a new credential using the
`authorization_code` grant type.

| Name | Description | Type | Default | Required |
|------|-------------|------|---------|----------|
Expand All @@ -153,6 +185,45 @@ application.

Remove the credential information from storage.

### `self/:name`

This path is for tokens to be obtained using the OAuth 2.0 client credentials
flow.

#### `GET` (`read`)

Retrieve a current access token for the underlying OAuth 2.0 application. Reuses
previous token if it is not yet expired or close to it. Otherwise, requests a
new credential using the `client_credentials` grant type.

| Name | Description | Type | Default | Required |
|------|-------------|------|---------|----------|
| `minimum_seconds` | Minimum seconds before access token expires | Integer | * | No |

\* Defaults to underlying library default, which is 10 seconds unless
the token expiration time is set to zero.

#### `DELETE` (`delete`)

Remove the credential information from storage.

### `self/:name/config`

#### `GET` (`read`)

Retrieve the configuration for the given credential, if any is present.

#### `PUT` (`write`)

Configure the credential for the given name. Writing configuration will cause a
new token to be retrieved and validated using the `client_credentials` grant
type.

| Name | Description | Type | Default | Required |
|------|-------------|------|---------|----------|
| `token_url_params` | A map of additional query string parameters to provide to the token URL. If any keys in this map conflict with the parameters stored in the configuration, the configuration's parameters take precedence. | Map of String🠦String | None | No |
| `scopes` | A list of explicit scopes to request. | List of String | None | No |

## Providers

### Bitbucket (`bitbucket`)
Expand Down
10 changes: 10 additions & 0 deletions pkg/backend/path.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
package backend

import (
"fmt"

"github.com/hashicorp/vault/sdk/framework"
"github.com/hashicorp/vault/sdk/logical"
)

// nameRegex allows most printable ASCII characters in path names that are not
// slashes.
func nameRegex(name string) string {
return fmt.Sprintf(`(?P<%s>\w(([\w.@~!_,:^-]+)?\w)?)`, name)
}

func pathsSpecial() *logical.Paths {
return &logical.Paths{
SealWrapStorage: []string{
Expand All @@ -19,5 +27,7 @@ func paths(b *backend) []*framework.Path {
pathConfig(b),
pathConfigAuthCodeURL(b),
pathCreds(b),
pathSelf(b),
pathSelfConfig(b),
}
}
29 changes: 8 additions & 21 deletions pkg/backend/path_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import (
"github.com/hashicorp/vault/sdk/logical"
"github.com/puppetlabs/leg/errmap/pkg/errmark"
"github.com/puppetlabs/vault-plugin-secrets-oauthapp/pkg/provider"
"golang.org/x/oauth2"
)

func (b *backend) configReadOperation(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
Expand Down Expand Up @@ -105,25 +104,13 @@ func (b *backend) configAuthCodeURLUpdateOperation(ctx context.Context, req *log
return logical.ErrorResponse("missing state"), nil
}

cb := c.Provider.NewAuthCodeURLConfigBuilder(c.Config.ClientID)

if redirectURL, ok := data.GetOk("redirect_url"); ok {
cb = cb.WithRedirectURL(redirectURL.(string))
}

if scopes, ok := data.GetOk("scopes"); ok {
cb = cb.WithScopes(scopes.([]string)...)
}

var opts []oauth2.AuthCodeOption
for k, v := range data.Get("auth_url_params").(map[string]string) {
opts = append(opts, oauth2.SetAuthURLParam(k, v))
}
for k, v := range c.Config.AuthURLParams {
opts = append(opts, oauth2.SetAuthURLParam(k, v))
}

url := cb.Build().AuthCodeURL(state.(string), opts...)
url := c.Provider.Public(c.Config.ClientID).AuthCodeURL(
state.(string),
provider.WithRedirectURL(data.Get("redirect_url").(string)),
provider.WithScopes(data.Get("scopes").([]string)),
provider.WithURLParams(data.Get("auth_url_params").(map[string]string)),
provider.WithURLParams(c.Config.AuthURLParams),
)

resp := &logical.Response{
Data: map[string]interface{}{
Expand Down Expand Up @@ -162,7 +149,7 @@ var configFields = map[string]*framework.FieldSchema{
}

const configHelpSynopsis = `
Configures the OAuth client information for authorization code exchange.
Configures OAuth 2.0 client information.
`

const configHelpDescription = `
Expand Down
34 changes: 15 additions & 19 deletions pkg/backend/path_creds.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ func credKey(name string) string {
func (b *backend) credsReadOperation(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
key := credKey(data.Get("name").(string))

tok, err := b.getRefreshToken(ctx, req.Storage, key, data)
tok, err := b.getRefreshAuthCodeToken(ctx, req.Storage, key, data)
switch {
case err == ErrNotConfigured:
return logical.ErrorResponse("not configured"), nil
Expand Down Expand Up @@ -82,22 +82,19 @@ func (b *backend) credsUpdateOperation(ctx context.Context, req *logical.Request

var tok *provider.Token

cb := c.Provider.NewExchangeConfigBuilder(c.Config.ClientID, c.Config.ClientSecret)

for name, value := range data.Get("provider_options").(map[string]string) {
cb = cb.WithOption(name, value)
}
ops := c.Provider.Private(c.Config.ClientID, c.Config.ClientSecret)

if code, ok := data.GetOk("code"); ok {
if _, ok := data.GetOk("refresh_token"); ok {
return logical.ErrorResponse("cannot use both code and refresh_token"), nil
}

if redirectURL, ok := data.GetOk("redirect_url"); ok {
cb = cb.WithRedirectURL(redirectURL.(string))
}

tok, err = cb.Build().Exchange(ctx, code.(string))
tok, err = ops.AuthCodeExchange(
ctx,
code.(string),
provider.WithRedirectURL(data.Get("redirect_url").(string)),
provider.WithProviderOptions(data.Get("provider_options").(map[string]string)),
)
if errmark.Matches(err, errmark.RuleType(&oauth2.RetrieveError{})) || errmark.MarkedUser(err) {
return logical.ErrorResponse(errmap.Wrap(errmark.MarkShort(err), "exchange failed").Error()), nil
} else if err != nil {
Expand All @@ -109,7 +106,11 @@ func (b *backend) credsUpdateOperation(ctx context.Context, req *logical.Request
RefreshToken: refreshToken.(string),
},
}
tok, err = cb.Build().Refresh(ctx, tok)
tok, err = ops.RefreshToken(
ctx,
tok,
provider.WithProviderOptions(data.Get("provider_options").(map[string]string)),
)
if errmark.Matches(err, errmark.RuleType(&oauth2.RetrieveError{})) || errmark.MarkedUser(err) {
return logical.ErrorResponse(errmap.Wrap(errmark.MarkShort(err), "refresh failed").Error()), nil
} else if err != nil {
Expand Down Expand Up @@ -156,6 +157,7 @@ var credsFields = map[string]*framework.FieldSchema{
"minimum_seconds": {
Type: framework.TypeInt,
Description: "Minimum remaining seconds to allow when reusing access token.",
Query: true,
},
// fields for write operation
"code": {
Expand All @@ -176,12 +178,6 @@ var credsFields = map[string]*framework.FieldSchema{
},
}

// Allow characters not special to urls or shells
// Derived from framework.GenericNameWithAtRegex
func credentialNameRegex(name string) string {
return fmt.Sprintf(`(?P<%s>\w(([\w.@~!_,:^-]+)?\w)?)`, name)
}

const credsHelpSynopsis = `
Provides access tokens for authorized credentials.
`
Expand All @@ -195,7 +191,7 @@ the access token will be available when reading the endpoint.

func pathCreds(b *backend) *framework.Path {
return &framework.Path{
Pattern: credsPathPrefix + credentialNameRegex("name") + `$`,
Pattern: credsPathPrefix + nameRegex("name") + `$`,
Fields: credsFields,
Operations: map[logical.Operation]framework.OperationHandler{
logical.ReadOperation: &framework.PathOperation{
Expand Down
22 changes: 11 additions & 11 deletions pkg/backend/path_creds_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import (
"golang.org/x/oauth2"
)

func TestBasicCredentialExchange(t *testing.T) {
func TestBasicAuthCodeExchange(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

Expand All @@ -29,7 +29,7 @@ func TestBasicCredentialExchange(t *testing.T) {
}

pr := provider.NewRegistry()
pr.MustRegister("mock", testutil.MockFactory(testutil.MockWithExchange(client, testutil.StaticMockExchange(token))))
pr.MustRegister("mock", testutil.MockFactory(testutil.MockWithAuthCodeExchange(client, testutil.StaticMockAuthCodeExchange(token))))

storage := &logical.InmemStorage{}

Expand Down Expand Up @@ -84,7 +84,7 @@ func TestBasicCredentialExchange(t *testing.T) {
require.Empty(t, resp.Data["expire_time"])
}

func TestInvalidCredentialExchange(t *testing.T) {
func TestInvalidAuthCodeExchange(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

Expand All @@ -93,12 +93,12 @@ func TestInvalidCredentialExchange(t *testing.T) {
Secret: "def",
}

exchange := testutil.RestrictMockExchange(map[string]testutil.MockExchangeFunc{
"valid": testutil.RandomMockExchange,
exchange := testutil.RestrictMockAuthCodeExchange(map[string]testutil.MockAuthCodeExchangeFunc{
"valid": testutil.RandomMockAuthCodeExchange,
})

pr := provider.NewRegistry()
pr.MustRegister("mock", testutil.MockFactory(testutil.MockWithExchange(client, exchange)))
pr.MustRegister("mock", testutil.MockFactory(testutil.MockWithAuthCodeExchange(client, exchange)))

storage := &logical.InmemStorage{}

Expand Down Expand Up @@ -138,7 +138,7 @@ func TestInvalidCredentialExchange(t *testing.T) {
require.EqualError(t, resp.Error(), "exchange failed: oauth2: cannot fetch token: Forbidden\nResponse: ")
}

func TestRefreshableCredentialExchange(t *testing.T) {
func TestRefreshableAuthCodeExchange(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

Expand All @@ -158,10 +158,10 @@ func TestRefreshableCredentialExchange(t *testing.T) {
}
}

exchange := testutil.RefreshableMockExchange(testutil.IncrementMockExchange("token_"), refresh)
exchange := testutil.RefreshableMockAuthCodeExchange(testutil.IncrementMockAuthCodeExchange("token_"), refresh)

pr := provider.NewRegistry()
pr.MustRegister("mock", testutil.MockFactory(testutil.MockWithExchange(client, exchange)))
pr.MustRegister("mock", testutil.MockFactory(testutil.MockWithAuthCodeExchange(client, exchange)))

storage := &logical.InmemStorage{}

Expand Down Expand Up @@ -239,10 +239,10 @@ func TestRefreshFailureReturnsNotConfigured(t *testing.T) {
}
}

exchange := testutil.RefreshableMockExchange(testutil.IncrementMockExchange("token_"), refresh)
exchange := testutil.RefreshableMockAuthCodeExchange(testutil.IncrementMockAuthCodeExchange("token_"), refresh)

pr := provider.NewRegistry()
pr.MustRegister("mock", testutil.MockFactory(testutil.MockWithExchange(client, exchange)))
pr.MustRegister("mock", testutil.MockFactory(testutil.MockWithAuthCodeExchange(client, exchange)))

storage := &logical.InmemStorage{}

Expand Down
Loading

0 comments on commit 0eef83a

Please sign in to comment.