Skip to content

Commit

Permalink
Update ns key discovery to follow openid-style lookups
Browse files Browse the repository at this point in the history
Previously, namespace keys were fetched by hardcoding the issuer jwks relative
to the namespace. Now we follow openid-style lookups by fetching the JSON
located at <registry url>/api/v2.0/registry/metadata/<namespace>/.well-known/namespace-configuration
and following the URL associated with the `jwks_uri` key. Currently this is still the same key path
in all instances that I'm aware of, but in theory this could be expanded to point to other locations.

The architecture of this PR is that a GET request to the namespace-configuration url mentioned above
should dynamically generate the `jwks_uri` key with associated value. The value is used throughout
and assigned the issuer URL in various places where we need to create a token on behalf of the
namespace.
  • Loading branch information
jhiemstrawisc committed Jan 3, 2024
1 parent 2f1315d commit ecd6073
Show file tree
Hide file tree
Showing 6 changed files with 171 additions and 65 deletions.
58 changes: 46 additions & 12 deletions director/origin_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ package director
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
Expand All @@ -31,11 +33,12 @@ import (
"github.com/lestrrat-go/jwx/v2/jwa"
"github.com/lestrrat-go/jwx/v2/jwk"
"github.com/lestrrat-go/jwx/v2/jwt"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"

"github.com/pelicanplatform/pelican/config"
"github.com/pelicanplatform/pelican/param"
"github.com/pelicanplatform/pelican/utils"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
)

