Skip to content

Commit 75b1064

Browse files
Merge pull request #583 from jhiemstrawisc/issue-111
Update ns key discovery to follow openid-style lookups
2 parents 0dfd0ca + 3ae6ae6 commit 75b1064

File tree

5 files changed

+174
-41
lines changed

5 files changed

+174
-41
lines changed

director/origin_api.go

Lines changed: 76 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,13 @@ import (
3434
"github.com/lestrrat-go/jwx/v2/jwa"
3535
"github.com/lestrrat-go/jwx/v2/jwk"
3636
"github.com/lestrrat-go/jwx/v2/jwt"
37+
"github.com/pkg/errors"
38+
log "github.com/sirupsen/logrus"
39+
3740
"github.com/pelicanplatform/pelican/config"
3841
"github.com/pelicanplatform/pelican/param"
3942
"github.com/pelicanplatform/pelican/token_scopes"
4043
"github.com/pelicanplatform/pelican/utils"
41-
"github.com/pkg/errors"
42-
log "github.com/sirupsen/logrus"
4344
)
4445

4546
type (
@@ -126,7 +127,7 @@ func CreateAdvertiseToken(namespace string) (string, error) {
126127
// TODO: Need to come back and carefully consider a few naming practices.
127128
// Here, issuerUrl is actually the registry database url, and not
128129
// the token issuer url for this namespace
129-
issuerUrl, err := GetRegistryIssuerURL(namespace)
130+
issuerUrl, err := GetNSIssuerURL(namespace)
130131
if err != nil {
131132
return "", err
132133
}
@@ -170,7 +171,12 @@ func CreateAdvertiseToken(namespace string) (string, error) {
170171
// see if the entity is authorized to advertise an origin for the
171172
// namespace
172173
func VerifyAdvertiseToken(ctx context.Context, token, namespace string) (bool, error) {
173-
issuerUrl, err := GetRegistryIssuerURL(namespace)
174+
issuerUrl, err := GetNSIssuerURL(namespace)
175+
if err != nil {
176+
return false, err
177+
}
178+
179+
keyLoc, err := GetJWKSURLFromIssuerURL(issuerUrl)
174180
if err != nil {
175181
return false, err
176182
}
@@ -201,7 +207,7 @@ func VerifyAdvertiseToken(ctx context.Context, token, namespace string) (bool, e
201207
if ar == nil {
202208
ar = jwk.NewCache(ctx)
203209
client := &http.Client{Transport: config.GetTransport()}
204-
if err = ar.Register(issuerUrl, jwk.WithMinRefreshInterval(15*time.Minute), jwk.WithHTTPClient(client)); err != nil {
210+
if err = ar.Register(keyLoc, jwk.WithMinRefreshInterval(15*time.Minute), jwk.WithHTTPClient(client)); err != nil {
205211
return false, err
206212
}
207213
namespaceKeysMutex.Lock()
@@ -215,8 +221,8 @@ func VerifyAdvertiseToken(ctx context.Context, token, namespace string) (bool, e
215221
}
216222

217223
}
218-
log.Debugln("Attempting to fetch keys from ", issuerUrl)
219-
keyset, err := ar.Get(ctx, issuerUrl)
224+
log.Debugln("Attempting to fetch keys from ", keyLoc)
225+
keyset, err := ar.Get(ctx, keyLoc)
220226

221227
if err != nil {
222228
return false, err
@@ -323,18 +329,73 @@ func VerifyDirectorTestReportToken(strToken string) (bool, error) {
323329
return false, nil
324330
}
325331

326-
func GetRegistryIssuerURL(prefix string) (string, error) {
327-
namespace_url_string := param.Federation_RegistryUrl.GetString()
328-
if namespace_url_string == "" {
329-
return "", errors.New("Namespace URL is not set")
332+
// For a given prefix, get the prefix's issuer URL, where we consider that the openid endpoint
333+
// we use to look up a key location. Note that this is NOT the same as the issuer key -- to
334+
// find that, follow openid-style discovery using the issuer URL as a base.
335+
func GetNSIssuerURL(prefix string) (string, error) {
336+
if prefix == "" || !strings.HasPrefix(prefix, "/") {
337+
return "", errors.New(fmt.Sprintf("the prefix \"%s\" is invalid", prefix))
338+
}
339+
registryUrlStr := param.Federation_RegistryUrl.GetString()
340+
if registryUrlStr == "" {
341+
return "", errors.New("federation registry URL is not set and was not discovered")
330342
}
331-
namespace_url, err := url.Parse(namespace_url_string)
343+
registryUrl, err := url.Parse(registryUrlStr)
332344
if err != nil {
333345
return "", err
334346
}
335-
namespace_url.Path, err = url.JoinPath(namespace_url.Path, "api", "v1.0", "registry", prefix, ".well-known", "issuer.jwks")
347+
348+
registryUrl.Path, err = url.JoinPath(registryUrl.Path, "api", "v1.0", "registry", prefix)
349+
336350
if err != nil {
337-
return "", err
351+
return "", errors.Wrapf(err, "failed to construct openid-configuration lookup URL for prefix %s", prefix)
352+
}
353+
return registryUrl.String(), nil
354+
}
355+
356+
// Given an issuer url, lookup the JWKS URL from the openid-configuration
357+
// For example, if the issuer URL is https://registry.com:8446/api/v1.0/registry/test-namespace,
358+
// this function will return the key indicated by the openid-configuration JSON hosted at
359+
// https://registry.com:8446/api/v1.0/registry/test-namespace/.well-known/openid-configuration.
360+
func GetJWKSURLFromIssuerURL(issuerUrl string) (string, error) {
361+
// Get/parse the openid-configuration JSON to lookup key location
362+
issOpenIDUrl, err := url.Parse(issuerUrl)
363+
if err != nil {
364+
return "", errors.Wrap(err, "failed to parse issuer URL")
365+
}
366+
issOpenIDUrl.Path, _ = url.JoinPath(issOpenIDUrl.Path, ".well-known", "openid-configuration")
367+
368+
client := &http.Client{Transport: config.GetTransport()}
369+
openIDCfg, err := client.Get(issOpenIDUrl.String())
370+
if err != nil {
371+
return "", errors.Wrapf(err, "failed to lookup openid-configuration for issuer %s", issuerUrl)
372+
}
373+
defer openIDCfg.Body.Close()
374+
375+
// If we hit an old registry, it may not have the openid-configuration. In that case, we fallback to the old
376+
// behavior of looking for the key directly at the issuer URL.
377+
if openIDCfg.StatusCode == http.StatusNotFound {
378+
oldKeyLoc, err := url.JoinPath(issuerUrl, ".well-known", "issuer.jwks")
379+
if err != nil {
380+
return "", errors.Wrapf(err, "failed to construct key lookup URL for issuer %s", issuerUrl)
381+
}
382+
return oldKeyLoc, nil
383+
}
384+
385+
body, err := io.ReadAll(openIDCfg.Body)
386+
if err != nil {
387+
return "", errors.Wrapf(err, "failed to read response body from %s", issuerUrl)
388+
}
389+
390+
var openIDCfgMap map[string]string
391+
err = json.Unmarshal(body, &openIDCfgMap)
392+
if err != nil {
393+
return "", errors.Wrapf(err, "failed to unmarshal openid-configuration for issuer %s", issuerUrl)
394+
}
395+
396+
if keyLoc, ok := openIDCfgMap["jwks_uri"]; ok {
397+
return keyLoc, nil
398+
} else {
399+
return "", errors.New(fmt.Sprintf("no key found in openid-configuration for issuer %s", issuerUrl))
338400
}
339-
return namespace_url.String(), nil
340401
}

director/origin_api_test.go

Lines changed: 48 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/json"
66
"net/http"
77
"net/http/httptest"
8+
"net/url"
89
"path/filepath"
910
"testing"
1011
"time"
@@ -13,14 +14,35 @@ import (
1314
"github.com/lestrrat-go/jwx/v2/jwa"
1415
"github.com/lestrrat-go/jwx/v2/jwk"
1516
"github.com/lestrrat-go/jwx/v2/jwt"
16-
"github.com/pelicanplatform/pelican/config"
17-
"github.com/pelicanplatform/pelican/test_utils"
1817
"github.com/spf13/viper"
1918
"github.com/stretchr/testify/assert"
2019
"github.com/stretchr/testify/require"
2120
"golang.org/x/sync/errgroup"
21+
22+
"github.com/pelicanplatform/pelican/config"
23+
"github.com/pelicanplatform/pelican/test_utils"
2224
)
2325

26+
// For these tests, we only need to lookup key locations. Create a dummy registry that only returns
27+
// the jwks_uri location for the given key. Once a server is instantiated, it will only return
28+
// locations for the provided prefix. To change prefixes, create a new registry mockup.
29+
func registryMockup(t *testing.T, prefix string) *httptest.Server {
30+
registryUrl, _ := url.Parse("https://registry.com:8446")
31+
path, err := url.JoinPath("/api/v1.0/registry", prefix, ".well-known/issuer.jwks")
32+
if err != nil {
33+
t.Fatalf("Failed to parse key path for prefix %s", prefix)
34+
}
35+
registryUrl.Path = path
36+
37+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
38+
jsonResponse := `{"jwks_uri": "` + registryUrl.String() + `"}`
39+
w.Header().Set("Content-Type", "application/json")
40+
w.WriteHeader(http.StatusOK)
41+
_, _ = w.Write([]byte(jsonResponse))
42+
}))
43+
return server
44+
}
45+
2446
func TestVerifyAdvertiseToken(t *testing.T) {
2547
ctx, cancel, egrp := test_utils.TestContext(context.Background(), t)
2648
defer func() { require.NoError(t, egrp.Wait()) }()
@@ -160,11 +182,14 @@ func TestCreateAdvertiseToken(t *testing.T) {
160182
viper.Set("Federation.RegistryUrl", "")
161183
viper.Set("Federation.DirectorURL", "")
162184

163-
// Test without a namsepace set and check to see if it returns the expected error
185+
registry := registryMockup(t, "/test-namespace")
186+
defer registry.Close()
187+
188+
// Test without a registry URL set and check to see if it returns the expected error
164189
tok, err := CreateAdvertiseToken("/test-namespace")
165190
assert.Equal(t, "", tok)
166-
assert.Equal(t, "Namespace URL is not set", err.Error())
167-
viper.Set("Federation.RegistryUrl", "https://get-your-tokens.org")
191+
assert.Equal(t, "federation registry URL is not set and was not discovered", err.Error())
192+
viper.Set("Federation.RegistryUrl", registry.URL)
168193

169194
// Test without a DirectorURL set and check to see if it returns the expected error
170195
tok, err = CreateAdvertiseToken("/test-namespace")
@@ -178,23 +203,28 @@ func TestCreateAdvertiseToken(t *testing.T) {
178203
assert.NotEqual(t, "", tok)
179204
}
180205

181-
func TestGetRegistryIssuerURL(t *testing.T) {
182-
/*
183-
* Runs unit tests on the GetRegistryIssuerURL function
184-
*/
206+
func TestGetNSIssuerURL(t *testing.T) {
185207
viper.Reset()
208+
viper.Set("Federation.RegistryUrl", "https://registry.com:8446")
209+
url, err := GetNSIssuerURL("/test-prefix")
210+
assert.Equal(t, nil, err)
211+
assert.Equal(t, "https://registry.com:8446/api/v1.0/registry/test-prefix", url)
212+
viper.Reset()
213+
}
186214

187-
// No namespace url has been set, so an error is expected
188-
url, err := GetRegistryIssuerURL("")
189-
assert.Equal(t, "", url)
190-
assert.Equal(t, "Namespace URL is not set", err.Error())
191-
192-
// Test to make sure the path is as expected
193-
viper.Set("Federation.RegistryUrl", "test-path")
194-
url, err = GetRegistryIssuerURL("test-prefix")
215+
func TestGetJWKSURLFromIssuerURL(t *testing.T) {
216+
viper.Reset()
217+
registry := registryMockup(t, "/test-prefix")
218+
defer registry.Close()
219+
viper.Set("Federation.RegistryUrl", registry.URL)
220+
expectedIssuerUrl := registry.URL + "/api/v1.0/registry/test-prefix"
221+
url, err := GetNSIssuerURL("/test-prefix")
195222
assert.Equal(t, nil, err)
196-
assert.Equal(t, "test-path/api/v1.0/registry/test-prefix/.well-known/issuer.jwks", url)
223+
assert.Equal(t, expectedIssuerUrl, url)
197224

225+
keyLoc, err := GetJWKSURLFromIssuerURL(url)
226+
assert.Equal(t, nil, err)
227+
assert.Equal(t, "https://registry.com:8446/api/v1.0/registry/test-prefix/.well-known/issuer.jwks", keyLoc)
198228
}
199229

200230
func TestNamespaceKeysCacheEviction(t *testing.T) {

director/redirect_test.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,13 @@ import (
2121
"github.com/lestrrat-go/jwx/v2/jwa"
2222
"github.com/lestrrat-go/jwx/v2/jwk"
2323
"github.com/lestrrat-go/jwx/v2/jwt"
24-
"github.com/pelicanplatform/pelican/config"
25-
"github.com/pelicanplatform/pelican/test_utils"
26-
"github.com/pelicanplatform/pelican/token_scopes"
2724
"github.com/spf13/viper"
2825
"github.com/stretchr/testify/assert"
2926
"github.com/stretchr/testify/require"
27+
28+
"github.com/pelicanplatform/pelican/config"
29+
"github.com/pelicanplatform/pelican/test_utils"
30+
"github.com/pelicanplatform/pelican/token_scopes"
3031
)
3132

3233
type MockCache struct {

registry/client_commands.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -233,9 +233,9 @@ func NamespaceDelete(endpoint string, prefix string) error {
233233
// TODO: We might consider moving widely-useful functions like `GetRegistryIssuerURL`
234234
// to a more generic `pelican/utils` package so that they're easier to find
235235
// and more likely to be used.
236-
issuerURL, err := director.GetRegistryIssuerURL(prefix)
236+
issuerURL, err := director.GetNSIssuerURL(prefix)
237237
if err != nil {
238-
return errors.Wrap(err, "Failed to determine issuer URL for creating deletion token")
238+
return errors.Wrap(err, "Failed to determine prefix's issuer/pubkey URL for creating deletion token")
239239
}
240240

241241
// TODO: Eventually we should think about a naming scheme for

registry/registry.go

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,13 @@ type checkStatusRes struct {
105105
}
106106

107107
// Various auxiliary functions used for client-server security handshakes
108+
type NamespaceConfig struct {
109+
JwksUri string `json:"jwks_uri"`
110+
}
111+
112+
/*
113+
Various auxiliary functions used for client-server security handshakes
114+
*/
108115
type registrationData struct {
109116
ClientNonce string `json:"client_nonce"`
110117
ClientPayload string `json:"client_payload"`
@@ -697,10 +704,44 @@ func wildcardHandler(ctx *gin.Context) {
697704
}
698705
ctx.JSON(http.StatusOK, jwks)
699706
return
700-
}
707+
} else if strings.HasSuffix(path, "/.well-known/openid-configuration") {
708+
// Check that the namespace exists before constructing config JSON
709+
prefix := strings.TrimSuffix(path, "/.well-known/openid-configuration")
710+
exists, err := namespaceExists(prefix)
711+
if err != nil {
712+
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Server encountered an error while checking if the prefix exists"})
713+
log.Errorf("Error while checking for existence of prefix %s: %v", prefix, err)
714+
return
715+
}
716+
if !exists {
717+
ctx.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("The requested prefix %s does not exist in the registry's database", prefix)})
718+
}
719+
// Construct the openid-configuration JSON and return to the requester
720+
// For a given namespace "foo", the jwks should be located at <registry url>/api/v1.0/registry/foo/.well-known/issuer.jwks
721+
configUrl, err := url.Parse(param.Server_ExternalWebUrl.GetString())
722+
if err != nil {
723+
log.Errorf("Failed to parse configured external web URL while constructing namespace jwks location: %v", err)
724+
return
725+
}
701726

702-
// No match found, return 404
703-
ctx.String(http.StatusNotFound, "404 Not Found")
727+
path := strings.TrimSuffix(path, "/openid-configuration")
728+
configUrl.Path, err = url.JoinPath("api", "v1.0", "registry", path, "issuer.jwks")
729+
if err != nil {
730+
log.Errorf("Failed to construct namespace jwks URL: %v", err)
731+
return
732+
}
733+
734+
nsCfg := NamespaceConfig{
735+
JwksUri: configUrl.String(),
736+
}
737+
738+
ctx.JSON(http.StatusOK, nsCfg)
739+
return
740+
} else {
741+
742+
ctx.String(http.StatusNotFound, "404 Page not found")
743+
return
744+
}
704745
}
705746

706747
// Check if a namespace prefix exists and its public key matches the registry record

0 commit comments

Comments
 (0)