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

Add new integration tests for Azure OIDC for git repositories #791

Closed
Closed
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
52 changes: 52 additions & 0 deletions auth/azure/fake_credential.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/*
Copyright 2024 The Flux authors

Licensed under the Apache License, Version 2.0 (the "License"); you may not use
this file except in compliance with the License.

You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed
under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
CONDITIONS OF ANY KIND, either express or implied. See the License for the
specific language governing permissions and limitations under the License.
*/

package azure

import (
"context"
"fmt"
"time"

"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
)

// FakeTokenCredential is a fake Azure credential provider.
type FakeTokenCredential struct {
Token string

ExpiresOn time.Time
Err error
}

var _ azcore.TokenCredential = &FakeTokenCredential{}

func (tc *FakeTokenCredential) GetToken(ctx context.Context, options policy.TokenRequestOptions) (azcore.AccessToken, error) {
if tc.Err != nil {
return azcore.AccessToken{}, tc.Err
}

// Embed the scope inside the context to verify that the desired scope was
// specified while fetching the token.
val, ok := ctx.Value("scope").(*string)
if ok {
*val = options.Scopes[0]
} else {
return azcore.AccessToken{}, fmt.Errorf("unable to get scope")
}

return azcore.AccessToken{Token: tc.Token, ExpiresOn: tc.ExpiresOn}, nil
}
84 changes: 84 additions & 0 deletions auth/azure/token_provider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/*
Copyright 2024 The Flux authors

Licensed under the Apache License, Version 2.0 (the "License"); you may not use
this file except in compliance with the License.

You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed
under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
CONDITIONS OF ANY KIND, either express or implied. See the License for the
specific language governing permissions and limitations under the License.
*/

package azure

import (
"context"
"fmt"

"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
)

const (
AzureDevOpsRestApiScope = "499b84ac-1321-427f-aa17-267ca6975798/.default"
)

// Provider is an authentication provider for Azure.
type Provider struct {
credential azcore.TokenCredential
scopes []string
}

// ProviderOptFunc enables specifying options for the provider.
type ProviderOptFunc func(*Provider)

// NewProvider returns a new authentication provider for Azure.
func NewProvider(opts ...ProviderOptFunc) *Provider {
p := &Provider{}
for _, opt := range opts {
opt(p)
}
return p
}

// WithCredential configures the credential to use to fetch the resource manager
// token.
func WithCredential(cred azcore.TokenCredential) ProviderOptFunc {
return func(p *Provider) {
p.credential = cred
}
}

func WithAzureDevOpsScope() ProviderOptFunc {
return func(p *Provider) {
p.scopes = []string{AzureDevOpsRestApiScope}
}
}

func (p *Provider) GetToken(ctx context.Context) (*azcore.AccessToken, error) {
if len(p.scopes) == 0 {
return nil, fmt.Errorf("error scopes must be specified")
}

if p.credential == nil {
cred, err := azidentity.NewDefaultAzureCredential(nil)
if err != nil {
return nil, err
}
p.credential = cred
}

accessToken, err := p.credential.GetToken(ctx, policy.TokenRequestOptions{
Scopes: p.scopes,
})
if err != nil {
return nil, err
}

return &accessToken, nil
}
82 changes: 82 additions & 0 deletions auth/azure/token_provider_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/*
Copyright 2024 The Flux authors

Licensed under the Apache License, Version 2.0 (the "License"); you may not use
this file except in compliance with the License.

You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed
under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
CONDITIONS OF ANY KIND, either express or implied. See the License for the
specific language governing permissions and limitations under the License.
*/

package azure

import (
"context"
"errors"
"testing"

"github.com/Azure/azure-sdk-for-go/sdk/azcore"
. "github.com/onsi/gomega"
"k8s.io/utils/pointer"
)

func TestGetProviderToken(t *testing.T) {
tests := []struct {
name string
tokenCred azcore.TokenCredential
opts []ProviderOptFunc
wantToken string
wantScope string
wantErr error
}{
{
name: "custom scope",
tokenCred: &FakeTokenCredential{
Token: "foo",
},
opts: []ProviderOptFunc{WithAzureDevOpsScope()},
wantScope: "499b84ac-1321-427f-aa17-267ca6975798/.default",
wantToken: "foo",
},
{
name: "no scope specified",
tokenCred: &FakeTokenCredential{
Token: "foo",
},
wantErr: errors.New("error scopes must be specified"),
},
{
name: "error",
tokenCred: &FakeTokenCredential{
Err: errors.New("oh no!"),
},
opts: []ProviderOptFunc{WithAzureDevOpsScope()},
wantErr: errors.New("oh no!"),
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := NewWithT(t)
provider := NewProvider(tt.opts...)
provider.credential = tt.tokenCred
ctx := context.WithValue(context.TODO(), "scope", pointer.String(""))
token, err := provider.GetToken(ctx)

if tt.wantErr != nil {
g.Expect(err).To(HaveOccurred())
g.Expect(err).To(Equal(tt.wantErr))
} else {
g.Expect(err).ToNot(HaveOccurred())
g.Expect(token.Token).To(Equal(tt.wantToken))
scope := ctx.Value("scope").(*string)
g.Expect(*scope).To(Equal(tt.wantScope))
}
})
}
}
20 changes: 20 additions & 0 deletions auth/constants.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/*
Copyright 2024 The Flux authors

Licensed under the Apache License, Version 2.0 (the "License"); you may not use
this file except in compliance with the License.

You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed
under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
CONDITIONS OF ANY KIND, either express or implied. See the License for the
specific language governing permissions and limitations under the License.
*/

