Skip to content

Commit

Permalink
Add HTTP Client (#7)
Browse files Browse the repository at this point in the history
  • Loading branch information
MicahParks authored Dec 11, 2023
1 parent e8e33ab commit cb01b09
Show file tree
Hide file tree
Showing 17 changed files with 858 additions and 360 deletions.
9 changes: 0 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,15 +82,6 @@ not implement any cryptographic algorithms itself.
* This project does not currently support JWK Set encryption using JWE. This would involve implementing the relevant JWE
specifications. It may be implemented in the future if there is interest. Open a GitHub issue to express interest.

# Test coverage

```
$ go test -cover
PASS
coverage: 85.5% of statements
ok github.com/MicahParks/jwkset 0.013s
```

# See also

* [`github.com/MicahParks/jcp`](https://github.com/MicahParks/jcp)
Expand Down
219 changes: 219 additions & 0 deletions client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
package jwkset

import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/url"
"time"
)

var (
// ErrNewClient fails to create a new JWK Set client.
ErrNewClient = errors.New("failed to create new JWK Set client")
)

// HTTPClientOptions are options for creating a new JWK Set client.
type HTTPClientOptions struct {
// Given contains keys known from outside HTTP URLs.
Given Storage
// HTTPURLs are a mapping of HTTP URLs to JWK Set endpoints to storage implementations for the keys located at the
// URL. If empty, HTTP will not be used.
HTTPURLs map[string]Storage
// PrioritizeHTTP is a flag that indicates whether keys from the HTTP URL should be prioritized over keys from the
// given storage.
PrioritizeHTTP bool
}

// Client is a JWK Set client.
type httpClient struct {
given Storage
httpURLs map[string]Storage
prioritizeHTTP bool
}

// NewHTTPClient creates a new JWK Set client from remote HTTP resources.
func NewHTTPClient(options HTTPClientOptions) (Storage, error) {
if options.Given == nil && len(options.HTTPURLs) == 0 {
return nil, fmt.Errorf("%w: no given keys or HTTP URLs", ErrNewClient)
}
for u, store := range options.HTTPURLs {
if store == nil {
options.HTTPURLs[u] = NewMemoryStorage()
}
}
given := options.Given
if given == nil {
given = NewMemoryStorage()
}
c := httpClient{
given: given,
httpURLs: options.HTTPURLs,
prioritizeHTTP: options.PrioritizeHTTP,
}
return c, nil
}

// NewDefaultHTTPClient creates a new JWK Set client with default options from remote HTTP resources.
func NewDefaultHTTPClient(urls []string) (Storage, error) {
clientOptions := HTTPClientOptions{
HTTPURLs: make(map[string]Storage),
}
for _, u := range urls {
parsed, err := url.ParseRequestURI(u)
if err != nil {
return nil, fmt.Errorf("failed to parse given URL %q: %w", u, errors.Join(err, ErrNewClient))
}
u = parsed.String()
refreshErrorHandler := func(ctx context.Context, err error) {
slog.Default().ErrorContext(ctx, "Failed to refresh HTTP JWK Set from remote HTTP resource.",
"error", err,
"url", u,
)
}
options := HTTPClientStorageOptions{
NoErrorReturnFirstHTTPReq: true,
RefreshErrorHandler: refreshErrorHandler,
RefreshInterval: time.Hour,
}
c, err := NewStorageFromHTTP(parsed, options)
if err != nil {
return nil, fmt.Errorf("failed to create HTTP client storage for %q: %w", u, errors.Join(err, ErrNewClient))
}
clientOptions.HTTPURLs[u] = c
}
return NewHTTPClient(clientOptions)
}

func (c httpClient) KeyDelete(ctx context.Context, keyID string) (ok bool, err error) {
ok, err = c.given.KeyDelete(ctx, keyID)
if err != nil && !errors.Is(err, ErrKeyNotFound) {
return false, fmt.Errorf("failed to delete key with ID %q from given storage due to error: %w", keyID, err)
}
if ok {
return true, nil
}
for _, store := range c.httpURLs {
ok, err = store.KeyDelete(ctx, keyID)
if err != nil && !errors.Is(err, ErrKeyNotFound) {
return false, fmt.Errorf("failed to delete key with ID %q from HTTP storage due to error: %w", keyID, err)
}
if ok {
return true, nil
}
}
return false, nil
}
func (c httpClient) KeyRead(ctx context.Context, keyID string) (jwk JWK, err error) {
if !c.prioritizeHTTP {
jwk, err = c.given.KeyRead(ctx, keyID)
switch {
case errors.Is(err, ErrKeyNotFound):
// Do nothing.
case err != nil:
return JWK{}, fmt.Errorf("failed to find JWT key with ID %q in given storage due to error: %w", keyID, err)
default:
return jwk, nil
}
}
for _, store := range c.httpURLs {
jwk, err = store.KeyRead(ctx, keyID)
switch {
case errors.Is(err, ErrKeyNotFound):
continue
case err != nil:
return JWK{}, fmt.Errorf("failed to find JWT key with ID %q in HTTP storage due to error: %w", keyID, err)
default:
return jwk, nil
}
}
if c.prioritizeHTTP {
jwk, err = c.given.KeyRead(ctx, keyID)
switch {
case errors.Is(err, ErrKeyNotFound):
// Do nothing.
case err != nil:
return JWK{}, fmt.Errorf("failed to find JWT key with ID %q in given storage due to error: %w", keyID, err)
default:
return jwk, nil
}
}
return JWK{}, fmt.Errorf("%w %q", ErrKeyNotFound, keyID)
}
func (c httpClient) KeyReadAll(ctx context.Context) ([]JWK, error) {
jwks, err := c.given.KeyReadAll(ctx)
if err != nil {
return nil, fmt.Errorf("failed to snapshot given keys due to error: %w", err)
}
for u, store := range c.httpURLs {
j, err := store.KeyReadAll(ctx)
if err != nil {
return nil, fmt.Errorf("failed to snapshot HTTP keys from %q due to error: %w", u, err)
}
jwks = append(jwks, j...)
}
return jwks, nil
}
func (c httpClient) KeyWrite(ctx context.Context, jwk JWK) error {
return c.given.KeyWrite(ctx, jwk)
}

func (c httpClient) JSON(ctx context.Context) (json.RawMessage, error) {
m, err := c.combineStorage(ctx)
if err != nil {
return nil, fmt.Errorf("failed to combine storage due to error: %w", err)
}
return m.JSON(ctx)
}
func (c httpClient) JSONPublic(ctx context.Context) (json.RawMessage, error) {
m, err := c.combineStorage(ctx)
if err != nil {
return nil, fmt.Errorf("failed to combine storage due to error: %w", err)
}
return m.JSONPublic(ctx)
}
func (c httpClient) JSONPrivate(ctx context.Context) (json.RawMessage, error) {
m, err := c.combineStorage(ctx)
if err != nil {
return nil, fmt.Errorf("failed to combine storage due to error: %w", err)
}
return m.JSONPrivate(ctx)
}
func (c httpClient) JSONWithOptions(ctx context.Context, marshalOptions JWKMarshalOptions, validationOptions JWKValidateOptions) (json.RawMessage, error) {
m, err := c.combineStorage(ctx)
if err != nil {
return nil, fmt.Errorf("failed to combine storage due to error: %w", err)
}
return m.JSONWithOptions(ctx, marshalOptions, validationOptions)
}
func (c httpClient) Marshal(ctx context.Context) (JWKSMarshal, error) {
m, err := c.combineStorage(ctx)
if err != nil {
return JWKSMarshal{}, fmt.Errorf("failed to combine storage due to error: %w", err)
}
return m.Marshal(ctx)
}
func (c httpClient) MarshalWithOptions(ctx context.Context, marshalOptions JWKMarshalOptions, validationOptions JWKValidateOptions) (JWKSMarshal, error) {
m, err := c.combineStorage(ctx)
if err != nil {
return JWKSMarshal{}, fmt.Errorf("failed to combine storage due to error: %w", err)
}
return m.MarshalWithOptions(ctx, marshalOptions, validationOptions)
}

func (c httpClient) combineStorage(ctx context.Context) (Storage, error) {
jwks, err := c.KeyReadAll(ctx)
if err != nil {
return nil, fmt.Errorf("failed to snapshot keys due to error: %w", err)
}
m := NewMemoryStorage()
for _, jwk := range jwks {
err = m.KeyWrite(ctx, jwk)
if err != nil {
return nil, fmt.Errorf("failed to write key to memory storage due to error: %w", err)
}
}
return m, nil
}
103 changes: 103 additions & 0 deletions client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package jwkset

import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"testing"
)

