Skip to content

Commit

Permalink
Add JWKS data source for fetching public keys for JWT validation (#447)
Browse files Browse the repository at this point in the history
Co-authored-by: Luiz Aoqui <luiz@hashicorp.com>
Co-authored-by: Luiz Aoqui <lgfa29@gmail.com>
  • Loading branch information
3 people committed Apr 24, 2024
1 parent 4342e7f commit b167023
Show file tree
Hide file tree
Showing 5 changed files with 260 additions and 0 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
@@ -1,6 +1,7 @@
## 2.2.1 (Unreleased)

IMPROVEMENTS:
* **New Data Source**: `nomad_jwks` to retrieve the public keys used for signing workload identity JWTs ([#447](https://github.com/hashicorp/terraform-provider-nomad/pull/447))
* resource/acl_auth_method: add support for configuring a JWT auth-method ([#448](https://github.com/hashicorp/terraform-provider-nomad/pull/448))

## 2.2.0 (March 12, 2024)
Expand Down
175 changes: 175 additions & 0 deletions nomad/data_source_jwks.go
@@ -0,0 +1,175 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0

package nomad

import (
"fmt"
"log"

"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"math/big"

"github.com/hashicorp/nomad/api"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/id"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)

func dataSourceJWKS() *schema.Resource {
return &schema.Resource{
Read: dataSourceJWKSRead,
Schema: map[string]*schema.Schema{
"keys": {
Description: "JSON Web Key Set (JWKS) public keys for validating workload identity JWTs",
Type: schema.TypeList,
Computed: true,
Elem: &schema.Resource{
Schema: map[string]*schema.Schema{
"key_use": {
Type: schema.TypeString,
Computed: true,
},
"key_type": {
Type: schema.TypeString,
Computed: true,
},
"key_id": {
Type: schema.TypeString,
Computed: true,
},
"algorithm": {
Type: schema.TypeString,
Computed: true,
},
"modulus": {
Type: schema.TypeString,
Computed: true,
},
"exponent": {
Type: schema.TypeString,
Computed: true,
},
},
},
},
"pem_keys": {
Description: "JWKS as a list of PEM keys",
Type: schema.TypeList,
Elem: &schema.Schema{Type: schema.TypeString},
Computed: true,
},
},
}
}

type Key struct {
KeyUse string `json:"use"`
KeyType string `json:"kty"`
KeyId string `json:"kid"`
Algorithm string `json:"alg"`
Modulus string `json:"n"`
Exponent string `json:"e"`
}

func dataSourceJWKSRead(d *schema.ResourceData, meta any) error {
client := meta.(ProviderConfig).client
operator := client.Raw()
queryOpts := &api.QueryOptions{}

jwks := struct {
Keys []Key `json:"keys"`
}{}

log.Printf("[DEBUG] Reading JWKS from Nomad")
_, err := operator.Query("/.well-known/jwks.json", &jwks, queryOpts)

if err != nil {
return fmt.Errorf("error reading JWKS from Nomad: %s", err)
}

if len(jwks.Keys) == 0 {
return fmt.Errorf("no keys found")
}

d.SetId(id.UniqueId())
if err := d.Set("keys", fromKeys(jwks.Keys)); err != nil {
return fmt.Errorf("error setting JWKS: %#v", err)
}

pemKeys := make([]string, 0, len(jwks.Keys))

for _, key := range jwks.Keys {
pemKey, err := keyToPem(key)
if err != nil {
return fmt.Errorf("Could not encode JWK as PEM: %s", err)
}
pemKeys = append(pemKeys, pemKey)
}

if err := d.Set("pem_keys", pemKeys); err != nil {
return fmt.Errorf("error setting JWKS pemKeys: %s", err)
}

return nil
}

func keyToPem(key Key) (string, error) {

// Nomad also supports EdDSA keys, but they are not used for OIDC so only
// RSA keys should be listed in this endpoint.
if key.KeyType != "RSA" {
return "", fmt.Errorf("Key type not supported: %s", key.Algorithm)
}
modulus, err := base64.RawURLEncoding.DecodeString(key.Modulus)

if err != nil {
return "", fmt.Errorf("Could not decode modulus as base64 from JWK: %s", err)
}

exponent, err := base64.RawURLEncoding.DecodeString(key.Exponent)

if err != nil {
return "", fmt.Errorf("Could not decode exponent as base64 from JWK: %s", err)
}

modulusInt := new(big.Int)
modulusInt.SetBytes(modulus)

exponentInt := new(big.Int)
exponentInt.SetBytes(exponent)

publicKey := rsa.PublicKey{N: modulusInt, E: int(exponentInt.Uint64())}

x509Cert, err := x509.MarshalPKIXPublicKey(&publicKey)

if err != nil {
return "", fmt.Errorf("Could not marshal JWK public key to X509 PKIX: %s", err)
}

x509CertEncoded := pem.EncodeToMemory(
&pem.Block{
Type: "PUBLIC KEY",
Bytes: x509Cert,
})

return string(x509CertEncoded), nil
}

func fromKeys(keys []Key) []interface{} {
output := make([]interface{}, 0, len(keys))
for _, key := range keys {
p := map[string]interface{}{
"key_use": key.KeyUse,
"key_type": key.KeyType,
"key_id": key.KeyId,
"algorithm": key.Algorithm,
"modulus": key.Modulus,
"exponent": key.Exponent,
}
output = append(output, p)
}
return output
}
50 changes: 50 additions & 0 deletions nomad/data_source_jwks_test.go
@@ -0,0 +1,50 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0

package nomad

import (
"crypto/x509"
"encoding/pem"
"fmt"
"regexp"
"testing"

"github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource"
)

const testAccNomadJWKSConfig = `data "nomad_jwks" "test" {}`

func TestAccDataSourceNomadJWKS_Basic(t *testing.T) {
dataSourceName := "data.nomad_jwks.test"
expectedKeyCount := "1"

resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
Providers: testProviders,
Steps: []resource.TestStep{
{
Config: testAccNomadJWKSConfig,
},
{
Config: testAccNomadJWKSConfig,
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr(dataSourceName, "keys.#", expectedKeyCount),
resource.TestMatchResourceAttr(dataSourceName, "keys.0.key_type", regexp.MustCompile("RSA")),
resource.TestCheckResourceAttr(dataSourceName, "pem_keys.#", expectedKeyCount),
resource.TestCheckResourceAttrWith(dataSourceName, "pem_keys.0", validateKeyPEM),
),
},
},
})
}

func validateKeyPEM(keyPEM string) error {
fmt.Printf(keyPEM)
block, _ := pem.Decode([]byte(keyPEM))
if block == nil {
return fmt.Errorf("failed to parse key PEM")
}
_, err := x509.ParsePKIXPublicKey(block.Bytes)
return err
}
1 change: 1 addition & 0 deletions nomad/provider.go
Expand Up @@ -151,6 +151,7 @@ func Provider() *schema.Provider {
"nomad_deployments": dataSourceDeployments(),
"nomad_job": dataSourceJob(),
"nomad_job_parser": dataSourceJobParser(),
"nomad_jwks": dataSourceJWKS(),
"nomad_namespace": dataSourceNamespace(),
"nomad_namespaces": dataSourceNamespaces(),
"nomad_node_pool": dataSourceNodePool(),
Expand Down
33 changes: 33 additions & 0 deletions website/docs/d/jwks.html.markdown
@@ -0,0 +1,33 @@
---
layout: "nomad"
page_title: "Nomad: nomad_jwks"
sidebar_current: "docs-nomad-datasource-jwks"
description: |-
Retrieve the cluster JWKS public keys.
---

# nomad_jwks

Retrieve the cluster JWKS public keys.

The keys are returned both as a list of maps (`keys`), and as a list of PEM-encoded strings
(`pem_keys`), which may be more convenient for use.

## Example Usage

```hcl
data "nomad_jwks" "example" {}
```

## Attribute Reference

The following attributes are exported:
* `keys`: `list of maps` a list of JWK keys in structured format: see [RFC7517](https://datatracker.ietf.org/doc/html/rfc7517) for the
JWK field meanings.
* `key_use` `(string)` - JWK field `use`
* `key_type` `(string)` - JWK field `kty`
* `key_id` `(string)` - JWK field `kid`
* `algorithm` `(string)` - JWK field `alg`
* `modulus` `(string)` - JWK field `n`
* `exponent` `(string)` - JWK field `e`
* `pem_keys`: `list of strings` a list JWK keys rendered as PEM-encoded X.509 keys

0 comments on commit b167023

Please sign in to comment.