From db2f8ae7bbabcf67c31301bb5ecdc961ca1ecbb4 Mon Sep 17 00:00:00 2001 From: Prasanth Ullattil Date: Mon, 22 May 2023 16:04:55 +0200 Subject: [PATCH] Add AWS KMS support for OAuth2 Client Credentials JWT authentication This implementaion adds new configuration properties to "oauth2" aws_kms: AWS KMS key details aws_signing: Infomation for signing AWS requestion, similar to s3_signing References: 1) https://github.com/go-jose/go-jose/blob/v3/asymmetric.go#L501 2) https://github.com/codelittinc/gobitauth/blob/master/sign.go#L101 Signed-off-by: Prasanth Ullattil --- docs/content/configuration.md | 60 ++++++-- internal/providers/aws/kms.go | 106 ++++++++++++++ internal/providers/aws/kms_test.go | 96 +++++++++++++ plugins/rest/auth.go | 215 +++++++++++++++++++++++++++-- plugins/rest/auth_test.go | 44 ++++++ plugins/rest/aws.go | 38 +++++ plugins/rest/rest_test.go | 179 +++++++++++++++++++++++- 7 files changed, 709 insertions(+), 29 deletions(-) create mode 100644 internal/providers/aws/kms.go create mode 100644 internal/providers/aws/kms_test.go diff --git a/docs/content/configuration.md b/docs/content/configuration.md index 93bb052c13..594d7e2baa 100644 --- a/docs/content/configuration.md +++ b/docs/content/configuration.md @@ -371,19 +371,28 @@ OPA will authenticate using a bearer token obtained through the OAuth2 [client c Rather than providing a client secret along with the request for an access token, the client [asserts](https://tools.ietf.org/html/rfc7521#section-4.2) its identity in the form of a signed JWT. Following successful authentication at the token endpoint the returned token will be cached for subsequent requests for the duration of its lifetime. Note that as per the [OAuth2 standard](https://tools.ietf.org/html/rfc6749#section-2.3.1), only the HTTPS scheme is supported for the token endpoint URL. -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `services[_].credentials.oauth2.token_url` | `string` | Yes | URL pointing to the token endpoint at the OAuth2 authorization server. | -| `services[_].credentials.oauth2.grant_type` | `string` | No | Defaults to `client_credentials`. | -| `services[_].credentials.oauth2.client_id` | `string` | No | The client ID to use for authentication. | -| `services[_].credentials.oauth2.signing_key` | `string` | Yes | Reference to private key used for signing the JWT. | -| `services[_].credentials.oauth2.thumbprint` | `string` | No | Certificate thumbprint to use for x5t header generation. | -| `services[_].credentials.oauth2.additional_claims` | `map` | No | Map of claims to include in the JWT (see notes below) | -| `services[_].credentials.oauth2.include_jti_claim` | `bool` | No | Include a uniquely generated `jti` claim in any issued JWT | -| `services[_].credentials.oauth2.scopes` | `[]string` | No | Optional list of scopes to request for the token. | +| Field | Type | Required | Description | +|----------------------------------------------------|------------|----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `services[_].credentials.oauth2.token_url` | `string` | Yes | URL pointing to the token endpoint at the OAuth2 authorization server. | +| `services[_].credentials.oauth2.grant_type` | `string` | No | Defaults to `client_credentials`. | +| `services[_].credentials.oauth2.client_id` | `string` | No | The client ID to use for authentication. | +| `services[_].credentials.oauth2.signing_key` | `string` | No | Reference to private key used for signing the JWT. Required if `aws_kms` is not provided | +| `services[_].credentials.oauth2.thumbprint` | `string` | No | Certificate thumbprint to use for x5t header generation. | +| `services[_].credentials.oauth2.additional_claims` | `map` | No | Map of claims to include in the JWT (see notes below) | +| `services[_].credentials.oauth2.include_jti_claim` | `bool` | No | Include a uniquely generated `jti` claim in any issued JWT | +| `services[_].credentials.oauth2.scopes` | `[]string` | No | Optional list of scopes to request for the token. | +| `services[_].credentials.oauth2.aws_kms.name` | `string` | No | To specify a KMS key, use its key ID, key ARN, alias name, or alias ARN. Required only for signing with AWS KMS. | +| `services[_].credentials.oauth2.aws_kms.algorithm` | `string` | No | Specifies the signing algorithm used by the key `aws_kms.name` `(ECDSA_SHA_256, ECDSA_SHA_384 or ECDSA_SHA_512)`. Required only for signing with AWS KMS. | +| `services[_].credentials.oauth2.aws_signing` | `{}` | No | AWS credentials for signing requests. Required if `aws_kms` is provided. | Two claims will always be included in the issued JWT: `iat` and `exp`. Any other claims will be populated from the `additional_claims` map. +{{< info >}} +For using `services[_].credentials.oauth2.aws_kms`, a method for setting the AWS credentials has to be specifed in the `services[_].credentials.oauth2.aws_signing`. +The value of `services[_].credentials.oauth2.aws_signing.service` should be `kms`. Several methods of obtaining the necessary credentials are available; exactly one must be specified, +see description for `services[_].credentials.s3_signing`. +{{< /info >}} + ##### Example Using the client credentials grant type with JWT client authentication replacing client secret as the credential used at the token endpoint. @@ -417,6 +426,37 @@ keys: private_key: ${BUNDLE_SERVICE_SIGNING_KEY} ``` +Using the client credentials grant type with JWT client authentication & AWS KMS signing of client assertions. + +```yaml +services: + remote: + url: ${BUNDLE_SERVICE_URL} + credentials: + oauth2: + token_url: ${TOKEN_URL} + grant_type: client_credentials + client_id: opa-client + aws_kms: + name: ${AWS_KMS_KEYID} + algorithm: ECDSA_SHA_256 + aws_signing: # similar to s3_signing + service: kms + environment_credentials: + aws_default_region: eu-west-1 + include_jti_claim: true + scopes: + - read + - write + additional_claims: + sub: opa-client + iss: opa-${POD_NAME} + +bundles: + authz: + service: remote + resource: bundles/http/example/authz.tar.gz +``` #### OAuth2 JWT Bearer Grant Type OPA will authenticate using a bearer token obtained through the OAuth2 [JWT authorization grant](https://tools.ietf.org/html/rfc7523#section-2.1) flow. diff --git a/internal/providers/aws/kms.go b/internal/providers/aws/kms.go new file mode 100644 index 0000000000..77c0bc9349 --- /dev/null +++ b/internal/providers/aws/kms.go @@ -0,0 +1,106 @@ +package aws + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/open-policy-agent/opa/internal/version" + "github.com/open-policy-agent/opa/logging" +) + +// Values taken from +// https://docs.aws.amazon.com/kms/latest/APIReference/Welcome.html +// https://docs.aws.amazon.com/general/latest/gr/kms.html +const ( + kmsSignTarget = "TrentService.Sign" + kmsEndpointFmt = "https://kms.%s.amazonaws.com/" +) + +// KMS is used to sign payloads using AWS Key Management Service. +type KMS struct { + // endpoint returns the region-specifc KMS endpoint. + // It can be overridden by tests. + endpoint func(region string) string + + // client is used to send authorization tokens requests. + client *http.Client + + logger logging.Logger +} + +func NewKMS(logger logging.Logger) *KMS { + return &KMS{ + endpoint: func(region string) string { + return fmt.Sprintf(kmsEndpointFmt, region) + }, + client: &http.Client{}, + logger: logger, + } +} + +func NewKMSWithURLClient(url string, client *http.Client, logger logging.Logger) *KMS { + return &KMS{ + endpoint: func(string) string { return url }, + client: client, + logger: logger, + } +} + +type KMSSignRequest struct { + KeyID string `json:"KeyId"` + Message string `json:"Message"` + MessageType string `json:"MessageType"` + SigningAlgorithm string `json:"SigningAlgorithm"` +} +type KMSSignResponse struct { + KeyID string `json:"KeyId"` + Signature string `json:"Signature"` + SigningAlgorithm string `json:"SigningAlgorithm"` +} + +// SignDigest signs a digest using KMS. +func (k *KMS) SignDigest(ctx context.Context, digest []byte, keyID string, signingAlgorithm string, creds Credentials, signatureVersion string) (string, error) { + endpoint := k.endpoint(creds.RegionName) + + kmsRequest := KMSSignRequest{ + KeyID: keyID, + Message: base64.StdEncoding.EncodeToString(digest), + MessageType: "DIGEST", + SigningAlgorithm: signingAlgorithm, + } + requestJSONBytes, err := json.Marshal(kmsRequest) + if err != nil { + return "", fmt.Errorf("failed to marshall request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBuffer(requestJSONBytes)) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("X-Amz-Target", kmsSignTarget) + req.Header.Set("Accept-Encoding", "identity") + req.Header.Set("Content-Type", "application/x-amz-json-1.1") + req.Header.Set("User-Agent", version.UserAgent) + + if err := SignRequest(req, "kms", creds, time.Now(), signatureVersion); err != nil { + return "", fmt.Errorf("failed to sign request: %w", err) + } + + resp, err := DoRequestWithClient(req, k.client, "kms sign digest", k.logger) + if err != nil { + return "", err + } + + var data KMSSignResponse + if err := json.Unmarshal(resp, &data); err != nil { + return "", fmt.Errorf("failed to unmarshal response: %w", err) + } + + return data.Signature, nil +} diff --git a/internal/providers/aws/kms_test.go b/internal/providers/aws/kms_test.go new file mode 100644 index 0000000000..ced9e3d248 --- /dev/null +++ b/internal/providers/aws/kms_test.go @@ -0,0 +1,96 @@ +package aws + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/open-policy-agent/opa/logging" +) + +func mockPayload(request KMSSignRequest) string { + responseFmt := `{"KeyId": "%s", "Signature": "%s", "SigningAlgorithm": "%s"}` + return fmt.Sprintf(responseFmt, request.KeyID, request.Message, request.SigningAlgorithm) +} + +func TestKMS_SignDigest(t *testing.T) { + type testCase struct { + name string + request KMSSignRequest + responsePayload string + responseStatus int + wantSignature string + wantErr bool + } + + run := func(t *testing.T, tc testCase) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if tc.responseStatus != 200 { + w.WriteHeader(tc.responseStatus) + } + if _, err := io.WriteString(w, tc.responsePayload); err != nil { + t.Fatalf("io.WriteString(w, payload) = %v", err) + } + + })) + defer server.Close() + + logger := logging.New() + logger.SetLevel(logging.Debug) + + kms := NewKMSWithURLClient(server.URL, server.Client(), logger) + + creds := Credentials{} + signature, err := kms.SignDigest(context.Background(), []byte(tc.request.Message), tc.request.KeyID, tc.request.SigningAlgorithm, creds, "v4") + if err != nil && tc.wantErr == false { + t.Fatalf("expected no error, got: %s", err) + } + + if err == nil && tc.wantErr { + t.Fatal("expected error") + } + + if err == nil && tc.wantSignature != signature { + t.Fatalf("expected %s, got %s", tc.wantSignature, signature) + } + + } + validRequest1 := KMSSignRequest{ + KeyID: "Keyid1", + Message: "sample", + SigningAlgorithm: "ECDSA_SHA_256", + } + testCases := []testCase{ + { + name: "valid response", + request: validRequest1, + responsePayload: mockPayload(validRequest1), + responseStatus: 200, + wantSignature: validRequest1.Message, + wantErr: false, + }, + { + name: "error response", + request: validRequest1, + responsePayload: "Backend error", + responseStatus: 500, + wantErr: true, + }, + { + name: "valid error response", + request: validRequest1, + responsePayload: `{ "__type" :"SerializationException" }`, + responseStatus: 400, + wantErr: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + run(t, tc) + }) + } +} diff --git a/plugins/rest/auth.go b/plugins/rest/auth.go index dedbcb8d1b..567900e8b4 100644 --- a/plugins/rest/auth.go +++ b/plugins/rest/auth.go @@ -8,15 +8,20 @@ import ( "context" "crypto/rand" "crypto/rsa" + "crypto/sha256" + "crypto/sha512" "crypto/tls" "crypto/x509" + "encoding/asn1" "encoding/base64" "encoding/hex" "encoding/json" "encoding/pem" "errors" "fmt" + "hash" "io" + "math/big" "net/http" "net/url" "os" @@ -169,6 +174,97 @@ type tokenEndpointResponse struct { ExpiresIn int64 `json:"expires_in"` } +type awsKmsKeyConfig struct { + Name string `json:"name"` + Algorithm string `json:"algorithm"` +} + +func convertSignatureToBase64(alg string, der []byte) (string, error) { + r, s, derErr := pointsFromDER(der) + if derErr != nil { + return "", fmt.Errorf("failed to read points from der %v", derErr) + } + + signatureData, err := convertPointsToBase64(alg, r.Bytes(), s.Bytes()) + if err != nil { + return "", err + } + return signatureData, nil +} + +func pointsFromDER(der []byte) (R, S *big.Int, err error) { + R, S = &big.Int{}, &big.Int{} + data := asn1.RawValue{} + if _, err := asn1.Unmarshal(der, &data); err != nil { + return nil, nil, fmt.Errorf("failed to unmarshall the signature from DER format %v", err) + + } + // https://docs.aws.amazon.com/kms/latest/APIReference/API_Sign.html#API_Sign_ResponseSyntax + // https://datatracker.ietf.org/doc/html/rfc3279#section-2.2.3 + // The format of our DER string is 0x02 + rlen + r + 0x02 + slen + s + rLen := data.Bytes[1] // The entire length of R + offset of 2 for 0x02 and rlen + r := data.Bytes[2 : rLen+2] + // Ignore the next 0x02 and slen bytes and just take the start of S to the end of the byte array + s := data.Bytes[rLen+4:] + R.SetBytes(r) + S.SetBytes(s) + return +} + +func convertPointsToBase64(alg string, r, s []byte) (string, error) { + curveBits, err := retrieveCurveBits(alg) + if err != nil { + return "", err + } + keyBytes := curveBits / 8 + if curveBits%8 > 0 { + keyBytes++ + } + // We serialize the outputs (r and s) into big-endian byte arrays and pad + // them with zeros on the left to make sure the sizes work out. Both arrays + // must be keyBytes long, and the output must be 2*keyBytes long. + rBytesPadded := make([]byte, keyBytes) + copy(rBytesPadded[keyBytes-len(r):], r) + sBytesPadded := make([]byte, keyBytes) + copy(sBytesPadded[keyBytes-len(s):], s) + signatureEnc := append(rBytesPadded, sBytesPadded...) + + return base64.RawURLEncoding.EncodeToString(signatureEnc), nil +} + +func retrieveCurveBits(alg string) (int, error) { + var curveBits int + switch alg { + case "ECDSA_SHA_256": + curveBits = 256 + case "ECDSA_SHA_384": + curveBits = 384 + case "ECDSA_SHA_512": + curveBits = 512 + default: + return 0, fmt.Errorf("unsupported sign algorithm %s", alg) + } + return curveBits, nil +} + +func messageDigest(message []byte, alg string) ([]byte, error) { + var digest hash.Hash + + switch alg { + case "ECDSA_SHA_256": + digest = sha256.New() + case "ECDSA_SHA_384": + digest = sha512.New384() + case "ECDSA_SHA_512": + digest = sha512.New() + default: + return []byte{}, fmt.Errorf("unsupported sign algorithm %s", alg) + } + + digest.Write(message) + return digest.Sum(nil), nil +} + // oauth2ClientCredentialsAuthPlugin represents authentication via a bearer token in the HTTP Authorization header // obtained through the OAuth2 client credentials flow type oauth2ClientCredentialsAuthPlugin struct { @@ -183,6 +279,8 @@ type oauth2ClientCredentialsAuthPlugin struct { Scopes []string `json:"scopes,omitempty"` AdditionalHeaders map[string]string `json:"additional_headers,omitempty"` AdditionalParameters map[string]string `json:"additional_parameters,omitempty"` + AWSKmsKey *awsKmsKeyConfig `json:"aws_kms,omitempty"` + AWSSigningPlugin *awsSigningAuthPlugin `json:"aws_signing,omitempty"` signingKey *keys.Config signingKeyParsed interface{} @@ -196,7 +294,7 @@ type oauth2Token struct { ExpiresAt time.Time } -func (ap *oauth2ClientCredentialsAuthPlugin) createAuthJWT(claims map[string]interface{}, signingKey interface{}) (*string, error) { +func (ap *oauth2ClientCredentialsAuthPlugin) createAuthJWT(ctx context.Context, claims map[string]interface{}, signingKey interface{}) (*string, error) { now := time.Now() baseClaims := map[string]interface{}{ "iat": now.Unix(), @@ -227,22 +325,35 @@ func (ap *oauth2ClientCredentialsAuthPlugin) createAuthJWT(claims map[string]int } var jwsHeaders []byte + var signatureAlg string + if ap.AWSKmsKey == nil { + signatureAlg = ap.signingKey.Algorithm + } else { + signatureAlg, err = ap.mapKMSAlgToSign(ap.AWSKmsKey.Algorithm) + if err != nil { + return nil, err + } + } if ap.Thumbprint != "" { bytes, err := hex.DecodeString(ap.Thumbprint) if err != nil { return nil, err } x5t := base64.URLEncoding.EncodeToString(bytes) - jwsHeaders = []byte(fmt.Sprintf(`{"typ":"JWT","alg":"%s","x5t":"%s"}`, ap.signingKey.Algorithm, x5t)) + jwsHeaders = []byte(fmt.Sprintf(`{"typ":"JWT","alg":"%s","x5t":"%s"}`, signatureAlg, x5t)) } else { - jwsHeaders = []byte(fmt.Sprintf(`{"typ":"JWT","alg":"%s"}`, ap.signingKey.Algorithm)) + jwsHeaders = []byte(fmt.Sprintf(`{"typ":"JWT","alg":"%s"}`, signatureAlg)) + } + var jwsCompact []byte + if ap.AWSKmsKey == nil { + jwsCompact, err = jws.SignLiteral(payload, + jwa.SignatureAlgorithm(signatureAlg), + signingKey, + jwsHeaders, + rand.Reader) + } else { + jwsCompact, err = ap.SignWithKMS(ctx, payload, jwsHeaders) } - - jwsCompact, err := jws.SignLiteral(payload, - jwa.SignatureAlgorithm(ap.signingKey.Algorithm), - signingKey, - jwsHeaders, - rand.Reader) if err != nil { return nil, err } @@ -251,6 +362,55 @@ func (ap *oauth2ClientCredentialsAuthPlugin) createAuthJWT(claims map[string]int return &jwt, nil } +func (ap *oauth2ClientCredentialsAuthPlugin) mapKMSAlgToSign(alg string) (string, error) { + switch alg { + case "ECDSA_SHA_256": + return "ES256", nil + case "ECDSA_SHA_384": + return "ES384", nil + case "ECDSA_SHA_512": + return "ES512", nil + default: + return "", fmt.Errorf("unsupported sign algorithm %s", alg) + } +} + +// SignWithKMS will sign the JWT in AWS using the key stored in the supplied kmsArn +func (ap *oauth2ClientCredentialsAuthPlugin) SignWithKMS(ctx context.Context, payload []byte, hdrBuf []byte) ([]byte, error) { + + encodedHdr := base64.RawURLEncoding.EncodeToString(hdrBuf) + encodedPayload := base64.RawURLEncoding.EncodeToString(payload) + input := strings.Join( + []string{ + encodedHdr, + encodedPayload, + }, ".", + ) + digest, err := messageDigest([]byte(input), ap.AWSKmsKey.Algorithm) + if err != nil { + return nil, err + } + if ap.AWSSigningPlugin != nil { + signature, err := ap.AWSSigningPlugin.SignDigest(ctx, digest, ap.AWSKmsKey.Name, ap.AWSKmsKey.Algorithm) + if err != nil { + return nil, err + } + der, err := base64.StdEncoding.DecodeString(signature) + if err != nil { + return nil, err + } + signatureData, err := convertSignatureToBase64(ap.AWSKmsKey.Algorithm, der) + if err != nil { + return nil, err + } + + signedAssertion := input + "." + signatureData + + return []byte(signedAssertion), nil + } + return nil, errors.New("missing AWS credentials, failed to sign the assertion with kms") +} + func (ap *oauth2ClientCredentialsAuthPlugin) parseSigningKey(c Config) (err error) { if ap.SigningKeyID == "" { return errors.New("signing_key required for jwt_bearer grant type") @@ -302,12 +462,23 @@ func (ap *oauth2ClientCredentialsAuthPlugin) NewClient(c Config) (*http.Client, return nil, errors.New("token_url required to use https scheme") } if ap.GrantType == grantTypeClientCredentials { - if ap.ClientSecret != "" && ap.SigningKeyID != "" { - return nil, errors.New("can only use one of client_secret and signing_key for client_credentials") + if ap.AWSKmsKey != nil && (ap.ClientSecret != "" || ap.SigningKeyID != "") || + (ap.ClientSecret != "" && ap.SigningKeyID != "") { + return nil, errors.New("can only use one of client_secret, signing_key or signing_kms_key for client_credentials") } - if ap.SigningKeyID == "" && (ap.ClientID == "" || ap.ClientSecret == "") { + if ap.SigningKeyID == "" && ap.AWSKmsKey == nil && (ap.ClientID == "" || ap.ClientSecret == "") { return nil, errors.New("client_id and client_secret required") } + if ap.AWSKmsKey != nil { + if ap.AWSSigningPlugin == nil { + return nil, errors.New("aws_kms and aws_signing required") + } + // initialize the awsSigningAuthPlugin + _, err = ap.AWSSigningPlugin.NewClient(c) + if err != nil { + return nil, err + } + } } return DefaultRoundTripperClient(t, *c.ResponseHeaderTimeoutSeconds), nil @@ -320,7 +491,7 @@ func (ap *oauth2ClientCredentialsAuthPlugin) NewClient(c Config) (*http.Client, func (ap *oauth2ClientCredentialsAuthPlugin) requestToken(ctx context.Context) (*oauth2Token, error) { body := url.Values{} if ap.GrantType == grantTypeJwtBearer { - authJwt, err := ap.createAuthJWT(ap.Claims, ap.signingKeyParsed) + authJwt, err := ap.createAuthJWT(ctx, ap.Claims, ap.signingKeyParsed) if err != nil { return nil, err } @@ -329,8 +500,8 @@ func (ap *oauth2ClientCredentialsAuthPlugin) requestToken(ctx context.Context) ( } else { body.Add("grant_type", grantTypeClientCredentials) - if ap.SigningKeyID != "" { - authJwt, err := ap.createAuthJWT(ap.Claims, ap.signingKeyParsed) + if ap.SigningKeyID != "" || ap.AWSKmsKey != nil { + authJwt, err := ap.createAuthJWT(ctx, ap.Claims, ap.signingKeyParsed) if err != nil { return nil, err } @@ -536,6 +707,7 @@ type awsSigningAuthPlugin struct { AWSSignatureVersion string `json:"signature_version,omitempty"` ecrAuthPlugin *ecrAuthPlugin + kmsSignPlugin *awsKMSSignPlugin logger logging.Logger } @@ -680,6 +852,10 @@ func (ap *awsSigningAuthPlugin) validateAndSetDefaults(serviceType string) error if ap.AWSService == "ecr" { return errors.New(`aws service "ecr" must be used with service type "oci"`) } + if ap.AWSService == "kms" && ap.kmsSignPlugin == nil { + // We need a special plugin for KMS. + ap.kmsSignPlugin = newKMSSignPlugin(ap) + } if ap.AWSService == "" { ap.AWSService = awsSigv4SigningDefaultService } @@ -691,3 +867,12 @@ func (ap *awsSigningAuthPlugin) validateAndSetDefaults(serviceType string) error return nil } + +func (ap *awsSigningAuthPlugin) SignDigest(ctx context.Context, digest []byte, keyID string, signingAlgorithm string) (string, error) { + switch ap.AWSService { + case "kms": + return ap.kmsSignPlugin.SignDigest(ctx, digest, keyID, signingAlgorithm) + default: + return "", fmt.Errorf(`cannot use SignDigest with aws service %q`, ap.AWSService) + } +} diff --git a/plugins/rest/auth_test.go b/plugins/rest/auth_test.go index ddfc5db10f..f67bbcf64f 100644 --- a/plugins/rest/auth_test.go +++ b/plugins/rest/auth_test.go @@ -91,3 +91,47 @@ func TestECRWithoutOCIFails(t *testing.T) { } } } + +func TestOauth2WithAWSKMS(t *testing.T) { + conf := `{ + "name": "foo", + "url": "http://localhost", + "credentials": { + "oauth2": { + "grant_type": "client_credentials", + "aws_kms": { + "name": "arn:aws:kms:eu-west-1:account_no:key/key_id", + "algorithm": "ECDSA_SHA_256" + }, + "aws_signing": { + "service": "kms", + "environment_credentials": { + "aws_default_region": "eu-west-1" + } + }, + "token_url": "https://localhost", + "scopes": ["profile", "opa"], + "additional_claims": { + "aud": "some audience" + } + } + } + }` + + client, err := New([]byte(conf), map[string]*keys.Config{}) + if err != nil { + t.Fatalf("New() = %v", err) + } + + if _, err := client.config.Credentials.OAuth2.NewClient(client.config); err != nil { + t.Fatalf("OAuth2.NewClient() = %q", err) + } + + if client.config.Credentials.OAuth2.AWSKmsKey.Name != "arn:aws:kms:eu-west-1:account_no:key/key_id" { + t.Errorf("OAuth2.AWSKmsKey.Name = %v, want = %v", client.config.Credentials.OAuth2.AWSKmsKey.Name, "arn:aws:kms:eu-west-1:account_no:key/key_id") + } + + if client.config.Credentials.OAuth2.AWSSigningPlugin.kmsSignPlugin == nil { + t.Errorf("OAuth2.AWSSigningPlugin.kmsSignPlugin isn't setup") + } +} diff --git a/plugins/rest/aws.go b/plugins/rest/aws.go index 70025f6d8d..3e78b1fad2 100644 --- a/plugins/rest/aws.go +++ b/plugins/rest/aws.go @@ -517,3 +517,41 @@ func (ap *ecrAuthPlugin) refreshAuthorizationToken(ctx context.Context) error { ap.token = token return nil } + +// awsKMSSignPlugin signs digests using AWS KMS. +type awsKMSSignPlugin struct { + + // awsAuthPlugin is used to sign kms sign requests. + awsAuthPlugin *awsSigningAuthPlugin + + // kms represents the service for signing digests. + kms awskms + + logger logging.Logger +} + +type awskms interface { + SignDigest(ctx context.Context, digest []byte, keyID string, signingAlgorithm string, creds aws.Credentials, signatureVersion string) (string, error) +} + +func newKMSSignPlugin(ap *awsSigningAuthPlugin) *awsKMSSignPlugin { + return &awsKMSSignPlugin{ + awsAuthPlugin: ap, + kms: aws.NewKMS(ap.logger), + logger: ap.logger, + } +} + +func (ap *awsKMSSignPlugin) SignDigest(ctx context.Context, digest []byte, keyID string, signingAlgorithm string) (string, error) { + creds, err := ap.awsAuthPlugin.awsCredentialService().credentials(ctx) + if err != nil { + return "", fmt.Errorf("failed to get aws credentials: %w", err) + } + + signature, err := ap.kms.SignDigest(ctx, digest, keyID, signingAlgorithm, creds, ap.awsAuthPlugin.AWSSignatureVersion) + if err != nil { + return "", fmt.Errorf("kms: failed to sign digest: %w", err) + } + + return signature, nil +} diff --git a/plugins/rest/rest_test.go b/plugins/rest/rest_test.go index 0ef248ab95..96de1bfc07 100644 --- a/plugins/rest/rest_test.go +++ b/plugins/rest/rest_test.go @@ -34,6 +34,7 @@ import ( "github.com/open-policy-agent/opa/bundle" "github.com/open-policy-agent/opa/internal/jwx/jwa" "github.com/open-policy-agent/opa/internal/jwx/jws" + "github.com/open-policy-agent/opa/internal/providers/aws" "github.com/open-policy-agent/opa/keys" "github.com/open-policy-agent/opa/logging" "github.com/open-policy-agent/opa/tracing" @@ -543,6 +544,55 @@ func TestNew(t *testing.T) { } }`, grantTypeClientCredentials), }, + { + name: "Oauth2ClientCredentialsJWTAuthentication_with_AWS_KMS", + input: fmt.Sprintf(`{ + "name": "foo", + "url": "http://localhost", + "credentials": { + "oauth2": { + "grant_type": %q, + "aws_kms": { + "name": "arn:aws:kms:eu-west-1:account_no:key/key_id", + "algorithm": "ECDSA_SHA_256" + }, + "aws_signing": { + "service": "kms", + "environment_credentials": { + "aws_default_region": "eu-west-1" + } + }, + "token_url": "https://localhost", + "scopes": ["profile", "opa"], + "additional_claims": { + "aud": "some audience" + } + } + } + }`, grantTypeClientCredentials), + }, + { + name: "Oauth2ClientCredentialsJWTAuthentication_with_AWS_KMS_missing_credentials", + input: fmt.Sprintf(`{ + "name": "foo", + "url": "http://localhost", + "credentials": { + "oauth2": { + "grant_type": %q, + "aws_kms": { + "name": "arn:aws:kms:eu-west-1:account_no:key/key_id", + "algorithm": "ECDSA_SHA_256" + }, + "token_url": "https://localhost", + "scopes": ["profile", "opa"], + "additional_claims": { + "aud": "some audience" + } + } + } + }`, grantTypeClientCredentials), + wantErr: true, + }, { name: "S3WebIdentityMissingEnvVars", input: `{ @@ -1915,6 +1965,7 @@ type oauth2TestServer struct { expScope *[]string expAlgorithm jwa.SignatureAlgorithm expX5t string + expSignature string tokenType string tokenTTL int64 invocations int32 @@ -2110,11 +2161,17 @@ func (t *oauth2TestServer) handle(w http.ResponseWriter, r *http.Request) { } else { token = r.Form["client_assertion"][0] } - _, err := jws.Verify([]byte(token), t.expAlgorithm, t.verificationKey) - if err != nil { - t.t.Fatalf("Unexpected signature verification error %v", err) + if t.expSignature != "" { + signature := strings.Split(token, ".")[2] + if t.expSignature != signature { + t.t.Errorf("Expected expSignature %v, got %v", t.expSignature, signature) + } + } else { + _, err := jws.Verify([]byte(token), t.expAlgorithm, t.verificationKey) + if err != nil { + t.t.Fatalf("Unexpected signature verification error %v", err) + } } - if t.expX5t != "" { headerRaw, _ := base64.RawURLEncoding.DecodeString(strings.Split(token, ".")[0]) var headers map[string]string @@ -2344,3 +2401,117 @@ func (m *myPluginMock) NewClient(c Config) (*http.Client, error) { func (*myPluginMock) Prepare(*http.Request) error { return nil } + +func TestOauth2ClientCredentialsGrantTypeWithKms(t *testing.T) { + + // DER-encoded object from KMS as explained here: https://docs.aws.amazon.com/kms/latest/APIReference/API_Sign.html#API_Sign_ResponseSyntax + derEncodeSignature := []byte{48, 68, 2, 32, 84, 124, 17, 255, 68, 181, 189, 159, 77, 235, 242, 88, 85, 139, 84, 111, 204, 108, 235, 90, 128, 220, 247, 176, 215, 28, 188, 110, 19, 158, 137, 30, 2, 32, 88, 17, 176, 72, 157, 42, 1, 223, 69, 41, 225, 77, 121, 13, 117, 132, 146, 243, 45, 208, 207, 119, 233, 156, 96, 94, 192, 174, 136, 218, 206, 84} + // The signature representing the above object + jwtSignature := "VHwR_0S1vZ9N6_JYVYtUb8xs61qA3Pew1xy8bhOeiR5YEbBInSoB30Up4U15DXWEkvMt0M936ZxgXsCuiNrOVA" + + ts := testServer{t: t, expBearerToken: "token_1"} + ts.start() + defer ts.stop() + + ots := oauth2TestServer{ + t: t, + tokenTTL: 300, + expScope: &[]string{"scope1", "scope2"}, + expJwtCredential: true, + expAlgorithm: jwa.ES256, + expGrantType: grantTypeClientCredentials, + expSignature: jwtSignature, + } + ots.start() + defer ots.stop() + + kmsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var signRequest = &aws.KMSSignRequest{} + if r.Body != nil { + bodyBytes, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("failed to read kms sign request = %v", err) + } + defer r.Body.Close() + err = json.Unmarshal(bodyBytes, signRequest) + if err != nil { + t.Fatalf("failed to unmarshall kms sign request = %v", err) + } + } + responseFmt := `{"KeyId": "%s", "Signature": "%s", "SigningAlgorithm": "%s"}` + responsePayload := fmt.Sprintf(responseFmt, signRequest.KeyID, base64.StdEncoding.EncodeToString(derEncodeSignature), signRequest.SigningAlgorithm) + if _, err := io.WriteString(w, responsePayload); err != nil { + t.Fatalf("io.WriteString(w, payload) = %v", err) + } + })) + defer kmsServer.Close() + + logger := logging.New() + logger.SetLevel(logging.Debug) + + kms := aws.NewKMSWithURLClient(kmsServer.URL, kmsServer.Client(), logger) + client := newOauth2KmsClientCredentialsTestClient(t, &ts, &ots, kms) + ctx := context.Background() + _, err := client.Do(ctx, "GET", "test") + if err != nil { + t.Fatalf("Unexpected error %v", err) + } + + _, err = client.Do(ctx, "GET", "test") + if err != nil { + t.Fatalf("Unexpected error %v", err) + } +} + +// Create client to test ClientCredentials grant using KMS +func newOauth2KmsClientCredentialsTestClient(t *testing.T, ts *testServer, ots *oauth2TestServer, kms *aws.KMS) *Client { + config := fmt.Sprintf(`{ + "name": "foo", + "url": %q, + "allow_insecure_tls": true, + "credentials": { + "oauth2": { + "token_url": "%v/token", + "grant_type": %q, + "scopes": ["scope1", "scope2"], + "additional_claims": { + "aud": "test-audience", + "iss": "client-one" + }, + "aws_kms": { + "name": "arn:aws:kms:eu-west-1:account_no:key/key_id", + "algorithm": "ECDSA_SHA_256" + }, + "aws_signing": { + "service": "kms", + "environment_credentials": { + "aws_default_region": "eu-west-1" + } + } + } + } + }`, ts.server.URL, ots.server.URL, grantTypeClientCredentials) + + // Setup variables for environment_credentials{} + t.Setenv(accessKeyEnvVar, accessKeyEnvVar) + t.Setenv(secretKeyEnvVar, secretKeyEnvVar) + t.Setenv(awsRegionEnvVar, awsRegionEnvVar) + + client, err := New([]byte(config), map[string]*keys.Config{}) + + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if _, err := client.config.Credentials.OAuth2.NewClient(client.config); err != nil { + t.Fatalf("OAuth2.NewClient() = %q", err) + } + + if client.config.Credentials.OAuth2.AWSSigningPlugin.kmsSignPlugin == nil { + t.Errorf("OAuth2.AWSSigningPlugin.kmsSignPlugin isn't setup") + } + + // setup fake KMS signer + client.config.Credentials.OAuth2.AWSSigningPlugin.kmsSignPlugin.kms = kms + return &client +}