func TestClient(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

kid := "my-key-id"
secret := []byte("my-hmac-secret")
serverStore := NewMemoryStorage()
marshalOptions := JWKMarshalOptions{
Private: true,
}
metadata := JWKMetadataOptions{
KID: kid,
}
options := JWKOptions{
Marshal: marshalOptions,
Metadata: metadata,
}
jwk, err := NewJWKFromKey(secret, options)
if err != nil {
t.Fatalf("Failed to create a JWK from the given HMAC secret.\nError: %s", err)
}
err = serverStore.KeyWrite(ctx, jwk)
if err != nil {
t.Fatalf("Failed to write the given JWK to the store.\nError: %s", err)
}
rawJWKS, err := serverStore.JSON(ctx)
if err != nil {
t.Fatalf("Failed to get the JSON.\nError: %s", err)
}

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(rawJWKS)
}))

clientStore, err := NewDefaultHTTPClient([]string{server.URL})
if err != nil {
t.Fatalf("Failed to create a new HTTP client.\nError: %s", err)
}

jwk, err = clientStore.KeyRead(ctx, kid)
if err != nil {
t.Fatalf("Failed to read the JWK.\nError: %s", err)
}

if !bytes.Equal(jwk.Key().([]byte), secret) {
t.Fatalf("The key read from the HTTP client did not match the original key.")
}