package auth

const (
ProviderAzure = "azure"
)
41 changes: 41 additions & 0 deletions auth/git/cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
Copyright 2024 The Flux authors

Licensed under the Apache License, Version 2.0 (the "License"); you may not use
this file except in compliance with the License. You may obtain a copy of the
License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed
under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
CONDITIONS OF ANY KIND, either express or implied. See the License for the
specific language governing permissions and limitations under the License.
*/

package git

import (
"time"

"github.com/fluxcd/pkg/cache"
)

func cacheObject[T Credentials](store cache.Expirable[cache.StoreObject[T]], auth T, key string, expiresAt time.Time) error {
obj := cache.StoreObject[T]{
Object: auth,
Key: key,
}

err := store.Set(obj)
if err != nil {
return err
}

return store.SetExpiration(obj, expiresAt)
}

func getObjectFromCache[T Credentials](cache cache.Expirable[cache.StoreObject[T]], key string) (T, bool, error) {
val, exists, err := cache.GetByKey(key)
return val.Object, exists, err
}
109 changes: 109 additions & 0 deletions auth/git/credentials.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/*
Copyright 2024 The Flux authors

Licensed under the Apache License, Version 2.0 (the "License"); you may not use
this file except in compliance with the License.

You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed
under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
CONDITIONS OF ANY KIND, either express or implied. See the License for the
specific language governing permissions and limitations under the License.
*/

package git

import (
"context"
"time"

"github.com/fluxcd/pkg/auth"
"github.com/fluxcd/pkg/auth/azure"
"github.com/fluxcd/pkg/cache"
"sigs.k8s.io/controller-runtime/pkg/log"
)

// AuthOptions contains options that can be used for authentication.
type AuthOptions struct {
// ProviderOptions specifies the options to configure various authentication
// providers.
ProviderOptions ProviderOptions

// Cache is a cache for storing auth configurations.
Cache cache.Expirable[cache.StoreObject[Credentials]]
}

// ProviderOptions contains options to configure various authentication
// providers.
type ProviderOptions struct {
AzureOpts []azure.ProviderOptFunc
}

// Credentials contains authentication data needed in order to access a Git
// repository.
type Credentials struct {
BearerToken string `json:"bearerToken,omitempty"`
ExpiresOn time.Time
}

// ToSecretData returns the Credentials object in the format of the data found
// in Kubernetes Generic Secret.
func (c *Credentials) ToSecretData() map[string][]byte {
var data map[string][]byte = make(map[string][]byte)

if c.BearerToken != "" {
data["bearerToken"] = []byte(c.BearerToken)
}

return data
}

func CacheCredentials(ctx context.Context, url string, authOpts *AuthOptions, creds *Credentials) {
log := log.FromContext(ctx)
if authOpts.Cache != nil {
err := cacheObject(authOpts.Cache, *creds, url, creds.ExpiresOn)
if err != nil {
log.Error(err, "failed to cache credentials")
}
}
}

// GetCredentials returns authentication credentials for accessing the provided
// Git repository.
func GetCredentials(ctx context.Context, url string, provider string, authOpts *AuthOptions) (*Credentials, error) {
var creds Credentials
log := log.FromContext(ctx)

if authOpts.Cache != nil {
creds, exists, err := getObjectFromCache(authOpts.Cache, url)
if err != nil {
log.Error(err, "failed to get credential object from cache")
}
if exists {
return &creds, nil
}
}

switch provider {
case auth.ProviderAzure:
var opts []azure.ProviderOptFunc
if authOpts != nil {
opts = authOpts.ProviderOptions.AzureOpts
}
azureProvider := azure.NewProvider(opts...)
accessToken, err := azureProvider.GetToken(ctx)
if err != nil {
return nil, err
}
creds = Credentials{
BearerToken: accessToken.Token,
ExpiresOn: accessToken.ExpiresOn,
}
default:
return nil, nil
}

return &creds, nil
}
Loading