Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support OAuth Access tokens #147

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ type AzureProvider interface {
ApplicationsClient
GroupsClient
ServicePrincipalClient
TokenClient

CreateRoleAssignment(
ctx context.Context,
Expand Down
30 changes: 30 additions & 0 deletions api/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package api

import (
"github.com/Azure/go-autorest/autorest/adal"
"github.com/Azure/go-autorest/autorest/azure/auth"
)

type TokenClient interface {
GetToken(c auth.ClientCredentialsConfig) (adal.Token, error)
}

var _ TokenClient = (*AccessTokenClient)(nil)

type AccessTokenClient struct{}

// GetToken fetches a new Azure OAuth2 bearer token from the given clients
// credentials and tenant.
func (p *AccessTokenClient) GetToken(c auth.ClientCredentialsConfig) (adal.Token, error) {
t, err := c.ServicePrincipalToken()
if err != nil {
return adal.Token{}, err
}

err = t.Refresh()
if err != nil {
return adal.Token{}, err
}

return t.Token(), nil
}
1 change: 1 addition & 0 deletions backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ func backend() *azureSecretBackend {
Paths: framework.PathAppend(
pathsRole(&b),
[]*framework.Path{
pathAccessToken(&b),
pathConfig(&b),
pathServicePrincipal(&b),
pathRotateRoot(&b),
Expand Down
15 changes: 8 additions & 7 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ func (c *client) assignRoles(ctx context.Context, spID string, roles []*AzureRol
ra, err := c.provider.CreateRoleAssignment(ctx, role.Scope, assignmentID,
authorization.RoleAssignmentCreateParameters{
RoleAssignmentProperties: &authorization.RoleAssignmentProperties{
RoleDefinitionID: &role.RoleID,
RoleDefinitionID: to.StringPtr(role.RoleID),
PrincipalID: &spID,
},
})
Expand All @@ -154,6 +154,7 @@ func (c *client) assignRoles(ctx context.Context, spID string, roles []*AzureRol
return nil, false, nil
}

role.RoleAssignmentID = to.String(ra.ID)
return to.String(ra.ID), true, err
})

Expand All @@ -171,11 +172,11 @@ func (c *client) assignRoles(ctx context.Context, spID string, roles []*AzureRol
// This is a clean-up operation that isn't essential to revocation. As such, an
// attempt is made to remove all assignments, and not return immediately if there
// is an error.
func (c *client) unassignRoles(ctx context.Context, roleIDs []string) error {
func (c *client) unassignRoles(ctx context.Context, roles []*AzureRole) error {
var merr *multierror.Error

for _, id := range roleIDs {
if _, err := c.provider.DeleteRoleAssignmentByID(ctx, id); err != nil {
for _, role := range roles {
if _, err := c.provider.DeleteRoleAssignmentByID(ctx, role.RoleAssignmentID); err != nil {
merr = multierror.Append(merr, fmt.Errorf("error unassigning role: %w", err))
}
}
Expand Down Expand Up @@ -209,11 +210,11 @@ func (c *client) addGroupMemberships(ctx context.Context, spID string, groups []
// groups. This is a clean-up operation that isn't essential to revocation. As
// such, an attempt is made to remove all memberships, and not return
// immediately if there is an error.
func (c *client) removeGroupMemberships(ctx context.Context, servicePrincipalObjectID string, groupIDs []string) error {
func (c *client) removeGroupMemberships(ctx context.Context, servicePrincipalObjectID string, groups []*AzureGroup) error {
var merr *multierror.Error

for _, id := range groupIDs {
if err := c.provider.RemoveGroupMember(ctx, servicePrincipalObjectID, id); err != nil {
for _, group := range groups {
if err := c.provider.RemoveGroupMember(ctx, servicePrincipalObjectID, group.ObjectID); err != nil {
merr = multierror.Append(merr, fmt.Errorf("error removing group membership: %w", err))
}
}
Expand Down
7 changes: 4 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,13 @@ go 1.19
require (
github.com/Azure/azure-sdk-for-go v67.0.0+incompatible
github.com/Azure/go-autorest/autorest v0.11.28
github.com/Azure/go-autorest/autorest/adal v0.9.18
github.com/Azure/go-autorest/autorest/azure/auth v0.5.11
github.com/Azure/go-autorest/autorest/date v0.3.0
github.com/Azure/go-autorest/autorest/to v0.4.0
github.com/Azure/go-autorest/autorest/validation v0.2.0 // indirect
github.com/fatih/color v1.13.0 // indirect
github.com/fatih/structs v1.1.0
github.com/go-test/deep v1.0.8
github.com/golang/mock v1.6.0
github.com/hashicorp/go-hclog v1.3.1
Expand All @@ -21,17 +25,14 @@ require (

require (
github.com/Azure/go-autorest v14.2.0+incompatible // indirect
github.com/Azure/go-autorest/autorest/adal v0.9.18 // indirect
github.com/Azure/go-autorest/autorest/azure/cli v0.4.5 // indirect
github.com/Azure/go-autorest/autorest/validation v0.2.0 // indirect
github.com/Azure/go-autorest/logger v0.2.1 // indirect
github.com/Azure/go-autorest/tracing v0.6.0 // indirect
github.com/armon/go-metrics v0.3.9 // indirect
github.com/armon/go-radix v1.0.0 // indirect
github.com/cenkalti/backoff/v3 v3.0.0 // indirect
github.com/dimchansky/utfbom v1.1.1 // indirect
github.com/evanphx/json-patch/v5 v5.5.0 // indirect
github.com/fatih/color v1.13.0 // indirect
github.com/golang-jwt/jwt/v4 v4.2.0 // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/golang/snappy v0.0.4 // indirect
Expand Down
1 change: 1 addition & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5Kwzbycv
github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/frankban/quicktest v1.13.0 h1:yNZif1OkDfNoDfb9zZa9aXIpejNR4F23Wely0c+Qdqk=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
Expand Down
126 changes: 126 additions & 0 deletions path_access_token.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package azuresecrets

import (
"context"
"fmt"
"strings"

azureadal "github.com/Azure/go-autorest/autorest/adal"
azureauth "github.com/Azure/go-autorest/autorest/azure/auth"
"github.com/fatih/structs"
"github.com/hashicorp/vault/sdk/framework"
"github.com/hashicorp/vault/sdk/logical"
)

const (
azureAppNotFoundErrCode = 700016
)

func pathAccessToken(b *azureSecretBackend) *framework.Path {
return &framework.Path{
Pattern: fmt.Sprintf("token/%s", framework.GenericNameRegex("role")),
Fields: map[string]*framework.FieldSchema{
"role": {
Type: framework.TypeLowerCaseString,
Description: "Name of the Vault role",
},
"resource": {
Type: framework.TypeString,
Description: "The specific Azure audience of a generated access token",
Default: "https://management.azure.com/",
},
},
Operations: map[logical.Operation]framework.OperationHandler{
logical.ReadOperation: &framework.PathOperation{
Callback: b.pathAccessTokenRead,
},
},
HelpSynopsis: pathAccessTokenHelpSyn,
HelpDescription: pathAccessTokenHelpDesc,
}
}

func (b *azureSecretBackend) pathAccessTokenRead(ctx context.Context, request *logical.Request, data *framework.FieldData) (*logical.Response, error) {
roleName := data.Get("role").(string)
resource := data.Get("resource").(string)

role, err := getRole(ctx, roleName, request.Storage)
if err != nil {
return nil, err
}

if role == nil {
return logical.ErrorResponse("role '%s' does not exist", roleName), nil
}

if role.CredentialType != credentialTypeSP {
return logical.ErrorResponse("role '%s' cannot generate access tokens (has secret type %s)", roleName, role.CredentialType), nil
}

if role.Credentials == nil {
return logical.ErrorResponse("role '%s' configured before plugin supported access tokens (update or recreate role)", roleName), nil
}

return b.secretAccessTokenResponse(ctx, request.Storage, role, resource)
}

func (b *azureSecretBackend) secretAccessTokenResponse(ctx context.Context, storage logical.Storage, role *roleEntry, resource string) (*logical.Response, error) {
client, err := b.getClient(ctx, storage)
if err != nil {
return nil, err
}

cc := azureauth.NewClientCredentialsConfig(role.ApplicationID, role.Credentials.Password, client.settings.TenantID)
cc.Resource = resource
token, err := b.getToken(ctx, client, cc)
if err != nil {
return nil, err
}

// access_tokens are not revocable therefore do not return a framework.Secret (i.e. a lease)
return &logical.Response{Data: structsMap(token)}, nil
}

func structsMap(s interface{}) map[string]interface{} {
t := structs.New(s)
t.TagName = "json"
return t.Map()
}

func (b *azureSecretBackend) getToken(ctx context.Context, client *client, c azureauth.ClientCredentialsConfig) (azureadal.Token, error) {
token, err := retry(ctx, func() (interface{}, bool, error) {
t, err := client.provider.GetToken(c)

if hasAzureErrorCode(err, azureAppNotFoundErrCode) {
return nil, false, nil
} else if err != nil {
return nil, true, err
}

return t, true, nil
})

var t azureadal.Token
if token != nil {
t = token.(azureadal.Token)
}

return t, err
}

func hasAzureErrorCode(e error, code int) bool {
tErr, ok := e.(azureadal.TokenRefreshError)

// use a pattern match as TokenRefreshError is not easily parsable
return ok && tErr != nil && strings.Contains(tErr.Error(), fmt.Sprint(code))
}

const pathAccessTokenHelpSyn = `
Request an access token for a given Vault role.
`

const pathAccessTokenHelpDesc = `
This path creates access token credentials. The associated role must
be created ahead of time with either an existing App/Service Principal or
else a dynamic Service Principal will be created.
`
105 changes: 105 additions & 0 deletions path_access_token_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package azuresecrets

import (
"context"
"testing"

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

func Test_azureSecretBackend_pathAccessTokenRead(t *testing.T) {
b, s := getTestBackend(t, true)

t.Run("token generated", func(t *testing.T) {
role := generateUUID()
testRoleCreate(t, b, s, role, testStaticSPRole)

resp, err := b.HandleRequest(context.Background(), &logical.Request{
Operation: logical.ReadOperation,
Path: "token/" + role,
Storage: s,
})

assertErrorIsNil(t, err)

if resp.IsError() {
t.Fatalf("receive response error: %v", resp.Error())
}

if _, ok := resp.Data["access_token"]; !ok {
t.Fatalf("access_token not found in response")
}

if _, ok := resp.Data["refresh_token"]; !ok {
t.Fatalf("refresh_token not found in response")
}

if _, ok := resp.Data["expires_in"]; !ok {
t.Fatalf("expires_in not found in response")
}

if _, ok := resp.Data["expires_on"]; !ok {
t.Fatalf("expires_on not found in response")
}

if _, ok := resp.Data["not_before"]; !ok {
t.Fatalf("not_before not found in response")
}

r, ok := resp.Data["resource"]
if !ok {
t.Fatalf("resource not found in response")
}
if r != "https://management.azure.com/" {
t.Fatalf("resource not equal to requested")
}

if _, ok := resp.Data["token_type"]; !ok {
t.Fatalf("token_type not found in response")
}
})

t.Run("non default resource token generated", func(t *testing.T) {
role := generateUUID()
testRoleCreate(t, b, s, role, testStaticSPRole)

resource := "https://resource.endpoint/"
resp, err := b.HandleRequest(context.Background(), &logical.Request{
Operation: logical.ReadOperation,
Path: "token/" + role,
Data: map[string]interface{}{
"resource": resource,
},
Storage: s,
})

assertErrorIsNil(t, err)

if resp.IsError() {
t.Fatalf("receive response error: %v", resp.Error())
}

r, ok := resp.Data["resource"]
if !ok {
t.Fatalf("resource not found in response")
}
if r != resource {
t.Fatalf("resource not equal to requested")
}
})

t.Run("role does not exist", func(t *testing.T) {
role := generateUUID()
resp, err := b.HandleRequest(context.Background(), &logical.Request{
Operation: logical.ReadOperation,
Path: "token/" + role,
Storage: s,
})

assertErrorIsNil(t, err)

if !resp.IsError() {
t.Fatal("expected missing role error")
}
})
}
Loading