type (
Expand Down Expand Up @@ -65,7 +68,7 @@ func CreateAdvertiseToken(namespace string) (string, error) {
// TODO: Need to come back and carefully consider a few naming practices.
// Here, issuerUrl is actually the registry database url, and not
// the token issuer url for this namespace
issuerUrl, err := GetRegistryIssuerURL(namespace)
issuerUrl, err := GetNSIssuerURL(namespace)
if err != nil {
return "", err
}
Expand Down Expand Up @@ -109,7 +112,7 @@ func CreateAdvertiseToken(namespace string) (string, error) {
// see if the entity is authorized to advertise an origin for the
// namespace
func VerifyAdvertiseToken(ctx context.Context, token, namespace string) (bool, error) {
issuerUrl, err := GetRegistryIssuerURL(namespace)
issuerUrl, err := GetNSIssuerURL(namespace)
if err != nil {
return false, err
}
Expand Down Expand Up @@ -259,18 +262,49 @@ func VerifyDirectorTestReportToken(strToken string) (bool, error) {
return false, nil
}

func GetRegistryIssuerURL(prefix string) (string, error) {
namespace_url_string := param.Federation_RegistryUrl.GetString()
if namespace_url_string == "" {
return "", errors.New("Namespace URL is not set")
// For a given prefix, get the url of the issuer/public key from the registry
// This works by looking up the namespace-configuration json for the namespace
// and grabbing the value corresponding to the "jwks_uri" key.
func GetNSIssuerURL(prefix string) (string, error) {
if prefix == "" || !strings.HasPrefix(prefix, "/") {
return "", errors.New(fmt.Sprintf("the prefix \"%s\" is invalid", prefix))
}
namespace_url, err := url.Parse(namespace_url_string)
registryUrlStr := param.Federation_RegistryUrl.GetString()
if registryUrlStr == "" {
return "", errors.New("federation registry URL is not set and was not discovered")
}
registryUrl, err := url.Parse(registryUrlStr)
if err != nil {
return "", err
}
namespace_url.Path, err = url.JoinPath(namespace_url.Path, "api", "v2.0", "registry", "metadata", prefix, ".well-known", "issuer.jwks")

registryUrl.Path, err = url.JoinPath(registryUrl.Path, "api", "v2.0", "registry", "metadata", prefix, ".well-known", "namespace-configuration")
if err != nil {
return "", err
return "", errors.Wrapf(err, "failed to construct namespace-configuration lookup URL for prefix %s", prefix)
}

// Get/parse the namespace-configuration JSON to lookup key location
client := &http.Client{Transport: config.GetTransport()}
originConfig, err := client.Get(registryUrl.String())
if err != nil {
return "", errors.Wrapf(err, "failed to lookup namespace configuration for prefix %s", prefix)
}
defer originConfig.Body.Close()

body, err := io.ReadAll(originConfig.Body)
if err != nil {
return "", errors.Wrapf(err, "failed to read response body from %s", registryUrl.String())
}

var originCfgMap map[string]string
err = json.Unmarshal(body, &originCfgMap)
if err != nil {
return "", errors.Wrapf(err, "failed to unmarshal namespace configuration for prefix %s", prefix)
}

if keyLoc, ok := originCfgMap["jwks_uri"]; ok {
return keyLoc, nil
} else {
return "", errors.New(fmt.Sprintf("no key found in namespace configuration for prefix %s", prefix))
}
return namespace_url.String(), nil
}
86 changes: 59 additions & 27 deletions director/origin_api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ package director
import (
"context"
"crypto/elliptic"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"testing"
"time"
Expand All @@ -11,14 +14,35 @@ import (
"github.com/lestrrat-go/jwx/v2/jwa"
"github.com/lestrrat-go/jwx/v2/jwk"
"github.com/lestrrat-go/jwx/v2/jwt"
"github.com/pelicanplatform/pelican/config"
"github.com/pelicanplatform/pelican/test_utils"
"github.com/spf13/viper"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/sync/errgroup"

"github.com/pelicanplatform/pelican/config"
"github.com/pelicanplatform/pelican/test_utils"
)

// For these tests, we only need to lookup key locations. Create a dummy registry that only
// the jwks_uri location for the given key. Once a server is instantiated, it will only return
// locations for the provided prefix. To change prefixes, create a new registry mockup.
func registryMockup(t *testing.T, prefix string) *httptest.Server {
registryUrl, _ := url.Parse("https://registry.com:8446")
path, err := url.JoinPath("/api/v2.0/registry/metadata", prefix, ".well-known/issuer.jwks")
if err != nil {
t.Fatalf("Failed to parse key path for prefix %s", prefix)
}
registryUrl.Path = path

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
jsonResponse := `{"jwks_uri": "` + registryUrl.String() + `"}`
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(jsonResponse))
}))
return server
}

func TestVerifyAdvertiseToken(t *testing.T) {
/*
* Runs unit tests on the VerifyAdvertiseToken function
Expand All @@ -35,14 +59,17 @@ func TestVerifyAdvertiseToken(t *testing.T) {
//Setup a private key and a token
viper.Set("IssuerKey", kfile)

viper.Set("Federation.RegistryUrl", "https://get-your-tokens.org")
registry := registryMockup(t, "/test-namespace")
defer registry.Close()

viper.Set("Federation.RegistryUrl", registry.URL)
viper.Set("Federation.DirectorURL", "https://director-url.org")

kSet, err := config.GetIssuerPublicJWKS()
ar := MockCache{
GetFn: func(key string, keyset *jwk.Set) (jwk.Set, error) {
if key != "https://get-your-tokens.org/api/v2.0/registry/metadata/test-namespace/.well-known/issuer.jwks" {
t.Errorf("expecting: https://get-your-tokens.org/api/v2.0/registry/metadata/test-namespace/.well-known/issuer.jwks, got %q", key)
if key != "https://registry.com:8446/api/v2.0/registry/metadata/test-namespace/.well-known/issuer.jwks" {
t.Errorf("expecting: https://registry.com:8446/api/v2.0/registry/metadata/test-namespace/.well-known/issuer.jwks, got %q", key)
}
return *keyset, nil
},
Expand All @@ -59,13 +86,13 @@ func TestVerifyAdvertiseToken(t *testing.T) {
}
namespaceKeysMutex.Lock()
defer namespaceKeysMutex.Unlock()
namespaceKeys.Set("test-namespace", &ar, ttlcache.DefaultTTL)
namespaceKeys.Set("/test-namespace", &ar, ttlcache.DefaultTTL)
}()

// A verified token with a the correct scope - should return no error
tok, err := CreateAdvertiseToken("test-namespace")
tok, err := CreateAdvertiseToken("/test-namespace")
assert.NoError(t, err)
ok, err := VerifyAdvertiseToken(ctx, tok, "test-namespace")
ok, err := VerifyAdvertiseToken(ctx, tok, "/test-namespace")
assert.NoError(t, err)
assert.Equal(t, true, ok, "Expected scope to be 'pelican.advertise'")

Expand All @@ -82,7 +109,7 @@ func TestVerifyAdvertiseToken(t *testing.T) {

signed, err := jwt.Sign(scopelessTok, jwt.WithKey(jwa.ES256, key))

ok, err = VerifyAdvertiseToken(ctx, string(signed), "test-namespace")
ok, err = VerifyAdvertiseToken(ctx, string(signed), "/test-namespace")
assert.Equal(t, false, ok)
assert.Equal(t, "No scope is present; required to advertise to director", err.Error())

Expand All @@ -96,7 +123,7 @@ func TestVerifyAdvertiseToken(t *testing.T) {

signed, err = jwt.Sign(nonStrScopeTok, jwt.WithKey(jwa.ES256, key))

ok, err = VerifyAdvertiseToken(ctx, string(signed), "test-namespace")
ok, err = VerifyAdvertiseToken(ctx, string(signed), "/test-namespace")
assert.Equal(t, false, ok)
assert.Equal(t, "scope claim in token is not string-valued", err.Error())

Expand All @@ -110,7 +137,7 @@ func TestVerifyAdvertiseToken(t *testing.T) {

signed, err = jwt.Sign(wrongScopeTok, jwt.WithKey(jwa.ES256, key))

ok, err = VerifyAdvertiseToken(ctx, string(signed), "test-namespace")
ok, err = VerifyAdvertiseToken(ctx, string(signed), "/test-namespace")
assert.Equal(t, false, ok, "Should fail due to incorrect scope name")
assert.NoError(t, err, "Incorrect scope name should not throw and error")
}
Expand All @@ -131,41 +158,46 @@ func TestCreateAdvertiseToken(t *testing.T) {
err := config.GeneratePrivateKey(kfile, elliptic.P521())
assert.NoError(t, err)

// Test without a namsepace set and check to see if it returns the expected error
tok, err := CreateAdvertiseToken("test-namespace")
registry := registryMockup(t, "/test-namespace")
defer registry.Close()

// Test without a registry URL set and check to see if it returns the expected error
tok, err := CreateAdvertiseToken("/test-namespace")
assert.Equal(t, "", tok)
assert.Equal(t, "Namespace URL is not set", err.Error())
viper.Set("Federation.RegistryUrl", "https://get-your-tokens.org")
assert.Equal(t, "federation registry URL is not set and was not discovered", err.Error())
viper.Set("Federation.RegistryUrl", registry.URL)

// Test without a DirectorURL set and check to see if it returns the expected error
tok, err = CreateAdvertiseToken("test-namespace")
tok, err = CreateAdvertiseToken("/test-namespace")
assert.Equal(t, "", tok)
assert.Equal(t, "Director URL is not known; cannot create advertise token", err.Error())
viper.Set("Federation.DirectorURL", "https://director-url.org")

// Test the CreateAdvertiseToken with good values and test that it returns a non-nil token value and no error
tok, err = CreateAdvertiseToken("test-namespace")
tok, err = CreateAdvertiseToken("/test-namespace")
assert.Equal(t, nil, err)
assert.NotEqual(t, "", tok)
}

func TestGetRegistryIssuerURL(t *testing.T) {
/*
* Runs unit tests on the GetRegistryIssuerURL function
*/
func TestGetNSIssuerURL(t *testing.T) {
viper.Reset()

emptyRegistry := registryMockup(t, "")
defer emptyRegistry.Close()

viper.Set("Federation.RegistryUrl", emptyRegistry.URL)
// No namespace url has been set, so an error is expected
url, err := GetRegistryIssuerURL("")
url, err := GetNSIssuerURL("")
assert.Equal(t, "the prefix \"\" is invalid", err.Error())
assert.Equal(t, "", url)
assert.Equal(t, "Namespace URL is not set", err.Error())

// Test to make sure the path is as expected
viper.Set("Federation.RegistryUrl", "test-path")
url, err = GetRegistryIssuerURL("test-prefix")
registry := registryMockup(t, "/test-prefix")
defer registry.Close()
viper.Set("Federation.RegistryUrl", registry.URL)
url, err = GetNSIssuerURL("/test-prefix")
assert.Equal(t, nil, err)
assert.Equal(t, "test-path/api/v2.0/registry/metadata/test-prefix/.well-known/issuer.jwks", url)

assert.Equal(t, "https://registry.com:8446/api/v2.0/registry/metadata/test-prefix/.well-known/issuer.jwks", url)
}

func TestNamespaceKeysCacheEviction(t *testing.T) {
Expand Down
17 changes: 12 additions & 5 deletions director/redirect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,12 @@ import (
"github.com/lestrrat-go/jwx/v2/jwa"
"github.com/lestrrat-go/jwx/v2/jwk"
"github.com/lestrrat-go/jwx/v2/jwt"
"github.com/pelicanplatform/pelican/config"
"github.com/pelicanplatform/pelican/test_utils"
"github.com/spf13/viper"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/pelicanplatform/pelican/config"
"github.com/pelicanplatform/pelican/test_utils"
)

type MockCache struct {
Expand Down Expand Up @@ -65,7 +66,13 @@ func TestDirectorRegistration(t *testing.T) {

viper.Reset()

viper.Set("Federation.RegistryUrl", "https://get-your-tokens.org")
// Anything that needs to create/verify a token by getting the namespace's pubkey/issuer URL needs to
// do a lookup against a registry. Create a dummy registry that can return the correct value
registry := registryMockup(t, "/foo/bar")
defer registry.Close()
viper.Set("Federation.RegistryUrl", registry.URL)

// viper.Set("Federation.RegistryUrl", "https://get-your-tokens.org")

setupContext := func() (*gin.Context, *gin.Engine, *httptest.ResponseRecorder) {
// Setup httptest recorder and context for the the unit test
Expand Down Expand Up @@ -137,8 +144,8 @@ func TestDirectorRegistration(t *testing.T) {
setupMockCache := func(t *testing.T, publicKey jwk.Key) MockCache {
return MockCache{
GetFn: func(key string, keyset *jwk.Set) (jwk.Set, error) {
if key != "https://get-your-tokens.org/api/v2.0/registry/metadata/foo/bar/.well-known/issuer.jwks" {
t.Errorf("expecting: https://get-your-tokens.org/api/v2.0/registry/metadata/foo/bar/.well-known/issuer.jwks, got %q", key)
if key != "https://registry.com:8446/api/v2.0/registry/metadata/foo/bar/.well-known/issuer.jwks" {
t.Errorf("expecting: https://registry.com:8446/api/v2.0/registry/metadata/foo/bar/.well-known/issuer.jwks, got %q", key)
}
return *keyset, nil
},
Expand Down
4 changes: 2 additions & 2 deletions registry/client_commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -232,9 +232,9 @@ func NamespaceDelete(endpoint string, prefix string) error {
// TODO: We might consider moving widely-useful functions like `GetRegistryIssuerURL`
// to a more generic `pelican/utils` package so that they're easier to find
// and more likely to be used.
issuerURL, err := director.GetRegistryIssuerURL(prefix)
issuerURL, err := director.GetNSIssuerURL(prefix)
if err != nil {
return errors.Wrap(err, "Failed to determine issuer URL for creating deletion token")
return errors.Wrap(err, "Failed to determine prefix's issuer/pubkey URL for creating deletion token")
}

// TODO: Eventually we should think about a naming scheme for
Expand Down
Loading

0 comments on commit ecd6073

Please sign in to comment.