pkg/auth/auth.go (248 lines of code) (raw):
package auth
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/Azure/go-autorest/autorest/date"
"github.com/Azure/secrets-store-csi-driver-provider-azure/pkg/utils"
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
"github.com/pkg/errors"
"k8s.io/klog/v2"
)
const (
// Pod Identity podNameHeader
podNameHeader = "podname"
// Pod Identity podNamespaceHeader
podNamespaceHeader = "podns"
// For Azure AD Workload Identity, the audience recommended for use is
// "api://AzureADTokenExchange"
DefaultTokenAudience = "api://AzureADTokenExchange" // nolint
)
var (
// ErrServiceAccountTokensNotFound is returned when the service account token is not found
ErrServiceAccountTokensNotFound = errors.New("service account tokens not found")
)
// Token encapsulates the access token used to authorize Azure requests.
// https://docs.microsoft.com/en-us/azure/active-directory/develop/v1-oauth2-client-creds-grant-flow#service-to-service-access-token-response
type Token struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn json.Number `json:"expires_in"`
ExpiresOn json.Number `json:"expires_on"`
NotBefore json.Number `json:"not_before"`
Resource string `json:"resource"`
Type string `json:"token_type"`
}
// Expires returns the time.Time when the Token expires.
func (t Token) Expires() time.Time {
s, err := t.ExpiresOn.Float64()
if err != nil {
s = -3600
}
expiration := date.NewUnixTimeFromSeconds(s)
return time.Time(expiration).UTC()
}
// PodIdentityResponse is the response received from aad-pod-identity when requesting token
// on behalf of the pod
type PodIdentityResponse struct {
Token Token `json:"token"`
ClientID string `json:"clientid"`
}
// Config is the required parameters for auth config
type Config struct {
// UsePodIdentity is set to true if access mode is using aad-pod-identity
UsePodIdentity bool
// UseVMManagedIdentity is set to true if access mode is using managed identity
UseVMManagedIdentity bool
// UserAssignedIdentityID is the user-assigned managed identity clientID
UserAssignedIdentityID string
// AADClientSecret is the client secret for SP access mode
AADClientSecret string
// AADClientID is the clientID for SP access mode
AADClientID string
// WorkloadIdentityClientID is the clientID for workload identity
// this clientID can be an Azure AD Application or a Managed identity
// NOTE: workload identity federation with managed identity is currently not supported
WorkloadIdentityClientID string
// WorkloadIdentityToken is the service account token for workload identity
// this token will be exchanged for an Azure AD Token based on the federated identity credential
// this service account token is associated with the workload requesting the volume mount
WorkloadIdentityToken string
}
// SATokens represents the service account tokens sent as part of the MountRequest
type SATokens struct {
APIAzureADTokenExchange struct {
Token string `json:"token"`
ExpirationTimestamp time.Time `json:"expirationTimestamp"`
} `json:"api://AzureADTokenExchange"`
}
type workloadIdentityCredential struct {
assertion string
cred *azidentity.ClientAssertionCredential
}
type workloadIdentityCredentialOptions struct {
azcore.ClientOptions
}
type podIdentityCredential struct {
podName string
podNamespace string
resource string
tenantID string
nmiPort string
}
// NewConfig returns new auth config
func NewConfig(
usePodIdentity,
useVMManagedIdentity bool,
userAssignedIdentityID,
workloadIdentityClientID,
workloadIdentityToken string,
secrets map[string]string) (Config, error) {
config := Config{}
// aad-pod-identity and user assigned managed identity modes are currently mutually exclusive
if usePodIdentity && useVMManagedIdentity {
return config, fmt.Errorf("cannot enable both pod identity and user-assigned managed identity")
}
useWorkloadIdentity := len(workloadIdentityClientID) > 0 && len(workloadIdentityToken) > 0
if !usePodIdentity && !useVMManagedIdentity && !useWorkloadIdentity {
var err error
if config.AADClientID, config.AADClientSecret, err = getCredential(secrets); err != nil {
return config, err
}
}
config.UsePodIdentity = usePodIdentity
config.UseVMManagedIdentity = useVMManagedIdentity
config.UserAssignedIdentityID = userAssignedIdentityID
config.WorkloadIdentityClientID = workloadIdentityClientID
config.WorkloadIdentityToken = workloadIdentityToken
return config, nil
}
// GetCredential returns the azure credential to use based on the auth config
func (c Config) GetCredential(podName, podNamespace, resource, aadEndpoint, tenantID, nmiPort string) (azcore.TokenCredential, error) {
// use switch case to ensure only one of the identity modes is enabled
switch {
case c.UsePodIdentity:
return getPodIdentityTokenCredential(podName, podNamespace, resource, tenantID, nmiPort)
case c.UseVMManagedIdentity:
return getManagedIdentityTokenCredential(c.UserAssignedIdentityID)
case len(c.AADClientSecret) > 0 && len(c.AADClientID) > 0:
return getServicePrincipalTokenCredential(c.AADClientID, c.AADClientSecret, aadEndpoint, tenantID)
case len(c.WorkloadIdentityClientID) > 0 && len(c.WorkloadIdentityToken) > 0:
return getWorkloadIdentityTokenCredential(c.WorkloadIdentityClientID, c.WorkloadIdentityToken, aadEndpoint, tenantID)
default:
return nil, fmt.Errorf("no identity mode is enabled")
}
}
func newWorkloadIdentityCredential(tenantID, clientID, assertion string, options *workloadIdentityCredentialOptions) (azcore.TokenCredential, error) {
w := &workloadIdentityCredential{assertion: assertion}
cred, err := azidentity.NewClientAssertionCredential(tenantID, clientID, w.getAssertion, &azidentity.ClientAssertionCredentialOptions{ClientOptions: options.ClientOptions})
if err != nil {
return nil, err
}
w.cred = cred
return w, nil
}
func (w *workloadIdentityCredential) GetToken(ctx context.Context, opts policy.TokenRequestOptions) (azcore.AccessToken, error) {
return w.cred.GetToken(ctx, opts)
}
func (w *workloadIdentityCredential) getAssertion(context.Context) (string, error) {
return w.assertion, nil
}
func getWorkloadIdentityTokenCredential(clientID, signedAssertion, aadEndpoint, tenantID string) (azcore.TokenCredential, error) {
opts := &workloadIdentityCredentialOptions{
ClientOptions: azcore.ClientOptions{
Cloud: cloud.Configuration{
ActiveDirectoryAuthorityHost: aadEndpoint,
},
},
}
return newWorkloadIdentityCredential(tenantID, clientID, signedAssertion, opts)
}
func getServicePrincipalTokenCredential(clientID, secret, aadEndpoint, tenantID string) (azcore.TokenCredential, error) {
opts := &azidentity.ClientSecretCredentialOptions{
ClientOptions: azcore.ClientOptions{
Cloud: cloud.Configuration{
ActiveDirectoryAuthorityHost: aadEndpoint,
},
},
}
return azidentity.NewClientSecretCredential(tenantID, clientID, secret, opts)
}
func getManagedIdentityTokenCredential(identityClientID string) (azcore.TokenCredential, error) {
opts := &azidentity.ManagedIdentityCredentialOptions{
ID: azidentity.ClientID(identityClientID),
}
return azidentity.NewManagedIdentityCredential(opts)
}
func (c *podIdentityCredential) GetToken(ctx context.Context, _ policy.TokenRequestOptions) (azcore.AccessToken, error) {
// For usePodIdentity mode, the CSI driver makes an authorization request to fetch token for a resource from the NMI host endpoint (http://127.0.0.1:2579/host/token/).
// The request includes the pod namespace `podns` and the pod name `podname` in the request header and the resource endpoint of the resource requesting the token.
// The NMI server identifies the pod based on the `podns` and `podname` in the request header and then queries k8s (through MIC) for a matching azure identity.
// Then nmi makes an adal request to get a token for the resource in the request, returns the `token` and the `clientid` as a response to the CSI request.
klog.V(5).InfoS("using pod identity to retrieve token", "pod", klog.ObjectRef{Namespace: c.podNamespace, Name: c.podName})
endpoint := fmt.Sprintf("http://localhost:%s/host/token/?resource=%s", c.nmiPort, c.resource)
client := &http.Client{}
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
if err != nil {
return azcore.AccessToken{}, err
}
req.Header.Add(podNamespaceHeader, c.podNamespace)
req.Header.Add(podNameHeader, c.podName)
req = req.WithContext(ctx)
resp, err := client.Do(req)
if err != nil {
return azcore.AccessToken{}, err
}
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return azcore.AccessToken{}, err
}
if resp.StatusCode != http.StatusOK {
return azcore.AccessToken{}, fmt.Errorf("nmi response failed with status code: %d, response body: %+v", resp.StatusCode, string(bodyBytes))
}
podIdentityResponse := &PodIdentityResponse{}
if err = json.Unmarshal(bodyBytes, &podIdentityResponse); err != nil {
return azcore.AccessToken{}, err
}
klog.V(5).InfoS("successfully acquired access token", "accessToken", utils.RedactSecureString(podIdentityResponse.Token.AccessToken), "clientID", utils.RedactSecureString(podIdentityResponse.ClientID), "pod", klog.ObjectRef{Namespace: c.podNamespace, Name: c.podName})
token, clientID := podIdentityResponse.Token, podIdentityResponse.ClientID
if token.AccessToken == "" || clientID == "" {
return azcore.AccessToken{}, fmt.Errorf("nmi did not return expected values in response: token and clientid")
}
return azcore.AccessToken{
Token: token.AccessToken,
ExpiresOn: podIdentityResponse.Token.Expires(),
}, nil
}
func getPodIdentityTokenCredential(podName, podNamespace, resource, tenantID, nmiPort string) (azcore.TokenCredential, error) {
if len(podName) == 0 || len(podNamespace) == 0 {
return nil, fmt.Errorf("pod information is not available. deploy a CSIDriver object to set podInfoOnMount: true")
}
return &podIdentityCredential{
podName: podName,
podNamespace: podNamespace,
resource: resource,
tenantID: tenantID,
nmiPort: nmiPort,
}, nil
}
// getCredential gets clientid and clientsecret from the secrets
func getCredential(secrets map[string]string) (string, string, error) {
if secrets == nil {
return "", "", fmt.Errorf("failed to get credentials, nodePublishSecretRef secret is not set")
}
var clientID, clientSecret string
for k, v := range secrets {
switch strings.ToLower(k) {
case "clientid":
clientID = v
case "clientsecret":
clientSecret = v
}
}
if clientID == "" {
return "", "", fmt.Errorf("could not find clientid in secrets")
}
if clientSecret == "" {
return "", "", fmt.Errorf("could not find clientsecret in secrets")
}
return clientID, clientSecret, nil
}
// ParseServiceAccountToken parses the bound service account token from the tokens
// passed from driver as part of MountRequest.
// ref: https://kubernetes-csi.github.io/docs/token-requests.html
func ParseServiceAccountToken(saTokens string) (string, error) {
klog.V(5).InfoS("parsing service account token for workload identity")
if len(saTokens) == 0 {
return "", ErrServiceAccountTokensNotFound
}
// Bound token is of the format:
// "csi.storage.k8s.io/serviceAccount.tokens": {
// <audience>: {
// 'token': <token>,
// 'expirationTimestamp': <expiration timestamp in RFC3339 format>,
// },
// ...
// }
tokens := SATokens{}
if err := json.Unmarshal([]byte(saTokens), &tokens); err != nil {
return "", fmt.Errorf("failed to unmarshal service account tokens, error: %w", err)
}
klog.V(5).InfoS("successfully unmarshaled service account tokens")
if tokens.APIAzureADTokenExchange.Token == "" {
return "", fmt.Errorf("token for audience %s not found", DefaultTokenAudience)
}
return tokens.APIAzureADTokenExchange.Token, nil
}
func getScope(resource string) string {
scope := resource
if !strings.HasSuffix(resource, "/.default") {
scope += "/.default"
}
return scope
}