From 5cc3a32e27d49eabc0a85adbd5c8426086c33cd2 Mon Sep 17 00:00:00 2001 From: schmidtw Date: Sat, 23 Sep 2023 11:59:49 -0700 Subject: [PATCH] Add a working txt record fetcher that produces the instructions needed. --- .gitignore | 2 + go.mod | 11 +- go.sum | 35 ++- internal/jwtxt/cmd/example/main.go | 62 +++++ internal/jwtxt/event/events.go | 53 ++++ internal/jwtxt/keypairs_for_test.go | 77 ++++++ internal/jwtxt/options.go | 196 +++++++++++++++ internal/jwtxt/token.go | 275 +++++++++++++++++++++ internal/jwtxt/token_test.go | 370 ++++++++++++++++++++++++++++ 9 files changed, 1078 insertions(+), 3 deletions(-) create mode 100644 internal/jwtxt/cmd/example/main.go create mode 100644 internal/jwtxt/event/events.go create mode 100644 internal/jwtxt/keypairs_for_test.go create mode 100644 internal/jwtxt/options.go create mode 100644 internal/jwtxt/token.go create mode 100644 internal/jwtxt/token_test.go diff --git a/.gitignore b/.gitignore index 040b3cb..058cad6 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,5 @@ go.work xmidt-agent + +internal/jwtxt/cmd/example/* diff --git a/go.mod b/go.mod index 69bd034..be3a495 100644 --- a/go.mod +++ b/go.mod @@ -4,22 +4,31 @@ go 1.21.0 require ( github.com/alecthomas/kong v0.8.0 + github.com/foxcpp/go-mockdns v1.0.0 + github.com/golang-jwt/jwt/v5 v5.0.1-0.20230913133926-0cb4fa15e31b github.com/goschtalt/goschtalt v0.22.1 github.com/goschtalt/yaml-decoder v0.0.1 github.com/goschtalt/yaml-encoder v0.0.3 github.com/stretchr/testify v1.8.4 + github.com/xmidt-org/eventor v0.0.0-20230910205925-8ff168bd12ed github.com/xmidt-org/sallust v0.2.2 + github.com/xmidt-org/wrp-go/v3 v3.2.0 go.uber.org/fx v1.20.0 go.uber.org/zap v1.25.0 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/google/uuid v1.3.1 // indirect github.com/goschtalt/approx v1.0.0 // indirect + github.com/miekg/dns v1.1.25 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/ugorji/go/codec v1.2.11 // indirect go.uber.org/dig v1.17.0 // indirect - go.uber.org/multierr v1.10.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392 // indirect + golang.org/x/net v0.0.0-20190923162816-aa69164e4478 // indirect golang.org/x/sys v0.4.0 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 46b5a0d..3dc3d7c 100644 --- a/go.sum +++ b/go.sum @@ -8,8 +8,14 @@ github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT github.com/benbjohnson/clock v1.3.0/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/foxcpp/go-mockdns v1.0.0 h1:7jBqxd3WDWwi/6WhDvacvH1XsN3rOLXyHM1uhvIx6FI= +github.com/foxcpp/go-mockdns v1.0.0/go.mod h1:lgRN6+KxQBawyIghpnl5CezHFGS9VLzvtVlwxvzXTQ4= +github.com/golang-jwt/jwt/v5 v5.0.1-0.20230913133926-0cb4fa15e31b h1:rCJAwcE4lbPSvM8odqRshGj2NF3LDnJ8cv/duU1DeHo= +github.com/golang-jwt/jwt/v5 v5.0.1-0.20230913133926-0cb4fa15e31b/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4= +github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/goschtalt/approx v1.0.0 h1:q8DMVEOSgwjFUYsupwhLApMWhfbaxRfWeSKT2uTU214= github.com/goschtalt/approx v1.0.0/go.mod h1:Mh0VbpeEgO2Qo2PKGrSuz241D/nj9q7OPegJNWzrbIU= github.com/goschtalt/goschtalt v0.22.1 h1:IcfNMSQMouZUsZnlzQlvGeVaDPJX1oB+hPPXXonpRq8= @@ -24,6 +30,8 @@ github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/miekg/dns v1.1.25 h1:dFwPR6SfLtrSwgDcIq2bcU/gVutB4sNApq2HBdqcakg= +github.com/miekg/dns v1.1.25/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -34,8 +42,14 @@ github.com/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XF github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= +github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/xmidt-org/eventor v0.0.0-20230910205925-8ff168bd12ed h1:KpcgFuumKrt/824H3gtmNI/IvgjsBo6rnlSnwXlFu60= +github.com/xmidt-org/eventor v0.0.0-20230910205925-8ff168bd12ed/go.mod h1:X9Og+8y1Llz7N8F20UmjZUNgrxHubMVfBcroJ5SPtIY= github.com/xmidt-org/sallust v0.2.2 h1:MrINLEr7cMj6ENx/O76fvpfd5LNGYnk7OipZAGXPYA0= github.com/xmidt-org/sallust v0.2.2/go.mod h1:ytBoypcPw10OmjM6b92Jx3eoqWX4J5zVXOQozGwz4qs= +github.com/xmidt-org/wrp-go/v3 v3.2.0 h1:XX5c0ZJYaTEvlHFk0lzxadoOMbxg5YtUkPWNXHoxTDE= +github.com/xmidt-org/wrp-go/v3 v3.2.0/go.mod h1:46ily/xzmRUhs8gSbTKNeOA6ztwcHauZFnfr4hRpoHA= go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ= go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/dig v1.17.0 h1:5Chju+tUvcC+N7N6EV08BJz41UZuO3BmHcN4A287ZLI= @@ -44,12 +58,29 @@ go.uber.org/fx v1.20.0 h1:ZMC/pnRvhsthOZh9MZjMq5U8Or3mA9zBSPaLnzs3ihQ= go.uber.org/fx v1.20.0/go.mod h1:qCUj0btiR3/JnanEr1TYEePfSw6o/4qYJscgvzQ5Ub0= go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= -go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= -go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.25.0 h1:4Hvk6GtkucQ790dqmj7l1eEnRdKm3k3ZUrUMS2d5+5c= go.uber.org/zap v1.25.0/go.mod h1:JIAUzQIH94IC4fOJQm7gMmBJP5k7wQfdcnYdPoEXJYk= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392 h1:ACG4HJsFiNMf47Y4PeRoebLNy/2lXT9EtprMuTFWt1M= +golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190923162816-aa69164e4478 h1:l5EDrHhldLYb3ZRHDUhXF7Om7MvYXnkV9/iQNo1lX6g= +golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18= golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.13.0 h1:a0T3bh+7fhRyqeNbiC3qVHYmkiQgit3wnNan/2c0HMM= gonum.org/v1/gonum v0.13.0/go.mod h1:/WPYRckkfWrhWefxyYTfrTtQR0KH4iyHNuzxqXAKyAU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/jwtxt/cmd/example/main.go b/internal/jwtxt/cmd/example/main.go new file mode 100644 index 0000000..2dadaf8 --- /dev/null +++ b/internal/jwtxt/cmd/example/main.go @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: 2023 Comcast Cable Communications Management, LLC +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/xmidt-org/xmidt-agent/internal/jwtxt" + "github.com/xmidt-org/xmidt-agent/internal/jwtxt/event" +) + +func main() { + url := os.Args[1] + id := os.Args[2] + pemFile := os.Args[3] + + pem, err := os.ReadFile(pemFile) + if err != nil { + panic(err) + } + + instructions, err := jwtxt.New( + jwtxt.BaseURL(url), + jwtxt.DeviceID(id), + jwtxt.Algorithms("EdDSA", + "ES256", "ES384", "ES512", + "PS256", "PS384", "PS512", + "RS256", "RS384", "RS512"), + jwtxt.WithPEMs(pem), + jwtxt.Timeout(5*time.Second), + jwtxt.WithFetchListener(event.FetchListenerFunc(func(fe event.Fetch) { + fmt.Printf(" FQDN: %s\n", fe.FQDN) + fmt.Printf(" Server: %s\n", fe.Server) + fmt.Printf(" Found: %t\n", fe.Found) + fmt.Printf(" Timeout: %t\n", fe.Timeout) + fmt.Printf("PriorExpiration: %s\n", fe.PriorExpiration) + fmt.Printf(" Expiration: %s\n", fe.Expiration) + fmt.Printf(" TemporaryErr: %t\n", fe.TemporaryErr) + fmt.Printf(" Endpoint: %s\n", fe.Endpoint) + fmt.Printf(" Payload: %s\n", fe.Payload) + if fe.Err != nil { + fmt.Printf(" Err: %s\n", fe.Err) + } else { + fmt.Printf(" Err: nil\n") + } + })), + ) + if err != nil { + panic(err) + } + + endpoint, err := instructions.Endpoint(context.Background()) + if err != nil { + panic(err) + } + + fmt.Printf("\n\nEndpoint: '%s'\n", endpoint) +} diff --git a/internal/jwtxt/event/events.go b/internal/jwtxt/event/events.go new file mode 100644 index 0000000..c7cc50d --- /dev/null +++ b/internal/jwtxt/event/events.go @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: 2023 Comcast Cable Communications Management, LLC +// SPDX-License-Identifier: Apache-2.0 + +package event + +import ( + "time" +) + +// Fetch is an event that is emitted when a TXT record fetch is attempted. +type Fetch struct { + // FQDN is the fully qualified domain name of the TXT record. + FQDN string + + // Server is the DNS server that was queried. + Server string + + // Found indicates whether the TXT record was found. + Found bool + + // Timeout indicates whether the query timed out. + Timeout bool + + // PriorExpiration is the expiration time of the previous TXT record. + PriorExpiration time.Time + + // Expiration is the expiration time of the TXT record. + Expiration time.Time + + // TemporaryErr indicates whether a temporary error occurred during the query. + TemporaryErr bool + + // The endpoint that was found in the TXT record. + Endpoint string + + // Payload is the payload of the TXT record. + Payload []byte + + // Err indicates whether an error occurred during the query. + Err error +} + +// FetchListener is a sink for registration events. +type FetchListener interface { + OnFetchEvent(Fetch) +} + +// FetchListenerFunc is a function type that implements FetchListener. +type FetchListenerFunc func(Fetch) + +func (f FetchListenerFunc) OnFetchEvent(fe Fetch) { + f(fe) +} diff --git a/internal/jwtxt/keypairs_for_test.go b/internal/jwtxt/keypairs_for_test.go new file mode 100644 index 0000000..7764d10 --- /dev/null +++ b/internal/jwtxt/keypairs_for_test.go @@ -0,0 +1,77 @@ +// SPDX-FileCopyrightText: 2023 Comcast Cable Communications Management, LLC +// SPDX-License-Identifier: Apache-2.0 + +package jwtxt + +const ( + pemECPublic = "" + //nolint:golint,unused + "-----BEGIN PUBLIC KEY-----\n" + + "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE2JebmtU5WHi5yHBHmzhyiEGbg6OL\n" + + "r463xYdqs/Nzlh2OkaIikanpi7opOuD6wiqFVd9xaMjA54L5vjb5oLcLuA==\n" + + "-----END PUBLIC KEY-----" + + pemECPrivate = "" + //nolint:golint,unused + "-----BEGIN EC PRIVATE KEY-----\n" + + "MHcCAQEEIHJCsQFvPLEV45BXU3DLWEVUPiKSYte8knw7ZtrIj6YxoAoGCCqGSM49\n" + + "AwEHoUQDQgAE2JebmtU5WHi5yHBHmzhyiEGbg6OLr463xYdqs/Nzlh2OkaIikanp\n" + + "i7opOuD6wiqFVd9xaMjA54L5vjb5oLcLuA==\n" + + "-----END EC PRIVATE KEY-----" + + pemEdPublic = "" + //nolint:golint,unused + "-----BEGIN PUBLIC KEY-----\n" + + "MCowBQYDK2VwAyEA0WQIwE/DiCikp79XIkJ0H1vDiERaOieGL/1N8B+k7s8=\n" + + "-----END PUBLIC KEY-----\n" + + pemEdPrivate = "" + //nolint:golint,unused + "-----BEGIN PRIVATE KEY-----\n" + + "MC4CAQAwBQYDK2VwBCIEIHdPSdNde11yNaBYj+q/4044LbOo2lVAb73u7aL13UcH\n" + + "-----END PRIVATE KEY-----" + + pemRSAPublic = "" + //nolint:golint,unused + "-----BEGIN PUBLIC KEY-----\n" + + "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx3HGBMr6UCtsABqMkG9s\n" + + "w0DLRuRZK9M4b535T4vC3i37+3YCLHB9wvOhEOo6b7h6lJehX9Px7pL3ppWu+tr9\n" + + "LuCxW+Nz46gpgKAXvVbbuc7VU2O0XUBuus0WsOgUQUzqHN6ZNpA/eY3mMndEKR79\n" + + "DWdJMSNylBPGvS54WEtgIE8hDor/pPx/cTleXGXq3DasfqnoOlD/ALKL0eqkzbnX\n" + + "GGUzN2K79RCw7mm/CQeS5a7mLgRypT83fR3Kg1SgsyXCUjTNPupQVgWggxfWbRIj\n" + + "T5Q1LkBRl3SDKM6OaPb3xh5NncuQktbjSFO5NLlGdL6Ylzfm0OlK3nBvrpfmac46\n" + + "7QIDAQAB\n" + + "-----END PUBLIC KEY-----\n" + + pemRSAPrivate = "" + //nolint:golint,unused + "-----BEGIN RSA PRIVATE KEY-----\n" + + "MIIEowIBAAKCAQEAx3HGBMr6UCtsABqMkG9sw0DLRuRZK9M4b535T4vC3i37+3YC\n" + + "LHB9wvOhEOo6b7h6lJehX9Px7pL3ppWu+tr9LuCxW+Nz46gpgKAXvVbbuc7VU2O0\n" + + "XUBuus0WsOgUQUzqHN6ZNpA/eY3mMndEKR79DWdJMSNylBPGvS54WEtgIE8hDor/\n" + + "pPx/cTleXGXq3DasfqnoOlD/ALKL0eqkzbnXGGUzN2K79RCw7mm/CQeS5a7mLgRy\n" + + "pT83fR3Kg1SgsyXCUjTNPupQVgWggxfWbRIjT5Q1LkBRl3SDKM6OaPb3xh5NncuQ\n" + + "ktbjSFO5NLlGdL6Ylzfm0OlK3nBvrpfmac467QIDAQABAoIBADvDuaFdC6YzZNEh\n" + + "I4byhMZ7p45ORfROfoZf8cHm8RVv9SbUpXEYom7lX5n4fltVDhJx348eLUye6KwY\n" + + "BY+xSJYgCbWt0l/hV9Jt5r87hGtI8f7jjTw2XxgF9es8GDm7KRpOj93cWtD7dwQf\n" + + "XiLuYMj/7txVMXPy+yZcgv5+U8dKN18EUbUWxxH+JvS/BHA2klhVMY/S6wneiGli\n" + + "i5ZWFag/NAWWFH5rY0pYZIJ059xzzaDQGXihuAq6MhhoRhvwh50HxUKGnDHNGJ7Q\n" + + "MFs7Mbr16gYDgOGlHT2HGDsdvKp9X3KVyQmUNtCNX2B9CRX1b1d3ofey9VD/tMAT\n" + + "07GE/pcCgYEA7ILWxRFR1nTHc+nNdi39nwULkwsO1Qwc3XKnXPqxl/F2M4HZFGHR\n" + + "rcaWBZ/sTqj8P52AgJq9QNoOZ9dKCpCVfPawHYo6zyPb0XF9Od6mT3KAm8AeDLya\n" + + "0yrh0XCnOhzS09dTueNXbUYIDlHFkK8WXF+J0Gwh0oxEtSnZUf2P+F8CgYEA1+EF\n" + + "CKAiqfd+vKyku6FjoE89O1dc4CuJEMXGhgZ88rn4fec3Eqms6155+4DMwwyo0hvF\n" + + "nyoBeb/5/WJJm2EKnbSjSJ9uxFSeguIeIC2SiZCnQrEwUPMzcG9UjOr9UB67cim7\n" + + "N9d+kcV4DFe2knBqv7Iuvk0YLF6X7XBAXzT4QDMCgYEA43iTh8Y4p8J5coqUCe4B\n" + + "2EfJ8grYoR+dQ39aaJrU5AZgYPmqB2htem1dLNu7M4xjz+t0BDzPeOhAoq71j2Ov\n" + + "4xiAGmkwVrluWeqFPnteCVtfRm1oeWeMoTzFI+Ltc371Zrna1RZKp9aLOPp8wcMk\n" + + "BoP80HCvtwkhq/wsACeXqJECgYAdSJ7gLqjFGZeNjHXEJf5XrqgFtrIYjo9HQSzO\n" + + "3W5xlpyIp6am13FndCdj4HLmOn9kEPRbxNzyYQJORtjpRN6lye0kWswxwbDG3Flt\n" + + "0ADCvGaT+2kscfEWXWPAwdee2KxgrhyBVLAMohbIxdU0RB+W5VrF4btXuXUudj2l\n" + + "LJBIVQKBgCHdEUCWOacO5+B/yZijAbRTnKHH4Ht3B3bYJy6M5qIysSVzG4/Pls90\n" + + "bAQvlAyBG5I+yY1zBfeziI6Uopg/XCrxNNv+8sxiPXt1D8QkCPN08wKulj7DnCuf\n" + + "re3wR8zUZNUhRARSWJa68r7sDJVxxDxZjeh3OywmEBPYpQTBM5sa\n" + + "-----END RSA PRIVATE KEY-----\n" +) + +func publicECOption() Option { + return WithPEMs([]byte(pemECPublic)) +} + +func anyPublicOption() Option { + return WithPEMs([]byte(pemECPublic), []byte(pemEdPublic), []byte(pemRSAPublic)) +} diff --git a/internal/jwtxt/options.go b/internal/jwtxt/options.go new file mode 100644 index 0000000..adf4bbe --- /dev/null +++ b/internal/jwtxt/options.go @@ -0,0 +1,196 @@ +// SPDX-FileCopyrightText: 2023 Comcast Cable Communications Management, LLC +// SPDX-License-Identifier: Apache-2.0 + +package jwtxt + +import ( + "fmt" + "net/url" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/xmidt-org/wrp-go/v3" + "github.com/xmidt-org/xmidt-agent/internal/jwtxt/event" +) + +// Option is a functional option for the Instructions constructor. +type Option interface { + apply(*Instructions) error +} + +// WithFetchListener adds a listener for fetch events. +func WithFetchListener(listener event.FetchListener) Option { + return &fetchListener{ + listener: listener, + } +} + +type fetchListener struct { + listener event.FetchListener +} + +func (f fetchListener) apply(ins *Instructions) error { + ins.fetchListeners.Add(f.listener) + return nil +} + +// UseResolver sets the resolver to use for DNS queries. +func UseResolver(resolver Resolver) Option { + return &useResolver{ + resolver: resolver, + } +} + +type useResolver struct { + resolver Resolver +} + +func (u useResolver) apply(ins *Instructions) error { + ins.resolver = u.resolver + return nil +} + +// UseNowFunc sets the function to use for getting the current time. +func UseNowFunc(nowFunc func() time.Time) Option { + return &useNowFunc{ + now: nowFunc, + } +} + +type useNowFunc struct { + now func() time.Time +} + +func (u useNowFunc) apply(ins *Instructions) error { + ins.now = u.now + return nil +} + +// Algorithms sets the algorithms to use for verification. Valid algorithms +// are "EdDSA", "ES256", "ES384", "ES512", "PS256", "PS384", "PS512", +// "RS256", "RS384", and "RS512". +func Algorithms(algs ...string) Option { + return &algorithms{ + algs: algs, + } +} + +type algorithms struct { + algs []string +} + +func (a algorithms) apply(ins *Instructions) error { + for _, alg := range a.algs { + switch alg { + case "EdDSA", + "ES256", "ES384", "ES512", + "PS256", "PS384", "PS512", + "RS256", "RS384", "RS512": + default: + return fmt.Errorf("%w '%s'", ErrUnspportedAlg, alg) + } + ins.jwtOptions = append(ins.jwtOptions, jwt.WithValidMethods([]string{alg})) + ins.algorithms = append(ins.algorithms, alg) + } + return nil +} + +// Timeout sets the timeout for DNS queries. 0 means use the default timeout. +// A negative timeout is invalid. +func Timeout(timeout time.Duration) Option { + return &timeoutOption{ + timeout: timeout, + } +} + +type timeoutOption struct { + timeout time.Duration +} + +func (t timeoutOption) apply(ins *Instructions) error { + if t.timeout < 0 { + return fmt.Errorf("%w: timeout is invalid %s", ErrInvalidInput, t.timeout) + } + if t.timeout == 0 { + t.timeout = DefaultTimeout + } + ins.timeout = t.timeout + return nil +} + +// WithPEMs adds PEM-encoded keys to the list of keys to use for verification. +func WithPEMs(pems ...[]byte) Option { + return &pemOption{ + pems: pems, + } +} + +type pemOption struct { + pems [][]byte +} + +func (p pemOption) apply(ins *Instructions) error { + for _, pem := range p.pems { + var key jwt.VerificationKey + var err error + + key, err = jwt.ParseECPublicKeyFromPEM(pem) + + if err != nil { + key, err = jwt.ParseRSAPublicKeyFromPEM(pem) + } + + if err != nil { + key, err = jwt.ParseEdPublicKeyFromPEM(pem) + } + + if err != nil { + return fmt.Errorf("%w: invalid pem", ErrInvalidInput) + } + + ins.publicKeys.Keys = append(ins.publicKeys.Keys, key) + } + + return nil +} + +// BaseURL sets the base URL to use for the endpoint. +func BaseURL(url string) Option { + return &baseURL{ + url: url, + } +} + +type baseURL struct { + url string +} + +func (b baseURL) apply(ins *Instructions) error { + u, err := url.ParseRequestURI(b.url) + if err != nil { + return fmt.Errorf("%w: invalid url %s", ErrInvalidInput, b.url) + } + + ins.baseURL = u.Hostname() + return nil +} + +// DeviceID sets the ID to use for the endpoint. +func DeviceID(id string) Option { + return &idOption{ + id: id, + } +} + +type idOption struct { + id string +} + +func (i idOption) apply(ins *Instructions) error { + id, err := wrp.ParseDeviceID(i.id) + if err != nil { + return fmt.Errorf("%w: invalid id %s", ErrInvalidInput, i.id) + } + ins.id = id.ID() + return nil +} diff --git a/internal/jwtxt/token.go b/internal/jwtxt/token.go new file mode 100644 index 0000000..80532ef --- /dev/null +++ b/internal/jwtxt/token.go @@ -0,0 +1,275 @@ +// SPDX-FileCopyrightText: 2023 Comcast Cable Communications Management, LLC +// SPDX-License-Identifier: Apache-2.0 + +package jwtxt + +import ( + "context" + "errors" + "fmt" + "net" + "strings" + "sync" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/xmidt-org/eventor" + "github.com/xmidt-org/xmidt-agent/internal/jwtxt/event" +) + +var ( + ErrInvalidConfig = errors.New("invalid configuration") + ErrInvalidJWT = errors.New("invalid jwt txt record") + ErrInvalidPath = errors.New("invalid path") + ErrUnspportedID = errors.New("unsupported device id") + ErrUnspportedAlg = errors.New("unsupported jwt algorithm") + ErrNoKeys = errors.New("no keys provided") + ErrNoKeysMatch = errors.New("no keys match jwt") + ErrInvalidInput = errors.New("invalid input") +) + +const ( + // DefaultTimeout is the default timeout for DNS queries. + DefaultTimeout = time.Second * 15 +) + +// The Resolver interface allows users to provide their own resolver for +// resolving DNS TXT records. +type Resolver interface { + LookupTXT(context.Context, string) ([]string, error) +} + +type Instructions struct { + // baseURL is the base url to examine for a JWT from a DNS TXT record. + baseURL string + + // id is the identifier to prepend when looking for a DNS TXT record. + id string + + // fqdn is the 'device_id.base_url' based on the input configuration. + fqdn string + + // jwtOptions allows for normal and test configurations. + jwtOptions []jwt.ParserOption + + // timeout is the timeout for the DNS query. + timeout time.Duration + + // algorithms is the list of algorithms allowed for JWT validation. + algorithms []string + + // publicKeys is the collection of keys split out by supported algorithm. + publicKeys jwt.VerificationKeySet + + // now is used to supply the current time that is needed for expiration. + // it's here just for testing support. + now func() time.Time + + // resolver is used to supply the resolver to use; it's here just for + // testing support. + resolver Resolver + + // fetchListeners calls back listeners when a fetch event occurs. + fetchListeners eventor.Eventor[event.FetchListener] + + // ---- These fields are populated/used by the fetch method. ---- + + // m protects the fields below. + m sync.Mutex + + // validUntil is when the information from the JWT is valid until. + validUntil time.Time + + // endpoint is the endpoint of the most recent valid JWT. + endpoint string + + // payload is the payload of the most recent valid JWT. + payload []byte +} + +// New creates a new secure Instruction object. +func New(opts ...Option) (*Instructions, error) { + ins := Instructions{ + now: time.Now, + resolver: net.DefaultResolver, + timeout: DefaultTimeout, + algorithms: []string{}, + } + + for _, opt := range opts { + if opt != nil { + err := opt.apply(&ins) + if err != nil { + return nil, err + } + } + } + + if ins.baseURL == "" || ins.id == "" { + return nil, fmt.Errorf("%w: baseURL and id must be set", ErrInvalidInput) + } + + ins.fqdn = ins.id + "." + ins.baseURL + ins.jwtOptions = []jwt.ParserOption{jwt.WithValidMethods(ins.algorithms)} + + return &ins, nil +} + +func (ins *Instructions) dispatch(fe event.Fetch) error { + ins.fetchListeners.Visit(func(listener event.FetchListener) { + listener.OnFetchEvent(fe) + }) + return fe.Err +} + +// Endpoint returns the valid endpoint based on the instructions, or an error if +// there is no valid set of instructions. +func (ins *Instructions) Endpoint(ctx context.Context) (string, error) { + ins.m.Lock() + defer ins.m.Unlock() + + if ins.now().Before(ins.validUntil) { + return ins.endpoint, nil + } + + err := ins.fetch(ctx) + if err != nil { + return "", err + } + + return ins.endpoint, nil +} + +func (ins *Instructions) fetch(ctx context.Context) error { + fe := event.Fetch{ + FQDN: ins.fqdn, + PriorExpiration: ins.validUntil, + } + + // Don't wait forever if things are broken. + ctx, cancel := context.WithTimeout(ctx, ins.timeout) + defer cancel() + + lines, err := ins.resolver.LookupTXT(ctx, ins.fqdn) + if err != nil { + var dnsError *net.DNSError + + if errors.As(err, &dnsError) { + fe.Server = dnsError.Server + fe.Timeout = dnsError.Timeout() + fe.TemporaryErr = dnsError.Temporary() + } else { + if ctx.Err() != nil { + fe.Timeout = true + fe.TemporaryErr = true + } + } + fe.Err = err + return ins.dispatch(fe) + } + + fe.Found = true + + txt := ins.reassemble(lines) + + err = ins.validate(txt) + if err != nil { + fe.Err = err + return ins.dispatch(fe) + } + + fe.Endpoint = ins.endpoint + fe.Expiration = ins.validUntil + fe.Payload = ins.payload + + return ins.dispatch(fe) +} + +// reassemble converts the TXT record from the list of encoded lines into +// the expected string of text that we all hope is a legit JWT. The format +// of the lines in the TXT is: +// +// 00:base64_encoded_JWT_chuck_0 +// 01:base64_encoded_JWT_chuck_1 +// nn:base64_encoded_JWT_chuck_nn +// +// Notes: +// - the index could start at 0 or 1, so accept either. +// - the lines get concatenated in order and all parts are needed +// - only up to 100 lines are supported ... which is overkill for a JWT +// - each line can be 255 bytes long including the leading 3 characters +// - it doesn't really matter if we are missing something because the JWT +// won't compute and will be discarded. +func (ins *Instructions) reassemble(lines []string) string { + parts := make(map[string]string) + + // The value in the TXT record should be 1 (really 1, but make this tolerant + // of 0 based indexing) + parts["00"] = "" + + for _, line := range lines { + segments := strings.Split(line, ":") + if len(segments) != 2 { + // skip empty or otherwise malformed lines. + continue + } + parts[segments[0]] = segments[1] + } + + getIndexString := func(i int) string { return fmt.Sprintf("%0.2d", i) } + + // Since we're re-assembling a JWT that is validated later, do the best + // we can here, but don't be too strict. + var buf strings.Builder + + for i := 0; i < len(parts); i++ { + val, found := parts[getIndexString(i)] + if !found { + break + } + buf.WriteString(val) + } + + return buf.String() +} + +// validate takes a string that is believed to be a JWT and validates it. +// If it is valid, the information is saved in the Instruction object for +// use along with when the information is no longer valid after. +func (ins *Instructions) validate(input string) error { + parser := jwt.NewParser(ins.jwtOptions...) + + token, err := parser.ParseWithClaims(input, &customClaims{}, + func(t *jwt.Token) (any, error) { + return ins.publicKeys, nil + }) + if err != nil { + return err + } + + until, err := token.Claims.GetExpirationTime() + if err != nil { + return err + } + + _, parts, err := parser.ParseUnverified(input, &customClaims{}) + if err != nil { + return err + } + + payload, err := parser.DecodeSegment(parts[1]) + if err != nil { + return err + } + + ins.endpoint = token.Claims.(*customClaims).Endpoint + ins.payload = payload + ins.validUntil = (*until).Time + + return nil +} + +type customClaims struct { + Endpoint string `json:"endpoint"` + jwt.RegisteredClaims +} diff --git a/internal/jwtxt/token_test.go b/internal/jwtxt/token_test.go new file mode 100644 index 0000000..63eff3d --- /dev/null +++ b/internal/jwtxt/token_test.go @@ -0,0 +1,370 @@ +// SPDX-FileCopyrightText: 2023 Comcast Cable Communications Management, LLC +// SPDX-License-Identifier: Apache-2.0 + +package jwtxt + +import ( + "context" + "errors" + "net" + "testing" + "time" + + "github.com/foxcpp/go-mockdns" + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/xmidt-org/xmidt-agent/internal/jwtxt/event" +) + +func randomResolver() Option { + return UseResolver(&mockdns.Resolver{ + Zones: map[string]mockdns.Zone{ + "112233445566.fabric.random.example.org.": { + TXT: []string{ + "01:eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbmRwb2ludCI6I", + "02:mZhYnJpYy54bWlkdC5leGFtcGxlLm9yZyIsImV4cCI6MTY5MDAwMDA", + "03:wMH0.4ELQaJAcX67M0Me1ZjTAusZT3QZpiCj2WQATDCvgllnEN9g4R", + "04:xMeDqnqnYAE_GdzsXI_e9fAGI9o1QuIym7_zQ", + }, + }, + }, + }) +} + +type niceNeverResolver struct{} + +func (niceNeverResolver) LookupTXT(ctx context.Context, _ string) ([]string, error) { + <-ctx.Done() + + return nil, &net.DNSError{ + Err: "context canceled", + IsTimeout: true, + } +} + +type notNiceNeverResolver struct{} + +func (notNiceNeverResolver) LookupTXT(ctx context.Context, _ string) ([]string, error) { + <-ctx.Done() + + return nil, errors.New("context canceled") +} + +func TestInstructions_EndToEnd(t *testing.T) { + unknownErr := errors.New("unknown") + tests := []struct { + description string + opts []Option + times []int64 + listener func(*assert.Assertions, event.Fetch) + callEndpointTwice bool + expectedEndpoint string + expectedNewErr error + expectedEndpointErr error + }{ + { + description: "simple successful match", + times: []int64{1680000000}, + opts: []Option{ + BaseURL("https://fabric.random.example.org"), + DeviceID("mac:112233445566"), + Algorithms("ES256"), + publicECOption(), + randomResolver(), + }, + listener: func(assert *assert.Assertions, fe event.Fetch) { + assert.Equal("112233445566.fabric.random.example.org", fe.FQDN) + assert.Equal("", fe.Server) + assert.True(fe.Found) + assert.False(fe.Timeout) + assert.Equal(time.Time{}, fe.PriorExpiration) + assert.Equal(time.Unix(1690000000, 0), fe.Expiration) + assert.False(fe.TemporaryErr) + assert.Equal("fabric.xmidt.example.org", fe.Endpoint) + assert.Equal(`{"endpoint":"fabric.xmidt.example.org","exp":1690000000}`, string(fe.Payload)) + assert.NoError(fe.Err) + }, + expectedEndpoint: "fabric.xmidt.example.org", + }, { + description: "simple successful match returned twice", + times: []int64{1680000000}, + opts: []Option{ + BaseURL("https://fabric.random.example.org"), + DeviceID("mac:112233445566"), + Algorithms("ES256"), + publicECOption(), + randomResolver(), + }, + listener: func(assert *assert.Assertions, fe event.Fetch) { + assert.Equal("112233445566.fabric.random.example.org", fe.FQDN) + assert.Equal("", fe.Server) + assert.True(fe.Found) + assert.False(fe.Timeout) + assert.Equal(time.Time{}, fe.PriorExpiration) + assert.Equal(time.Unix(1690000000, 0), fe.Expiration) + assert.False(fe.TemporaryErr) + assert.Equal("fabric.xmidt.example.org", fe.Endpoint) + assert.Equal(`{"endpoint":"fabric.xmidt.example.org","exp":1690000000}`, string(fe.Payload)) + assert.NoError(fe.Err) + }, + callEndpointTwice: true, + expectedEndpoint: "fabric.xmidt.example.org", + }, { + description: "successful match starting at 0, with empty lines and out of order", + times: []int64{1680000000}, + opts: []Option{ + BaseURL("https://fabric.random.example.org"), + DeviceID("mac:112233445566"), + Algorithms("ES256"), + publicECOption(), + UseResolver(&mockdns.Resolver{ + Zones: map[string]mockdns.Zone{ + "112233445566.fabric.random.example.org.": { + TXT: []string{ + "01:mZhYnJpYy54bWlkdC5leGFtcGxlLm9yZyIsImV4cCI6MTY5MDAwMDA", + "", + "ignored:value", + "02:wMH0.4ELQaJAcX67M0Me1ZjTAusZT3QZpiCj2WQATDCvgllnEN9g4R", + "ignored:value:somewhere_else", + "03:xMeDqnqnYAE_GdzsXI_e9fAGI9o1QuIym7_zQ", + "00:eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbmRwb2ludCI6I", + }, + }, + }, + }), + }, + expectedEndpoint: "fabric.xmidt.example.org", + }, { + description: "no record", + times: []int64{1680000000}, + expectedEndpointErr: unknownErr, + opts: []Option{ + BaseURL("https://fabric.random.example.org"), + DeviceID("mac:112233445566"), + Algorithms("ES256"), + publicECOption(), + UseResolver(&mockdns.Resolver{}), + }, + listener: func(assert *assert.Assertions, fe event.Fetch) { + assert.Equal("112233445566.fabric.random.example.org", fe.FQDN) + assert.False(fe.Found) + assert.False(fe.Timeout) + assert.Equal(time.Time{}, fe.PriorExpiration) + assert.Equal(time.Time{}, fe.Expiration) + assert.False(fe.TemporaryErr) + assert.Equal("", fe.Endpoint) + assert.Error(fe.Err) + }, + }, { + description: "missing segments", + times: []int64{1680000000}, + expectedEndpointErr: jwt.ErrTokenMalformed, + opts: []Option{ + BaseURL("https://fabric.random.example.org"), + DeviceID("mac:112233445566"), + Algorithms("ES256"), + publicECOption(), + UseResolver(&mockdns.Resolver{ + Zones: map[string]mockdns.Zone{ + "112233445566.fabric.random.example.org.": { + TXT: []string{ + + "00:eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbmRwb2ludCI6I", + "01:mZhYnJpYy54bWlkdC5leGFtcGxlLm9yZyIsImV4cCI6MTY5MDAwMDA", + "03:xMeDqnqnYAE_GdzsXI_e9fAGI9o1QuIym7_zQ", + }, + }, + }, + }), + }, + }, { + description: "expired token", + times: []int64{1700000000}, + expectedEndpointErr: jwt.ErrTokenExpired, + opts: []Option{ + BaseURL("https://fabric.random.example.org"), + DeviceID("mac:112233445566"), + Algorithms("ES256"), + publicECOption(), + randomResolver(), + }, + listener: func(assert *assert.Assertions, fe event.Fetch) { + assert.Equal("112233445566.fabric.random.example.org", fe.FQDN) + assert.True(fe.Found) + assert.False(fe.Timeout) + assert.Equal(time.Time{}, fe.PriorExpiration) + assert.Equal(time.Time{}, fe.Expiration) + assert.False(fe.TemporaryErr) + assert.Equal("", fe.Endpoint) + assert.ErrorIs(fe.Err, jwt.ErrTokenExpired) + }, + }, { + description: "times out with nice resolver", + times: []int64{1680000000}, + expectedEndpointErr: unknownErr, + opts: []Option{ + BaseURL("https://fabric.random.example.org"), + DeviceID("mac:112233445566"), + Algorithms("ES256"), + publicECOption(), + UseResolver(&niceNeverResolver{}), + Timeout(time.Nanosecond), + }, + listener: func(assert *assert.Assertions, fe event.Fetch) { + assert.Equal("112233445566.fabric.random.example.org", fe.FQDN) + assert.False(fe.Found) + assert.True(fe.Timeout) + assert.Equal(time.Time{}, fe.PriorExpiration) + assert.Equal(time.Time{}, fe.Expiration) + assert.True(fe.TemporaryErr) + assert.Empty(fe.Endpoint) + assert.Error(fe.Err) + }, + }, { + description: "times out with not nice resolver", + times: []int64{1680000000}, + expectedEndpointErr: unknownErr, + opts: []Option{ + BaseURL("https://fabric.random.example.org"), + DeviceID("mac:112233445566"), + Algorithms("ES256"), + publicECOption(), + UseResolver(¬NiceNeverResolver{}), + Timeout(time.Nanosecond), + }, + listener: func(assert *assert.Assertions, fe event.Fetch) { + assert.Equal("112233445566.fabric.random.example.org", fe.FQDN) + assert.False(fe.Found) + assert.True(fe.Timeout) + assert.Equal(time.Time{}, fe.PriorExpiration) + assert.Equal(time.Time{}, fe.Expiration) + assert.True(fe.TemporaryErr) + assert.Empty(fe.Endpoint) + assert.Error(fe.Err) + }, + }, { + description: "no algorithms", + times: []int64{1680000000}, + expectedEndpointErr: jwt.ErrTokenSignatureInvalid, + opts: []Option{ + BaseURL("https://fabric.random.example.org"), + DeviceID("mac:112233445566"), + anyPublicOption(), + randomResolver(), + }, + }, { + description: "no keys", + times: []int64{1680000000}, + expectedEndpointErr: jwt.ErrTokenUnverifiable, + opts: []Option{ + BaseURL("https://fabric.random.example.org"), + DeviceID("mac:112233445566"), + Algorithms("ES256"), + randomResolver(), + }, + }, { + description: "no device id", + expectedNewErr: ErrInvalidInput, + opts: []Option{ + BaseURL("https://fabric.random.example.org"), + Algorithms("ES256"), + publicECOption(), + randomResolver(), + }, + }, { + description: "no base url", + expectedNewErr: ErrInvalidInput, + opts: []Option{ + DeviceID("mac:112233445566"), + Algorithms("ES256"), + publicECOption(), + randomResolver(), + }, + }, { + description: "invalid base url", + opts: []Option{ + BaseURL("invalid"), + }, + expectedNewErr: ErrInvalidInput, + }, { + description: "invalid device id", + opts: []Option{ + DeviceID("invalid"), + }, + expectedNewErr: ErrInvalidInput, + }, { + description: "invalid algorithm", + opts: []Option{ + Algorithms("invalid"), + }, + expectedNewErr: ErrUnspportedAlg, + }, { + description: "invalid pem", + opts: []Option{ + WithPEMs([]byte("invalid")), + }, + expectedNewErr: ErrInvalidInput, + }, { + description: "invalid timeout", + opts: []Option{ + Timeout(0), // ok, just set the default again. + Timeout(-1), + }, + expectedNewErr: ErrInvalidInput, + }, + } + for _, tc := range tests { + t.Run(tc.description, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + then := func() time.Time { return time.Unix(tc.times[0], 0) } + + opts := append(tc.opts, UseNowFunc(then)) + if tc.listener != nil { + opts = append(opts, WithFetchListener( + event.FetchListenerFunc( + func(fe event.Fetch) { + tc.listener(assert, fe) + }, + ))) + } + obj, err := New(opts...) + + if tc.expectedNewErr == nil { + assert.NoError(err) + require.NotNil(obj) + } else { + assert.ErrorIs(err, tc.expectedNewErr) + return + } + + when := jwt.WithTimeFunc(then) + obj.jwtOptions = append(obj.jwtOptions, when) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*2) + defer cancel() + + if tc.callEndpointTwice { + endpoint, err := obj.Endpoint(ctx) + require.NoError(err) + require.NotEmpty(endpoint) + } + endpoint, err := obj.Endpoint(ctx) + + if tc.expectedEndpointErr != nil { + assert.Error(err) + assert.Empty(endpoint) + + if !errors.Is(tc.expectedEndpointErr, unknownErr) { + assert.ErrorIs(err, tc.expectedEndpointErr) + } + return + } + + assert.NoError(err) + assert.Equal(tc.expectedEndpoint, endpoint) + }) + } +}