Skip to content

Commit d67f072

Browse files
authored
Merge pull request #8 from Sovietaced/background-metadata
Add support for fetching metadata in the background
2 parents 6c38c68 + 53b0723 commit d67f072

File tree

5 files changed

+185
-28
lines changed

5 files changed

+185
-28
lines changed

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
[![Go Report](https://goreportcard.com/badge/github.com/sovietaced/okta-jwt-verifier)](https://goreportcard.com/report/github.com/sovietaced/okta-jwt-verifier)
66

77
Alternative implementation to the official [okta-jwt-verifier](https://github.com/okta/okta-jwt-verifier-golang) that
8-
includes support for telemetry (ie. OpenTelemetry), minimizing operational latency, and testability.
8+
includes support for telemetry (ie. OpenTelemetry), minimizing verification latency, and testability.
99

1010
## Examples
1111

@@ -21,7 +21,8 @@ func main() {
2121
ctx := context.Background()
2222
issuer := "https://test.okta.com"
2323
clientId := "test"
24-
v := verifier.NewVerifier(issuer, clientId)
24+
v, err := verifier.NewVerifier(issuer, clientId)
25+
2526

2627
idToken := "..."
2728
token, err := v.VerifyAccessToken(ctx, idToken)

metadata/okta/okta.go

Lines changed: 96 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,25 @@ import (
1111
"time"
1212
)
1313

14+
type FetchStrategy int64
15+
1416
const (
17+
Lazy FetchStrategy = iota // Fetch new metadata inline with requests (when not cached)
18+
// Background Fetch new metadata in the background regardless of requests being made. This option was designed
19+
// for eliminating in-line metadata calls and minimizing latency in production use. Warning: this option will
20+
// attempt to seed metadata on initialization and block.
21+
Background
22+
1523
DefaultCacheTtl = 5 * time.Minute
1624
)
1725

1826
// Options are configurable options for the MetadataProvider.
1927
type Options struct {
20-
httpClient *http.Client
21-
cacheTtl time.Duration
22-
clock clock.Clock
28+
httpClient *http.Client
29+
cacheTtl time.Duration
30+
clock clock.Clock
31+
fetchStrategy FetchStrategy
32+
backgroundCtx context.Context
2333
}
2434

2535
// WithHttpClient allows for a configurable http client.
@@ -42,11 +52,28 @@ func withClock(clock clock.Clock) Option {
4252
}
4353
}
4454

55+
// WithFetchStrategy specifies a strategy for fetching new metadata.
56+
func WithFetchStrategy(fetchStrategy FetchStrategy) Option {
57+
return func(mo *Options) {
58+
mo.fetchStrategy = fetchStrategy
59+
}
60+
}
61+
62+
// WithBackgroundCtx specified the context to use in order to control the lifecycle of the background fetching
63+
// goroutine.
64+
func WithBackgroundCtx(ctx context.Context) Option {
65+
return func(mo *Options) {
66+
mo.backgroundCtx = ctx
67+
}
68+
}
69+
4570
func defaultOptions() *Options {
4671
opts := &Options{}
4772
WithHttpClient(http.DefaultClient)(opts)
4873
withClock(clock.New())(opts)
4974
WithCacheTtl(DefaultCacheTtl)(opts)
75+
WithFetchStrategy(Lazy)(opts)
76+
WithBackgroundCtx(context.Background())(opts)
5077
return opts
5178
}
5279

@@ -72,22 +99,34 @@ type MetadataProvider struct {
7299
metadataMutex sync.Mutex
73100
cacheTtl time.Duration
74101
cachedMetadata *cachedMetadata
102+
fetchStrategy FetchStrategy
75103
}
76104

77105
// NewMetadataProvider creates a new MetadataProvider for the specified Okta issuer.
78-
func NewMetadataProvider(issuer string, options ...Option) *MetadataProvider {
106+
func NewMetadataProvider(issuer string, options ...Option) (*MetadataProvider, error) {
79107
opts := defaultOptions()
80108
for _, option := range options {
81109
option(opts)
82110
}
83111

84112
metadataUrl := fmt.Sprintf("%s%s", issuer, "/.well-known/openid-configuration")
85-
return &MetadataProvider{
86-
metadataUrl: metadataUrl,
87-
httpClient: opts.httpClient,
88-
clock: opts.clock,
89-
cacheTtl: opts.cacheTtl,
113+
mp := &MetadataProvider{
114+
metadataUrl: metadataUrl,
115+
httpClient: opts.httpClient,
116+
clock: opts.clock,
117+
cacheTtl: opts.cacheTtl,
118+
fetchStrategy: opts.fetchStrategy,
90119
}
120+
121+
if opts.fetchStrategy == Background {
122+
_, err := mp.backgroundFetchAndCache(opts.backgroundCtx)
123+
if err != nil {
124+
return nil, fmt.Errorf("failed to seed metadata: %w", err)
125+
}
126+
go mp.backgroundFetchLoop(opts.backgroundCtx)
127+
}
128+
129+
return mp, nil
91130
}
92131

93132
// GetMetadata gets metadata for the specified Okta issuer.
@@ -99,12 +138,21 @@ func (mp *MetadataProvider) GetMetadata(ctx context.Context) (metadata.Metadata,
99138
return cachedMetadataCopy.m, nil
100139
}
101140

141+
if mp.fetchStrategy == Lazy {
142+
return mp.lazyFetchAndCache(ctx)
143+
}
144+
145+
return metadata.Metadata{}, fmt.Errorf("no metadata available")
146+
147+
}
148+
149+
func (mp *MetadataProvider) lazyFetchAndCache(ctx context.Context) (metadata.Metadata, error) {
102150
// Acquire a lock
103151
mp.metadataMutex.Lock()
104152
defer mp.metadataMutex.Unlock()
105153

106154
// Check for a race before continuing
107-
cachedMetadataCopy = mp.cachedMetadata
155+
cachedMetadataCopy := mp.cachedMetadata
108156
if cachedMetadataCopy != nil && mp.clock.Now().Before(cachedMetadataCopy.expiration) {
109157
return cachedMetadataCopy.m, nil
110158
}
@@ -120,6 +168,44 @@ func (mp *MetadataProvider) GetMetadata(ctx context.Context) (metadata.Metadata,
120168
return mp.cachedMetadata.m, nil
121169
}
122170

171+
func (mp *MetadataProvider) backgroundFetchAndCache(ctx context.Context) (metadata.Metadata, error) {
172+
// Acquire a lock
173+
mp.metadataMutex.Lock()
174+
defer mp.metadataMutex.Unlock()
175+
176+
expiration := mp.clock.Now().Add(mp.cacheTtl)
177+
178+
newMetadata, err := mp.fetchMetadata(ctx)
179+
if err != nil {
180+
return metadata.Metadata{}, fmt.Errorf("failed to fetch new fresh metadata: %w", err)
181+
}
182+
183+
mp.cachedMetadata = newCachedMetadata(expiration, newMetadata)
184+
return mp.cachedMetadata.m, nil
185+
}
186+
187+
func (mp *MetadataProvider) backgroundFetchLoop(ctx context.Context) {
188+
// Seed cache initially
189+
_, err := mp.backgroundFetchAndCache(ctx)
190+
if err != nil {
191+
// FIXME: log this
192+
}
193+
194+
ticker := time.NewTicker(mp.cacheTtl / 2)
195+
defer ticker.Stop()
196+
for {
197+
select {
198+
case <-ctx.Done():
199+
return
200+
case <-ticker.C:
201+
_, err := mp.backgroundFetchAndCache(ctx)
202+
if err != nil {
203+
// FIXME: log this
204+
}
205+
}
206+
}
207+
}
208+
123209
func (mp *MetadataProvider) fetchMetadata(ctx context.Context) (metadata.Metadata, error) {
124210
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodGet, mp.metadataUrl, nil)
125211
if err != nil {

metadata/okta/okta_test.go

Lines changed: 65 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import (
1919
"go.opentelemetry.io/otel/sdk/trace/tracetest"
2020
)
2121

22-
func TestMetadataProvider(t *testing.T) {
22+
func TestLazyMetadataProvider(t *testing.T) {
2323

2424
ctx := context.Background()
2525

@@ -29,7 +29,8 @@ func TestMetadataProvider(t *testing.T) {
2929
}))
3030
defer svr.Close()
3131

32-
mp := NewMetadataProvider(svr.URL)
32+
mp, err := NewMetadataProvider(svr.URL)
33+
require.NoError(t, err)
3334

3435
m, err := mp.GetMetadata(ctx)
3536
require.NoError(t, err)
@@ -47,9 +48,10 @@ func TestMetadataProvider(t *testing.T) {
4748
defer svr.Close()
4849

4950
fakeClock := clock.NewMock()
50-
mp := NewMetadataProvider(svr.URL, withClock(fakeClock))
51+
mp, err := NewMetadataProvider(svr.URL, withClock(fakeClock))
52+
require.NoError(t, err)
5153

52-
_, err := mp.GetMetadata(ctx)
54+
_, err = mp.GetMetadata(ctx)
5355
require.NoError(t, err)
5456
require.Equal(t, 1, serverCount)
5557

@@ -83,11 +85,12 @@ func TestMetadataProvider(t *testing.T) {
8385
)
8486

8587
httpClient := http.Client{Transport: tr}
86-
mp := NewMetadataProvider(svr.URL, WithHttpClient(&httpClient))
88+
mp, err := NewMetadataProvider(svr.URL, WithHttpClient(&httpClient))
89+
require.NoError(t, err)
8790

8891
tracer := provider.Tracer("test")
8992
spanCtx, span := tracer.Start(ctx, "test")
90-
_, err := mp.GetMetadata(spanCtx)
93+
_, err = mp.GetMetadata(spanCtx)
9194
require.NoError(t, err)
9295
span.End()
9396

@@ -104,6 +107,62 @@ func TestMetadataProvider(t *testing.T) {
104107
})
105108
}
106109

110+
func TestBackgroundMetadataProvider(t *testing.T) {
111+
112+
ctx := context.Background()
113+
114+
t.Run("get metadata success", func(t *testing.T) {
115+
svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
116+
w.Write(fixture(t, "metadata.json"))
117+
}))
118+
defer svr.Close()
119+
120+
backgroundCtx, cancelFunc := context.WithCancel(ctx)
121+
defer cancelFunc()
122+
123+
mp, err := NewMetadataProvider(svr.URL, WithFetchStrategy(Background), WithBackgroundCtx(backgroundCtx))
124+
require.NoError(t, err)
125+
126+
m, err := mp.GetMetadata(ctx)
127+
require.NoError(t, err)
128+
129+
expectedMetadata := metadata.Metadata{JwksUri: "https://test.okta.com/oauth2/v1/keys"}
130+
require.Equal(t, expectedMetadata, m)
131+
})
132+
133+
t.Run("get metadata and verify cached", func(t *testing.T) {
134+
serverCount := 0
135+
svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
136+
serverCount++
137+
w.Write(fixture(t, "metadata.json"))
138+
}))
139+
defer svr.Close()
140+
141+
backgroundCtx, cancelFunc := context.WithCancel(ctx)
142+
defer cancelFunc()
143+
144+
fakeClock := clock.NewMock()
145+
mp, err := NewMetadataProvider(svr.URL, withClock(fakeClock), WithFetchStrategy(Background), WithBackgroundCtx(backgroundCtx))
146+
require.NoError(t, err)
147+
148+
_, err = mp.GetMetadata(ctx)
149+
require.NoError(t, err)
150+
require.Equal(t, 1, serverCount)
151+
152+
// Get metadata again and ensure it is cached
153+
_, err = mp.GetMetadata(ctx)
154+
require.NoError(t, err)
155+
require.Equal(t, 1, serverCount)
156+
157+
// Fast forward time and invalidate the cache
158+
fakeClock.Add(10 * time.Minute)
159+
160+
_, err = mp.GetMetadata(ctx)
161+
require.NoError(t, err)
162+
require.Equal(t, 2, serverCount)
163+
})
164+
}
165+
107166
func fixture(t *testing.T, filename string) []byte {
108167
b, err := os.ReadFile(fmt.Sprintf("testdata/%s", filename))
109168
if err != nil {

verifier.go

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,15 @@ func WithLeeway(leeway time.Duration) Option {
3535
}
3636
}
3737

38-
func defaultOptions(issuer string) *Options {
38+
func defaultOptions(issuer string) (*Options, error) {
3939
opts := &Options{}
40-
WithKeyfuncProvider(okta.NewKeyfuncProvider(oktametadata.NewMetadataProvider(issuer)))(opts)
40+
mp, err := oktametadata.NewMetadataProvider(issuer)
41+
if err != nil {
42+
return nil, fmt.Errorf("creating default metadata provider: %w", err)
43+
}
44+
WithKeyfuncProvider(okta.NewKeyfuncProvider(mp))(opts)
4145
WithLeeway(DefaultLeeway)(opts)
42-
return opts
46+
return opts, nil
4347
}
4448

4549
// Option for the Verifier
@@ -65,8 +69,11 @@ type Verifier struct {
6569
}
6670

6771
// NewVerifier creates a new Verifier for the specified issuer or client ID.
68-
func NewVerifier(issuer string, clientId string, options ...Option) *Verifier {
69-
opts := defaultOptions(issuer)
72+
func NewVerifier(issuer string, clientId string, options ...Option) (*Verifier, error) {
73+
opts, err := defaultOptions(issuer)
74+
if err != nil {
75+
return nil, fmt.Errorf("creating default options: %w", err)
76+
}
7077
for _, option := range options {
7178
option(opts)
7279
}
@@ -79,7 +86,7 @@ func NewVerifier(issuer string, clientId string, options ...Option) *Verifier {
7986
jwt.WithExpirationRequired(),
8087
)
8188

82-
return &Verifier{issuer: issuer, clientId: clientId, keyfuncProvider: opts.keyfuncProvider, parser: parser}
89+
return &Verifier{issuer: issuer, clientId: clientId, keyfuncProvider: opts.keyfuncProvider, parser: parser}, nil
8390
}
8491

8592
// VerifyIdToken verifies an Okta ID token.

verifier_test.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ func TestVerifierVerifyIdToken(t *testing.T) {
3232
}
3333

3434
kp := okta.NewKeyfuncProvider(mp)
35-
v := NewVerifier(issuer, clientId, WithKeyfuncProvider(kp))
35+
v, err := NewVerifier(issuer, clientId, WithKeyfuncProvider(kp))
36+
require.NoError(t, err)
3637

3738
t.Run("verify valid id token", func(t *testing.T) {
3839
token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
@@ -160,7 +161,8 @@ func TestVerifierVerifyIdToken(t *testing.T) {
160161

161162
t.Run("verify id token expiration with leeway", func(t *testing.T) {
162163

163-
lv := NewVerifier(issuer, clientId, WithKeyfuncProvider(kp), WithLeeway(time.Minute))
164+
lv, err := NewVerifier(issuer, clientId, WithKeyfuncProvider(kp), WithLeeway(time.Minute))
165+
require.NoError(t, err)
164166

165167
token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
166168
"iss": issuer,
@@ -211,7 +213,8 @@ func TestVerifierVerifyAccessToken(t *testing.T) {
211213
}
212214

213215
kp := okta.NewKeyfuncProvider(mp)
214-
v := NewVerifier(issuer, clientId, WithKeyfuncProvider(kp))
216+
v, err := NewVerifier(issuer, clientId, WithKeyfuncProvider(kp))
217+
require.NoError(t, err)
215218

216219
t.Run("verify valid access token", func(t *testing.T) {
217220
token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
@@ -360,7 +363,8 @@ func TestVerifierVerifyAccessToken(t *testing.T) {
360363

361364
t.Run("verify access token expiration with leeway", func(t *testing.T) {
362365

363-
lv := NewVerifier(issuer, clientId, WithKeyfuncProvider(kp), WithLeeway(time.Minute))
366+
lv, err := NewVerifier(issuer, clientId, WithKeyfuncProvider(kp), WithLeeway(time.Minute))
367+
require.NoError(t, err)
364368

365369
token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
366370
"iss": issuer,

0 commit comments

Comments
 (0)