jwks, err := clientStore.KeyReadAll(ctx)
if err != nil {
t.Fatalf("Failed to read all the JWKs.\nError: %s", err)
}
if len(jwks) != 1 {
t.Fatalf("Expected to read 1 JWK, but got %d.", len(jwks))
}
if !bytes.Equal(jwks[0].Key().([]byte), secret) {
t.Fatalf("The key read from the HTTP client did not match the original key.")
}

ok, err := clientStore.KeyDelete(ctx, kid)
if err != nil {
t.Fatalf("Failed to delete the JWK.\nError: %s", err)
}
if !ok {
t.Fatalf("Expected the key to be deleted.")
}

err = clientStore.KeyWrite(ctx, jwk)
if err != nil {
t.Fatalf("Failed to write the JWK.\nError: %s", err)
}
jwk, err = clientStore.KeyRead(ctx, kid)
if err != nil {
t.Fatalf("Failed to read the JWK.\nError: %s", err)
}
if !bytes.Equal(jwk.Key().([]byte), secret) {
t.Fatalf("The key read from the HTTP client did not match the original key.")
}
}

func TestClientError(t *testing.T) {
_, err := NewHTTPClient(HTTPClientOptions{})
if err == nil {
t.Fatalf("Expected an error when creating a new HTTP client without any URLs.")
}
}

func TestClientJSON(t *testing.T) {
c := httpClient{
given: NewMemoryStorage(),
}
testJSON(context.Background(), t, c)
}
4 changes: 2 additions & 2 deletions cmd/jwksetinfer/go.work
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
go 1.21.4
go 1.21.5

use (
../..
.
../..
)
6 changes: 3 additions & 3 deletions cmd/jwksetinfer/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ func main() {
allPEM = s.String()
}

jwks := jwkset.NewMemory()
jwks := jwkset.NewMemoryStorage()

i := 0
const kidPrefix = "UniqueKeyID"
Expand Down Expand Up @@ -81,7 +81,7 @@ func main() {
)
os.Exit(1)
}
err = jwks.Store.WriteKey(ctx, jwk)
err = jwks.KeyWrite(ctx, jwk)
if err != nil {
l.Error("Failed to write JWK.",
logErr, err,
Expand Down Expand Up @@ -110,7 +110,7 @@ func main() {
)
os.Exit(1)
}
err = jwks.Store.WriteKey(ctx, jwk)
err = jwks.KeyWrite(ctx, jwk)
if err != nil {
l.Error("Failed to write JWK.",
logErr, err,
Expand Down
Loading

0 comments on commit cb01b09

Please sign in to comment.