Skip to content

Commit

Permalink
Add AWS KMS support for OAuth2 Client Credentials JWT authentication
Browse files Browse the repository at this point in the history
This implementaion adds new configuration properties to "oauth2"
aws_kms_key: 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 <prasanth.ullattil@dnb.no>
  • Loading branch information
prasanthu committed Jun 15, 2023
1 parent c29d375 commit 40ded4a
Show file tree
Hide file tree
Showing 6 changed files with 535 additions and 25 deletions.
60 changes: 50 additions & 10 deletions docs/content/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_key` 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_key.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_key.algorithm` | `string` | No | Specifies the signing algorithm used by the key `aws_kms_key.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_key` 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_key`, 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.
Expand Down Expand Up @@ -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_key:
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.
Expand Down
98 changes: 98 additions & 0 deletions internal/providers/aws/kms.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
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,
}
}

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
}
100 changes: 100 additions & 0 deletions internal/providers/aws/kms_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
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 := KMS{
endpoint: func(string) string { return server.URL },
client: server.Client(),
logger: 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)
})
}
}

0 comments on commit 40ded4a

Please sign in to comment.