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 <prasanth.ullattil@dnb.no>
This commit is contained in:
Prasanth Ullattil
2023-05-22 16:04:55 +02:00
committed by Ashutosh Narkar
parent f74a5f61d8
commit db2f8ae7bb
7 changed files with 709 additions and 29 deletions
+200 -15
View File
@@ -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)
}
}
+44
View File
@@ -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")
}
}
+38
View File
@@ -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
}
+175 -4
View File
@@ -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
}