From 36461cecd5e710534a3fcb198c8f8411f9f6cbc4 Mon Sep 17 00:00:00 2001 From: Jason Parraga Date: Mon, 1 Jan 2024 20:23:57 -0600 Subject: [PATCH 1/3] Initial implementation --- .github/dependabot.yml | 11 ++ .github/release-drafter.yml | 28 +++++ .github/workflows/ci.yml | 15 +++ .github/workflows/release-drafter.yml | 41 +++++++ .gitignore | 3 + README.md | 9 +- go.mod | 26 +++++ go.sum | 39 +++++++ keyfunc/keyfunc.go | 12 ++ keyfunc/okta/okta.go | 157 +++++++++++++++++++++++++ keyfunc/okta/okta_test.go | 161 ++++++++++++++++++++++++++ keyfunc/okta/oktatest/test_utils.go | 51 ++++++++ metadata/metadata.go | 16 +++ metadata/okta/okta.go | 136 ++++++++++++++++++++++ metadata/okta/okta_test.go | 114 ++++++++++++++++++ metadata/okta/testdata/metadata.json | 141 ++++++++++++++++++++++ verifier.go | 121 +++++++++++++++++++ verifier_test.go | 122 +++++++++++++++++++ 18 files changed, 1202 insertions(+), 1 deletion(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/release-drafter.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release-drafter.yml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 keyfunc/keyfunc.go create mode 100644 keyfunc/okta/okta.go create mode 100644 keyfunc/okta/okta_test.go create mode 100644 keyfunc/okta/oktatest/test_utils.go create mode 100644 metadata/metadata.go create mode 100644 metadata/okta/okta.go create mode 100644 metadata/okta/okta_test.go create mode 100644 metadata/okta/testdata/metadata.json create mode 100644 verifier.go create mode 100644 verifier_test.go diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..b444581 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "gomod" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "daily" diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 0000000..fa653b7 --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1,28 @@ +name-template: v$NEXT_PATCH_VERSION +tag-template: v$NEXT_PATCH_VERSION +template: | + # What's Changed + $CHANGES +categories: + - title: โš ๏ธ Breaking Changes + labels: + - 'breaking change' + - title: ๐Ÿ”’ Security + labels: + - 'security' + - title: ๐Ÿš€ Features + labels: + - 'enhancement' + - 'feature' + - title: ๐Ÿ› Bug Fixes + labels: + - 'bug' + - title: ๐Ÿ“– Documentation + labels: + - 'documentation' + - title: ๐Ÿงน Housekeeping + labels: + - 'chore' + - 'test flakiness' + - title: ๐Ÿ“ฆ Dependency updates + label: 'dependencies' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..137c22d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,15 @@ +on: [push, pull_request] +name: ci +jobs: + test: + strategy: + matrix: + go-version: [1.20.x] + os: [ubuntu-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-go@v4 + with: + go-version: ${{ matrix.go-version }} + - run: go test ./... diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml new file mode 100644 index 0000000..82e66d2 --- /dev/null +++ b/.github/workflows/release-drafter.yml @@ -0,0 +1,41 @@ +name: Release Drafter + +on: + push: + # branches to consider in the event; optional, defaults to all + branches: + - main + # pull_request event is required only for autolabeler + pull_request: + # Only following types are handled by the action, but one can default to all as well + types: [opened, reopened, synchronize] + # pull_request_target event is required for autolabeler to support PRs from forks + # pull_request_target: + # types: [opened, reopened, synchronize] + +permissions: + contents: read + +jobs: + update_release_draft: + permissions: + # write permission is required to create a github release + contents: write + # write permission is required for autolabeler + # otherwise, read permission is required at least + pull-requests: write + runs-on: ubuntu-latest + steps: + # (Optional) GitHub Enterprise requires GHE_HOST variable set + #- name: Set GHE_HOST + # run: | + # echo "GHE_HOST=${GITHUB_SERVER_URL##https:\/\/}" >> $GITHUB_ENV + + # Drafts your next Release notes as Pull Requests are merged into "master" + - uses: release-drafter/release-drafter@v5 + # (Optional) specify config name to use, relative to .github/. Default: release-drafter.yml + # with: + # config-name: my-config.yml + # disable-autolabeler: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 3b735ec..5b0ee2a 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,6 @@ # Go workspace file go.work + +# IntelliJ +.idea/* diff --git a/README.md b/README.md index a434e6e..712a2e9 100644 --- a/README.md +++ b/README.md @@ -1 +1,8 @@ -# okta-jwt-verifier \ No newline at end of file +# okta-jwt-verifier + +[![Test](https://github.com/sovietaced/okta-jwt-verifier/actions/workflows/ci.yml/badge.svg)](https://github.com/sovietaced/okta-jwt-verifier/actions/workflows/ci.yml) +[![GoDoc](https://godoc.org/github.com/sovietaced/okta-jwt-verifier?status.png)](http://godoc.org/github.com/sovietaced/okta-jwt-verifier) +[![Go Report](https://goreportcard.com/badge/github.com/sovietaced/okta-jwt-verifier)](https://goreportcard.com/report/github.com/sovietaced/okta-jwt-verifier) + +Alternative implementation to the official [okta-jwt-verifier](https://github.com/okta/okta-jwt-verifier-golang) that +includes support for telemetry (ie. OpenTelemetry) and minimizing latency. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0085ce5 --- /dev/null +++ b/go.mod @@ -0,0 +1,26 @@ +module github.com/sovietaced/okta-jwt-verifier + +go 1.21.5 + +require ( + github.com/MicahParks/jwkset v0.5.4 + github.com/MicahParks/keyfunc/v3 v3.1.1 + github.com/benbjohnson/clock v1.3.5 + github.com/golang-jwt/jwt/v5 v5.2.0 + github.com/stretchr/testify v1.8.4 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 + go.opentelemetry.io/otel v1.21.0 + go.opentelemetry.io/otel/sdk v1.21.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.3.0 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + go.opentelemetry.io/otel/metric v1.21.0 // indirect + go.opentelemetry.io/otel/trace v1.21.0 // indirect + golang.org/x/sys v0.14.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..87ebed2 --- /dev/null +++ b/go.sum @@ -0,0 +1,39 @@ +github.com/MicahParks/jwkset v0.5.4 h1:59s9OUNIKF3g+IXYm3pa4vPXXEudRNetyy3+H6KpKdw= +github.com/MicahParks/jwkset v0.5.4/go.mod h1:fOx7dCX+XgPDzcRbZzi9DMY3vyebWXmsz7XPqstr3ms= +github.com/MicahParks/keyfunc/v3 v3.1.1 h1:ghC5jcuU4/TTQQ9Ns7TEVuhnscQOH+WL4//Jmsy5/DA= +github.com/MicahParks/keyfunc/v3 v3.1.1/go.mod h1:Qmrhb9tkHX1i/kCiLAPDOCWIEfN9yq7u/tkP16lmLL8= +github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= +github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY= +github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= +github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 h1:aFJWCqJMNjENlcleuuOkGAPH82y0yULBScfXcIEdS24= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1/go.mod h1:sEGXWArGqc3tVa+ekntsN65DmVbVeW+7lTKTjZF3/Fo= +go.opentelemetry.io/otel v1.21.0 h1:hzLeKBZEL7Okw2mGzZ0cc4k/A7Fta0uoPgaJCr8fsFc= +go.opentelemetry.io/otel v1.21.0/go.mod h1:QZzNPQPm1zLX4gZK4cMi+71eaorMSGT3A4znnUvNNEo= +go.opentelemetry.io/otel/metric v1.21.0 h1:tlYWfeo+Bocx5kLEloTjbcDwBuELRrIFxwdQ36PlJu4= +go.opentelemetry.io/otel/metric v1.21.0/go.mod h1:o1p3CA8nNHW8j5yuQLdc1eeqEaPfzug24uvsyIEJRWM= +go.opentelemetry.io/otel/sdk v1.21.0 h1:FTt8qirL1EysG6sTQRZ5TokkU8d0ugCj8htOgThZXQ8= +go.opentelemetry.io/otel/sdk v1.21.0/go.mod h1:Nna6Yv7PWTdgJHVRD9hIYywQBRx7pbox6nwBnZIxl/E= +go.opentelemetry.io/otel/trace v1.21.0 h1:WD9i5gzvoUPuXIXH24ZNBudiarZDKuekPqi/E8fpfLc= +go.opentelemetry.io/otel/trace v1.21.0/go.mod h1:LGbsEB0f9LGjN+OZaQQ26sohbOmiMR+BaslueVtS/qQ= +golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q= +golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/keyfunc/keyfunc.go b/keyfunc/keyfunc.go new file mode 100644 index 0000000..c1b795b --- /dev/null +++ b/keyfunc/keyfunc.go @@ -0,0 +1,12 @@ +package keyfunc + +import ( + "context" + "github.com/golang-jwt/jwt/v5" +) + +// Provider is a pluggable provider of JWT verifying key functions. +type Provider interface { + // GetKeyfunc gets the JWT verifying key function for an issuer. + GetKeyfunc(ctx context.Context) (jwt.Keyfunc, error) +} diff --git a/keyfunc/okta/okta.go b/keyfunc/okta/okta.go new file mode 100644 index 0000000..aff5a91 --- /dev/null +++ b/keyfunc/okta/okta.go @@ -0,0 +1,157 @@ +package okta + +import ( + "context" + "fmt" + "github.com/MicahParks/keyfunc/v3" + "github.com/benbjohnson/clock" + "github.com/golang-jwt/jwt/v5" + "github.com/sovietaced/okta-jwt-verifier/metadata" + "io" + "net/http" + "sync" + "time" +) + +// Options are configurable options for the KeyfuncProvider. +type Options struct { + httpClient *http.Client + clock clock.Clock + cacheTtl time.Duration +} + +// WithHttpClient allows for a configurable http client. +func WithHttpClient(httpClient *http.Client) Option { + return func(mo *Options) { + mo.httpClient = httpClient + } +} + +func withClock(clock clock.Clock) Option { + return func(mo *Options) { + mo.clock = clock + } +} + +// WithCacheTtl specifies the TTL on the Okta JWK set. +func WithCacheTtl(ttl time.Duration) Option { + return func(mo *Options) { + mo.cacheTtl = ttl + } +} + +func defaultOptions() *Options { + opts := &Options{} + WithHttpClient(http.DefaultClient)(opts) + withClock(clock.New())(opts) + WithCacheTtl(5 * time.Minute)(opts) + return opts +} + +// Option for the KeyfuncProvider +type Option func(*Options) + +type cachedKeyfunc struct { + expiration time.Time + keyfunc jwt.Keyfunc +} + +func newCachedKeyfunc(expiration time.Time, keyfunc jwt.Keyfunc) *cachedKeyfunc { + return &cachedKeyfunc{expiration: expiration, keyfunc: keyfunc} +} + +// KeyfuncProvider implements the keyfunc.KeyfuncProvider and generates JWT validating functions for Okta tokens. +type KeyfuncProvider struct { + mp metadata.Provider + httpClient *http.Client + clock clock.Clock + + keyfuncMutex sync.Mutex + cacheTtl time.Duration + cachedKeyfunc *cachedKeyfunc +} + +// NewKeyfuncProvider creates a new KeyfuncProvider. +func NewKeyfuncProvider(mp metadata.Provider, options ...Option) *KeyfuncProvider { + opts := defaultOptions() + for _, option := range options { + option(opts) + } + + return &KeyfuncProvider{mp: mp, httpClient: opts.httpClient, clock: opts.clock, cacheTtl: opts.cacheTtl} +} + +// GetKeyfunc gets a jwt.Keyfunc based on the OIDC metadata. +func (kp *KeyfuncProvider) GetKeyfunc(ctx context.Context) (jwt.Keyfunc, error) { + md, err := kp.mp.GetMetadata(ctx) + if err != nil { + return nil, fmt.Errorf("getting metadata: %w", err) + } + + keyfunc, err := kp.getOrFetchKeyfunc(ctx, md.JwksUri) + if err != nil { + return nil, fmt.Errorf("getting or fetching keyfunc: %w", err) + } + + return keyfunc, nil +} + +func (kp *KeyfuncProvider) getOrFetchKeyfunc(ctx context.Context, jwksUri string) (jwt.Keyfunc, error) { + cachedKeyfuncCopy := kp.cachedKeyfunc + + if cachedKeyfuncCopy != nil && kp.clock.Now().Before(cachedKeyfuncCopy.expiration) { + return cachedKeyfuncCopy.keyfunc, nil + } + + // Acquire a lock + kp.keyfuncMutex.Lock() + defer kp.keyfuncMutex.Unlock() + + // Check again to protect against races + cachedKeyfuncCopy = kp.cachedKeyfunc + + if cachedKeyfuncCopy != nil && kp.clock.Now().Before(cachedKeyfuncCopy.expiration) { + return cachedKeyfuncCopy.keyfunc, nil + } + + keyfunc, err := kp.fetchKeyfunc(ctx, jwksUri) + if err != nil { + return nil, fmt.Errorf("fetching keyfunc: %w", err) + } + + expiration := kp.clock.Now().Add(kp.cacheTtl) + kp.cachedKeyfunc = newCachedKeyfunc(expiration, keyfunc) + + return keyfunc, nil +} + +func (kp *KeyfuncProvider) fetchKeyfunc(ctx context.Context, jwksUri string) (jwt.Keyfunc, error) { + + httpRequest, err := http.NewRequestWithContext(ctx, http.MethodGet, jwksUri, nil) + if err != nil { + return nil, fmt.Errorf("creating new http request: %w", err) + } + resp, err := kp.httpClient.Do(httpRequest) + if err != nil { + return nil, fmt.Errorf("making http request for jwks: %w", err) + } + defer resp.Body.Close() + + ok := resp.StatusCode >= 200 && resp.StatusCode < 300 + if !ok { + return nil, fmt.Errorf("request for jwks %q was not HTTP 2xx OK, it was: %d", jwksUri, resp.StatusCode) + } + + jwkJson, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read jwks response body: %w", err) + } + + kf, err := keyfunc.NewJWKSetJSON(jwkJson) + if err != nil { + return nil, fmt.Errorf("failed to create keyfunc from jwk json: %w", err) + } + + return kf.Keyfunc, nil + +} diff --git a/keyfunc/okta/okta_test.go b/keyfunc/okta/okta_test.go new file mode 100644 index 0000000..5c5a789 --- /dev/null +++ b/keyfunc/okta/okta_test.go @@ -0,0 +1,161 @@ +package okta + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "fmt" + clock2 "github.com/benbjohnson/clock" + "github.com/golang-jwt/jwt/v5" + "github.com/sovietaced/okta-jwt-verifier/keyfunc/okta/oktatest" + "github.com/sovietaced/okta-jwt-verifier/metadata" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + "go.opentelemetry.io/otel/propagation" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" + "net/http" + "testing" + "time" +) + +func TestKeyfuncProvider(t *testing.T) { + + // Generate RSA key. + pk, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + ctx := context.Background() + + t.Run("get keyfunc", func(t *testing.T) { + uri, _ := oktatest.ServeJwks(t, ctx, pk) + + mp := &oktatest.StaticMetadataProvider{ + Md: metadata.Metadata{ + JwksUri: uri, + }, + } + + kp := NewKeyfuncProvider(mp) + + keyfunc, err := kp.GetKeyfunc(ctx) + require.NoError(t, err) + validateKeyfunc(t, keyfunc, pk) + }) + + t.Run("get keyfunc and verify cached", func(t *testing.T) { + uri, countFun := oktatest.ServeJwks(t, ctx, pk) + + mp := &oktatest.StaticMetadataProvider{ + Md: metadata.Metadata{ + JwksUri: uri, + }, + } + + clock := clock2.NewMock() + kp := NewKeyfuncProvider(mp, withClock(clock)) + + keyfunc, err := kp.GetKeyfunc(ctx) + require.NoError(t, err) + validateKeyfunc(t, keyfunc, pk) + require.Equal(t, 1, countFun()) + + // Get again and verify that it was cached + keyfunc, err = kp.GetKeyfunc(ctx) + require.NoError(t, err) + validateKeyfunc(t, keyfunc, pk) + require.Equal(t, 1, countFun()) + + // Fast forward time and invalidate cache + clock.Add(10 * time.Minute) + keyfunc, err = kp.GetKeyfunc(ctx) + require.NoError(t, err) + validateKeyfunc(t, keyfunc, pk) + require.Equal(t, 2, countFun()) + }) + + t.Run("get keyfunc and validate tracing", func(t *testing.T) { + prop := propagation.TraceContext{} + spanRecorder := tracetest.NewSpanRecorder() + provider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(spanRecorder)) + + tr := otelhttp.NewTransport( + http.DefaultTransport, + otelhttp.WithTracerProvider(provider), + otelhttp.WithPropagators(prop), + ) + + httpClient := http.Client{Transport: tr} + + uri, _ := oktatest.ServeJwks(t, ctx, pk) + + mp := &oktatest.StaticMetadataProvider{ + Md: metadata.Metadata{ + JwksUri: uri, + }, + } + + kp := NewKeyfuncProvider(mp, WithHttpClient(&httpClient)) + + tracer := provider.Tracer("test") + spanCtx, span := tracer.Start(ctx, "test") + + keyfunc, err := kp.GetKeyfunc(spanCtx) + require.NoError(t, err) + span.End() + validateKeyfunc(t, keyfunc, pk) + + spans := spanRecorder.Ended() + require.Len(t, spans, 2) + httpSpan := spans[0] + require.Equal(t, "HTTP GET", httpSpan.Name()) + + testSpan := spans[1] + require.Equal(t, "test", testSpan.Name()) + + // Verify trace propagation through context + require.Equal(t, testSpan.SpanContext().SpanID(), httpSpan.Parent().SpanID()) + }) + + t.Run("get keyfunc and metadata provider returns error", func(t *testing.T) { + mp := errorMetadataProvider{err: fmt.Errorf("synthetic error")} + + kp := NewKeyfuncProvider(&mp) + + _, err := kp.GetKeyfunc(ctx) + require.ErrorContains(t, err, "getting metadata: synthetic error") + }) + + t.Run("get keyfunc and jwks uri is invalid", func(t *testing.T) { + mp := &oktatest.StaticMetadataProvider{ + Md: metadata.Metadata{ + JwksUri: "bad", + }, + } + + kp := NewKeyfuncProvider(mp) + + _, err := kp.GetKeyfunc(ctx) + require.Error(t, err) + require.ErrorContains(t, err, "getting or fetching keyfunc: fetching keyfunc: making http request for jwks") + }) +} + +func validateKeyfunc(t *testing.T, keyfunc jwt.Keyfunc, pk *rsa.PrivateKey) { + token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{}) + token.Header["kid"] = oktatest.KID + tokenString, err := token.SignedString(pk) + require.NoError(t, err) + + _, err = jwt.Parse(tokenString, keyfunc) + require.NoError(t, err) +} + +type errorMetadataProvider struct { + err error +} + +func (emp *errorMetadataProvider) GetMetadata(ctx context.Context) (metadata.Metadata, error) { + return metadata.Metadata{}, emp.err + +} diff --git a/keyfunc/okta/oktatest/test_utils.go b/keyfunc/okta/oktatest/test_utils.go new file mode 100644 index 0000000..90781f1 --- /dev/null +++ b/keyfunc/okta/oktatest/test_utils.go @@ -0,0 +1,51 @@ +package oktatest + +import ( + "context" + "crypto/rsa" + "github.com/MicahParks/jwkset" + "github.com/sovietaced/okta-jwt-verifier/metadata" + "github.com/stretchr/testify/require" + "net/http" + "net/http/httptest" + "testing" +) + +const ( + KID = "test" +) + +type StaticMetadataProvider struct { + Md metadata.Metadata +} + +func (smp *StaticMetadataProvider) GetMetadata(ctx context.Context) (metadata.Metadata, error) { + return smp.Md, nil +} + +func ServeJwks(t *testing.T, ctx context.Context, priv *rsa.PrivateKey) (string, func() int) { + serverStore := jwkset.NewMemoryStorage() + md := jwkset.JWKMetadataOptions{ + KID: KID, + } + jwkOptions := jwkset.JWKOptions{ + Metadata: md, + } + jwk, err := jwkset.NewJWKFromKey(priv, jwkOptions) + require.NoError(t, err) + + err = serverStore.KeyWrite(ctx, jwk) + require.NoError(t, err) + + rawJWKS, err := serverStore.JSONPrivate(ctx) + require.NoError(t, err) + + count := 0 + svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + count++ + w.Write(rawJWKS) + })) + t.Cleanup(svr.Close) + + return svr.URL, func() int { return count } +} diff --git a/metadata/metadata.go b/metadata/metadata.go new file mode 100644 index 0000000..8f25afe --- /dev/null +++ b/metadata/metadata.go @@ -0,0 +1,16 @@ +package metadata + +import ( + "context" +) + +// Provider is a pluggable provider of OIDC metadata. +type Provider interface { + GetMetadata(ctx context.Context) (Metadata, error) +} + +// Metadata represents the OIDC metadata response from Okta. We only care about the JWKS URI. +// See: https://developer.okta.com/docs/reference/api/oidc/#well-known-openid-configuration +type Metadata struct { + JwksUri string `json:"jwks_uri"` +} diff --git a/metadata/okta/okta.go b/metadata/okta/okta.go new file mode 100644 index 0000000..28d2a67 --- /dev/null +++ b/metadata/okta/okta.go @@ -0,0 +1,136 @@ +package okta + +import ( + "context" + "encoding/json" + "fmt" + "github.com/benbjohnson/clock" + "github.com/sovietaced/okta-jwt-verifier/metadata" + "net/http" + "sync" + "time" +) + +// Options are configurable options for the MetadataProvider. +type Options struct { + httpClient *http.Client + cacheTtl time.Duration + clock clock.Clock +} + +// WithHttpClient allows for a configurable http client. +func WithHttpClient(httpClient *http.Client) Option { + return func(mo *Options) { + mo.httpClient = httpClient + } +} + +// WithCacheTtl specifies the TTL on the Okta JWK set. +func WithCacheTtl(ttl time.Duration) Option { + return func(mo *Options) { + mo.cacheTtl = ttl + } +} + +func withClock(clock clock.Clock) Option { + return func(mo *Options) { + mo.clock = clock + } +} + +func defaultOptions() *Options { + opts := &Options{} + WithHttpClient(http.DefaultClient)(opts) + withClock(clock.New())(opts) + WithCacheTtl(5 * time.Minute)(opts) + return opts +} + +// Option for the MetadataProvider. +type Option func(*Options) + +type cachedMetadata struct { + expiration time.Time + m metadata.Metadata +} + +func newCachedMetadata(expiration time.Time, m metadata.Metadata) *cachedMetadata { + return &cachedMetadata{expiration: expiration, m: m} +} + +// MetadataProvider is an implementation of metadata.Provider that retrieves metadata from Okta's well known openid +// configuration +type MetadataProvider struct { + metadataUrl string // The URL to use to retrieve metadata + httpClient *http.Client // the HTTP client to use to retrieve metadata + clock clock.Clock + + metadataMutex sync.Mutex + cacheTtl time.Duration + cachedMetadata *cachedMetadata +} + +// NewMetadataProvider creates a new MetadataProvider for the specified Okta issuer. +func NewMetadataProvider(issuer string, options ...Option) *MetadataProvider { + opts := defaultOptions() + for _, option := range options { + option(opts) + } + + metadataUrl := fmt.Sprintf("%s%s", issuer, "/.well-known/openid-configuration") + return &MetadataProvider{metadataUrl: metadataUrl, httpClient: opts.httpClient, clock: opts.clock, cacheTtl: opts.cacheTtl} +} + +// GetMetadata gets metadata for the specified Okta issuer. +func (mp *MetadataProvider) GetMetadata(ctx context.Context) (metadata.Metadata, error) { + + cachedMetadataCopy := mp.cachedMetadata + + if cachedMetadataCopy != nil && mp.clock.Now().Before(cachedMetadataCopy.expiration) { + return cachedMetadataCopy.m, nil + } + + // Acquire a lock + mp.metadataMutex.Lock() + defer mp.metadataMutex.Unlock() + + // Check for a race before continuing + cachedMetadataCopy = mp.cachedMetadata + if cachedMetadataCopy != nil && mp.clock.Now().Before(cachedMetadataCopy.expiration) { + return cachedMetadataCopy.m, nil + } + + expiration := mp.clock.Now().Add(mp.cacheTtl) + + newMetadata, err := mp.fetchMetadata(ctx) + if err != nil { + return metadata.Metadata{}, fmt.Errorf("failed to fetch new fresh metadata: %w", err) + } + + mp.cachedMetadata = newCachedMetadata(expiration, newMetadata) + return mp.cachedMetadata.m, nil +} + +func (mp *MetadataProvider) fetchMetadata(ctx context.Context) (metadata.Metadata, error) { + httpRequest, err := http.NewRequestWithContext(ctx, http.MethodGet, mp.metadataUrl, nil) + if err != nil { + return metadata.Metadata{}, fmt.Errorf("creating new http request: %w", err) + } + resp, err := mp.httpClient.Do(httpRequest) + if err != nil { + return metadata.Metadata{}, fmt.Errorf("making http request for metadata: %w", err) + } + defer resp.Body.Close() + + ok := resp.StatusCode >= 200 && resp.StatusCode < 300 + if !ok { + return metadata.Metadata{}, fmt.Errorf("request for metadata %q was not HTTP 2xx OK, it was: %d", mp.metadataUrl, resp.StatusCode) + } + + m := metadata.Metadata{} + if err := json.NewDecoder(resp.Body).Decode(&m); err != nil { + return m, fmt.Errorf("decoding metadata: %w", err) + } + + return m, nil +} diff --git a/metadata/okta/okta_test.go b/metadata/okta/okta_test.go new file mode 100644 index 0000000..89a1bba --- /dev/null +++ b/metadata/okta/okta_test.go @@ -0,0 +1,114 @@ +package okta + +import ( + "context" + "fmt" + "github.com/benbjohnson/clock" + "github.com/sovietaced/okta-jwt-verifier/metadata" + "github.com/stretchr/testify/require" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "go.opentelemetry.io/otel/propagation" + + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" +) + +func TestMetadataProvider(t *testing.T) { + + ctx := context.Background() + + t.Run("get metadata success", func(t *testing.T) { + svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(fixture(t, "metadata.json")) + })) + defer svr.Close() + + mp := NewMetadataProvider(svr.URL) + + m, err := mp.GetMetadata(ctx) + require.NoError(t, err) + + expectedMetadata := metadata.Metadata{JwksUri: "https://test.okta.com/oauth2/v1/keys"} + require.Equal(t, expectedMetadata, m) + }) + + t.Run("get metadata and verify cached", func(t *testing.T) { + serverCount := 0 + svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + serverCount++ + w.Write(fixture(t, "metadata.json")) + })) + defer svr.Close() + + fakeClock := clock.NewMock() + mp := NewMetadataProvider(svr.URL, withClock(fakeClock)) + + _, err := mp.GetMetadata(ctx) + require.NoError(t, err) + require.Equal(t, 1, serverCount) + + // Get metadata again and ensure it is cached + _, err = mp.GetMetadata(ctx) + require.NoError(t, err) + require.Equal(t, 1, serverCount) + + // Fast forward time and invalidate the cache + fakeClock.Add(10 * time.Minute) + + _, err = mp.GetMetadata(ctx) + require.NoError(t, err) + require.Equal(t, 2, serverCount) + }) + + t.Run("get metadata and verify tracing", func(t *testing.T) { + svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(fixture(t, "metadata.json")) + })) + defer svr.Close() + + prop := propagation.TraceContext{} + spanRecorder := tracetest.NewSpanRecorder() + provider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(spanRecorder)) + + tr := otelhttp.NewTransport( + http.DefaultTransport, + otelhttp.WithTracerProvider(provider), + otelhttp.WithPropagators(prop), + ) + + httpClient := http.Client{Transport: tr} + mp := NewMetadataProvider(svr.URL, WithHttpClient(&httpClient)) + + tracer := provider.Tracer("test") + spanCtx, span := tracer.Start(ctx, "test") + _, err := mp.GetMetadata(spanCtx) + require.NoError(t, err) + span.End() + + spans := spanRecorder.Ended() + require.Len(t, spans, 2) + httpSpan := spans[0] + require.Equal(t, "HTTP GET", httpSpan.Name()) + + testSpan := spans[1] + require.Equal(t, "test", testSpan.Name()) + + // Verify trace propagation through context + require.Equal(t, testSpan.SpanContext().SpanID(), httpSpan.Parent().SpanID()) + }) +} + +func fixture(t *testing.T, filename string) []byte { + b, err := os.ReadFile(fmt.Sprintf("testdata/%s", filename)) + if err != nil { + t.Fatal(err) + } + + return b +} diff --git a/metadata/okta/testdata/metadata.json b/metadata/okta/testdata/metadata.json new file mode 100644 index 0000000..d277e52 --- /dev/null +++ b/metadata/okta/testdata/metadata.json @@ -0,0 +1,141 @@ +{ + "issuer": "https://test.okta.com", + "authorization_endpoint": "https://test.okta.com/oauth2/v1/authorize", + "token_endpoint": "https://test.okta.com/oauth2/v1/token", + "userinfo_endpoint": "https://test.okta.com/oauth2/v1/userinfo", + "registration_endpoint": "https://test.okta.com/oauth2/v1/clients", + "jwks_uri": "https://test.okta.com/oauth2/v1/keys", + "response_types_supported": [ + "code", + "id_token", + "code id_token", + "code token", + "id_token token", + "code id_token token" + ], + "response_modes_supported": [ + "query", + "fragment", + "form_post", + "okta_post_message" + ], + "grant_types_supported": [ + "authorization_code", + "implicit", + "refresh_token", + "password", + "urn:ietf:params:oauth:grant-type:device_code", + "urn:openid:params:grant-type:ciba" + ], + "subject_types_supported": [ + "public" + ], + "id_token_signing_alg_values_supported": [ + "RS256" + ], + "scopes_supported": [ + "openid", + "email", + "profile", + "address", + "phone", + "offline_access", + "groups" + ], + "token_endpoint_auth_methods_supported": [ + "client_secret_basic", + "client_secret_post", + "client_secret_jwt", + "private_key_jwt", + "none" + ], + "claims_supported": [ + "iss", + "ver", + "sub", + "aud", + "iat", + "exp", + "jti", + "auth_time", + "amr", + "idp", + "nonce", + "name", + "nickname", + "preferred_username", + "given_name", + "middle_name", + "family_name", + "email", + "email_verified", + "profile", + "zoneinfo", + "locale", + "address", + "phone_number", + "picture", + "website", + "gender", + "birthdate", + "updated_at", + "at_hash", + "c_hash" + ], + "code_challenge_methods_supported": [ + "S256" + ], + "introspection_endpoint": "https://test.okta.com/oauth2/v1/introspect", + "introspection_endpoint_auth_methods_supported": [ + "client_secret_basic", + "client_secret_post", + "client_secret_jwt", + "private_key_jwt", + "none" + ], + "revocation_endpoint": "https://test.okta.com/oauth2/v1/revoke", + "revocation_endpoint_auth_methods_supported": [ + "client_secret_basic", + "client_secret_post", + "client_secret_jwt", + "private_key_jwt", + "none" + ], + "end_session_endpoint": "https://test.okta.com/oauth2/v1/logout", + "request_parameter_supported": true, + "request_object_signing_alg_values_supported": [ + "HS256", + "HS384", + "HS512", + "RS256", + "RS384", + "RS512", + "ES256", + "ES384", + "ES512" + ], + "device_authorization_endpoint": "https://test.okta.com/oauth2/v1/device/authorize", + "pushed_authorization_request_endpoint": "https://test.okta.com/oauth2/v1/par", + "backchannel_token_delivery_modes_supported": [ + "poll" + ], + "backchannel_authentication_request_signing_alg_values_supported": [ + "HS256", + "HS384", + "HS512", + "RS256", + "RS384", + "RS512", + "ES256", + "ES384", + "ES512" + ], + "dpop_signing_alg_values_supported": [ + "RS256", + "RS384", + "RS512", + "ES256", + "ES384", + "ES512" + ] +} diff --git a/verifier.go b/verifier.go new file mode 100644 index 0000000..1ba6447 --- /dev/null +++ b/verifier.go @@ -0,0 +1,121 @@ +package verifier + +import ( + "context" + "fmt" + "github.com/golang-jwt/jwt/v5" + "github.com/sovietaced/okta-jwt-verifier/keyfunc" + "github.com/sovietaced/okta-jwt-verifier/keyfunc/okta" + oktametadata "github.com/sovietaced/okta-jwt-verifier/metadata/okta" +) + +// Options are configurable options for the Verifier. +type Options struct { + keyfuncProvider keyfunc.Provider +} + +// WithKeyfuncProvider allows for a configurable keyfunc.Provider, which may be useful if you want to customize +// the behavior of how metadata or JWK sets are fetched. +func WithKeyfuncProvider(keyfuncProvider keyfunc.Provider) Option { + return func(mo *Options) { + mo.keyfuncProvider = keyfuncProvider + } +} + +func defaultOptions(issuer string) *Options { + opts := &Options{} + WithKeyfuncProvider(okta.NewKeyfuncProvider(oktametadata.NewMetadataProvider(issuer)))(opts) + return opts +} + +// Option for the OktaMetadataProvider +type Option func(*Options) + +type Verifier struct { + keyfuncProvider keyfunc.Provider + issuer string + clientId string +} + +// NewVerifier creates a new Verifier for the specified issuer or client ID. +func NewVerifier(issuer string, clientId string, options ...Option) *Verifier { + opts := defaultOptions(issuer) + for _, option := range options { + option(opts) + } + + return &Verifier{issuer: issuer, clientId: clientId, keyfuncProvider: opts.keyfuncProvider} +} + +// VerifyIdToken verifies an Okta ID token. +func (v *Verifier) VerifyIdToken(ctx context.Context, idToken string) (*jwt.Token, error) { + jwt, err := v.parseToken(ctx, idToken) + if err != nil { + return nil, fmt.Errorf("verifying id token: %w", err) + } + + claims := jwt.Claims + + jwtIssuer, err := claims.GetIssuer() + if err != nil { + return nil, fmt.Errorf("verifying id token issuer: %w", err) + } + + if jwtIssuer != v.issuer { + return nil, fmt.Errorf("verifying id token issuer: issuer '%s' in token does not match '%s'", jwtIssuer, v.issuer) + } + + jwtAuds, err := claims.GetAudience() + if err != nil { + return nil, fmt.Errorf("veriying id token audience: %w", err) + } + + matchFound := false + for _, jwtAud := range jwtAuds { + if jwtAud == v.clientId { + matchFound = true + break + } + } + + if !matchFound { + return nil, fmt.Errorf("verifying id token audience: audience '%s' in token does not match '%s'", jwtAuds, v.clientId) + } + + jwtIat, err := claims.GetIssuedAt() + if err != nil { + return nil, fmt.Errorf("verifying id token issued time: %w", err) + } + + if jwtIat == nil { + return nil, fmt.Errorf("verifying id token issued time: no issued time found") + } + + jwtExp, err := claims.GetExpirationTime() + if err != nil { + return nil, fmt.Errorf("verifying id token expriation time: %w", err) + } + + if jwtExp == nil { + return nil, fmt.Errorf("verifying id token expiration time: no expiration time found") + } + + // FIXME: add support for nonce + + return jwt, nil +} + +func (v *Verifier) parseToken(ctx context.Context, tokenString string) (*jwt.Token, error) { + + keyfunc, err := v.keyfuncProvider.GetKeyfunc(ctx) + if err != nil { + return nil, fmt.Errorf("getting key function: %w", err) + } + + token, err := jwt.Parse(tokenString, keyfunc) + if err != nil { + return nil, fmt.Errorf("parsing token: %w", err) + } + + return token, err +} diff --git a/verifier_test.go b/verifier_test.go new file mode 100644 index 0000000..318bf64 --- /dev/null +++ b/verifier_test.go @@ -0,0 +1,122 @@ +package verifier + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "github.com/golang-jwt/jwt/v5" + "github.com/sovietaced/okta-jwt-verifier/keyfunc/okta" + "github.com/sovietaced/okta-jwt-verifier/keyfunc/okta/oktatest" + "github.com/sovietaced/okta-jwt-verifier/metadata" + "github.com/stretchr/testify/require" + "testing" + "time" +) + +func TestVerifier(t *testing.T) { + issuer := "https://test.okta.com" + clientId := "test" + + // Generate RSA key. + pk, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + ctx := context.Background() + + uri, _ := oktatest.ServeJwks(t, ctx, pk) + + mp := &oktatest.StaticMetadataProvider{ + Md: metadata.Metadata{ + JwksUri: uri, + }, + } + + kp := okta.NewKeyfuncProvider(mp) + v := NewVerifier(issuer, clientId, WithKeyfuncProvider(kp)) + + t.Run("verify valid id token", func(t *testing.T) { + token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{ + "iss": issuer, + "aud": clientId, + "iat": time.Now().Unix(), + "exp": time.Now().Add(24 * time.Hour).Unix(), + }) + token.Header["kid"] = oktatest.KID + idToken, err := token.SignedString(pk) + require.NoError(t, err) + + _, err = v.VerifyIdToken(ctx, idToken) + require.NoError(t, err) + }) + + t.Run("verify id token missing issuer", func(t *testing.T) { + token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{ + "aud": clientId, + "iat": time.Now().Unix(), + "exp": time.Now().Add(24 * time.Hour).Unix(), + }) + token.Header["kid"] = oktatest.KID + idToken, err := token.SignedString(pk) + require.NoError(t, err) + + _, err = v.VerifyIdToken(ctx, idToken) + require.ErrorContains(t, err, "verifying id token issuer: issuer '' in token does not match 'https://test.okta.com'") + }) + + t.Run("verify id token missing audience", func(t *testing.T) { + token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{ + "iss": issuer, + "iat": time.Now().Unix(), + "exp": time.Now().Add(24 * time.Hour).Unix(), + }) + token.Header["kid"] = oktatest.KID + idToken, err := token.SignedString(pk) + require.NoError(t, err) + + _, err = v.VerifyIdToken(ctx, idToken) + require.ErrorContains(t, err, "verifying id token audience: audience '[]' in token does not match 'test'") + }) + + t.Run("verify id token missing issued time", func(t *testing.T) { + token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{ + "iss": issuer, + "aud": clientId, + "exp": time.Now().Add(24 * time.Hour).Unix(), + }) + token.Header["kid"] = oktatest.KID + idToken, err := token.SignedString(pk) + require.NoError(t, err) + + _, err = v.VerifyIdToken(ctx, idToken) + require.ErrorContains(t, err, "verifying id token issued time: no issued time found") + }) + + t.Run("verify id token missing expiration", func(t *testing.T) { + token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{ + "iss": issuer, + "aud": clientId, + "iat": time.Now().Unix(), + }) + token.Header["kid"] = oktatest.KID + idToken, err := token.SignedString(pk) + require.NoError(t, err) + + _, err = v.VerifyIdToken(ctx, idToken) + require.ErrorContains(t, err, "verifying id token expiration time: no expiration time found") + }) + + t.Run("verify id token expired", func(t *testing.T) { + token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{ + "iss": issuer, + "aud": clientId, + "iat": time.Now().Unix(), + "exp": time.Now().Unix(), + }) + token.Header["kid"] = oktatest.KID + idToken, err := token.SignedString(pk) + require.NoError(t, err) + + _, err = v.VerifyIdToken(ctx, idToken) + require.ErrorContains(t, err, "verifying id token: parsing token: token has invalid claims: token is expired") + }) +} From 62e9b4817e3aea2fc15854416cce0b1b1c47091e Mon Sep 17 00:00:00 2001 From: Jason Parraga Date: Mon, 1 Jan 2024 20:25:05 -0600 Subject: [PATCH 2/3] Update README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 712a2e9..2ce4fb1 100644 --- a/README.md +++ b/README.md @@ -5,4 +5,4 @@ [![Go Report](https://goreportcard.com/badge/github.com/sovietaced/okta-jwt-verifier)](https://goreportcard.com/report/github.com/sovietaced/okta-jwt-verifier) Alternative implementation to the official [okta-jwt-verifier](https://github.com/okta/okta-jwt-verifier-golang) that -includes support for telemetry (ie. OpenTelemetry) and minimizing latency. +includes support for telemetry (ie. OpenTelemetry), minimizing operational latency, and testability. From b1fb6aba0f9498c2bec278d6faa00cb6afe5cf51 Mon Sep 17 00:00:00 2001 From: Jason Parraga Date: Mon, 1 Jan 2024 20:33:43 -0600 Subject: [PATCH 3/3] Update CI to run against 1.21 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 137c22d..e06cc05 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,7 @@ jobs: test: strategy: matrix: - go-version: [1.20.x] + go-version: [1.21.x] os: [ubuntu-latest] runs-on: ${{ matrix.os }} steps: