ContainerImage.Pinniped/pkg/client/client.go

166 lines
5.4 KiB
Go

/*
Copyright 2020 VMware, Inc.
SPDX-License-Identifier: Apache-2.0
*/
package client
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"net/http"
"net/url"
"path/filepath"
"time"
)
var (
// ErrCredentialRequestFailed is returned by ExchangeToken when the server rejects the credential request.
ErrCredentialRequestFailed = fmt.Errorf("credential request failed")
// ErrInvalidAPIEndpoint is returned by ExchangeToken when the provided API endpoint is invalid.
ErrInvalidAPIEndpoint = fmt.Errorf("invalid API endpoint")
// ErrInvalidCABundle is returned by ExchangeToken when the provided CA bundle is invalid.
ErrInvalidCABundle = fmt.Errorf("invalid CA bundle")
)
const (
// credentialRequestsAPIPath is the API path for the v1alpha1 CredentialRequest API.
credentialRequestsAPIPath = "/apis/pinniped.dev/v1alpha1/credentialrequests"
// userAgent is the user agent header value sent with requests.
userAgent = "pinniped"
)
func credentialRequest(ctx context.Context, apiEndpoint *url.URL, token string) (*http.Request, error) {
type CredentialRequestTokenCredential struct {
Value string `json:"value"`
}
type CredentialRequestSpec struct {
Type string `json:"type"`
Token *CredentialRequestTokenCredential `json:"token"`
}
body := struct {
APIVersion string `json:"apiVersion"`
Kind string `json:"kind"`
Metadata struct {
CreationTimestamp *string `json:"creationTimestamp"`
} `json:"metadata"`
Spec CredentialRequestSpec `json:"spec"`
Status struct{} `json:"status"`
}{
APIVersion: "pinniped.dev/v1alpha1",
Kind: "CredentialRequest",
Spec: CredentialRequestSpec{Type: "token", Token: &CredentialRequestTokenCredential{Value: token}},
}
bodyJSON, err := json.Marshal(&body)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiEndpoint.String(), bytes.NewReader(bodyJSON))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", userAgent)
return req, nil
}
// Credential is the output of an ExchangeToken operation. It is equivalent to the data
// in the Kubernetes client.authentication.k8s.io/v1beta1 ExecCredentialStatus type.
type Credential struct {
// ExpirationTimestamp indicates a time when the provided credentials expire.
ExpirationTimestamp *time.Time
// Token is a bearer token used by the client for request authentication.
Token string
// PEM-encoded client TLS certificates (including intermediates, if any).
ClientCertificateData string
// PEM-encoded private key for the above certificate.
ClientKeyData string
}
func ExchangeToken(ctx context.Context, token, caBundle, apiEndpoint string) (*Credential, error) {
// Parse and validate the provided API endpoint.
endpointURL, err := url.Parse(apiEndpoint)
if err != nil {
return nil, fmt.Errorf("%w: %s", ErrInvalidAPIEndpoint, err.Error())
}
if endpointURL.Scheme != "https" {
return nil, fmt.Errorf(`%w: protocol must be "https", not %q`, ErrInvalidAPIEndpoint, endpointURL.Scheme)
}
// Form the CredentialRequest API URL by appending the API path to the main API endpoint.
pinnipedEndpointURL := *endpointURL
pinnipedEndpointURL.Path = filepath.Join(pinnipedEndpointURL.Path, credentialRequestsAPIPath)
// Initialize a TLS client configuration from the provided CA bundle.
tlsConfig := tls.Config{
MinVersion: tls.VersionTLS12,
RootCAs: x509.NewCertPool(),
}
if !tlsConfig.RootCAs.AppendCertsFromPEM([]byte(caBundle)) {
return nil, fmt.Errorf("%w: no certificates found", ErrInvalidCABundle)
}
// Create a request object for the "POST /apis/pinniped.dev/v1alpha1/credentialrequests" request.
req, err := credentialRequest(ctx, &pinnipedEndpointURL, token)
if err != nil {
return nil, fmt.Errorf("could not build request: %w", err)
}
client := http.Client{Transport: &http.Transport{TLSClientConfig: &tlsConfig}}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("could not get credential: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusCreated {
return nil, fmt.Errorf("%w: server returned status %d", ErrCredentialRequestFailed, resp.StatusCode)
}
var respBody struct {
APIVersion string `json:"apiVersion"`
Kind string `json:"kind"`
Status struct {
Credential *struct {
ExpirationTimestamp string `json:"expirationTimestamp"`
Token string `json:"token"`
ClientCertificateData string `json:"clientCertificateData"`
ClientKeyData string `json:"clientKeyData"`
}
Message string `json:"message"`
} `json:"status"`
}
if err := json.NewDecoder(resp.Body).Decode(&respBody); err != nil {
return nil, fmt.Errorf("invalid credential response: %w", err)
}
if respBody.Status.Credential == nil || respBody.Status.Message != "" {
return nil, fmt.Errorf("%w: %s", ErrCredentialRequestFailed, respBody.Status.Message)
}
result := Credential{
Token: respBody.Status.Credential.Token,
ClientCertificateData: respBody.Status.Credential.ClientCertificateData,
ClientKeyData: respBody.Status.Credential.ClientKeyData,
}
if str := respBody.Status.Credential.ExpirationTimestamp; str != "" {
expiration, err := time.Parse(time.RFC3339, str)
if err != nil {
return nil, fmt.Errorf("invalid credential response: %w", err)
}
result.ExpirationTimestamp = &expiration
}
return &result, nil
}