This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
// Package cached handles a [metadata.Provider] implementation that both downloads and caches the MDS3 blob. This
|
||||
// effectively is the recommended provider in most instances as it's fairly robust. Alternatively we suggest
|
||||
// implementing a similar provider that leverages the [memory.Provider] as an underlying element.
|
||||
//
|
||||
// This provider only specifically performs updates at the time it's initialized. It has no automatic update
|
||||
// functionality. This may change in the future however if you want this functionality at this time we recommend making
|
||||
// your own implementation.
|
||||
package cached
|
||||
@@ -0,0 +1,92 @@
|
||||
package cached
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/go-webauthn/webauthn/metadata"
|
||||
)
|
||||
|
||||
// Option describes an optional pattern for this provider.
|
||||
type Option func(provider *Provider) (err error)
|
||||
|
||||
// NewFunc describes the type used to create the underlying provider.
|
||||
type NewFunc func(mds *metadata.Metadata) (provider metadata.Provider, err error)
|
||||
|
||||
// WithPath sets the path name for the cached file. This option is REQUIRED.
|
||||
func WithPath(name string) Option {
|
||||
return func(provider *Provider) (err error) {
|
||||
provider.name = name
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithUpdate is used to enable or disable the update. By default it's set to true.
|
||||
func WithUpdate(update bool) Option {
|
||||
return func(provider *Provider) (err error) {
|
||||
provider.update = update
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithForceUpdate is used to force an update on creation. This will forcibly overwrite the file if possible.
|
||||
func WithForceUpdate(force bool) Option {
|
||||
return func(provider *Provider) (err error) {
|
||||
provider.force = force
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithNew customizes the NewFunc. By default we just create a fairly standard [memory.Provider] with strict defaults.
|
||||
func WithNew(newup NewFunc) Option {
|
||||
return func(provider *Provider) (err error) {
|
||||
provider.newup = newup
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithDecoder sets the decoder to be used for this provider. By default this is a decoder with the entry parsing errors
|
||||
// configured to skip that entry.
|
||||
func WithDecoder(decoder *metadata.Decoder) Option {
|
||||
return func(provider *Provider) (err error) {
|
||||
provider.decoder = decoder
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithMetadataURL configures the URL to get the metadata from. This shouldn't be modified unless you know what you're
|
||||
// doing as we use the [metadata.ProductionMDSURL] which is safe in most instances.
|
||||
func WithMetadataURL(uri string) Option {
|
||||
return func(provider *Provider) (err error) {
|
||||
if _, err = url.ParseRequestURI(uri); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
provider.uri = uri
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithClient configures the [*http.Client] used to get the MDS3 blob.
|
||||
func WithClient(client *http.Client) Option {
|
||||
return func(provider *Provider) (err error) {
|
||||
provider.client = client
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithClock allows injection of a [metadata.Clock] to check the up-to-date status of a blob.
|
||||
func WithClock(clock metadata.Clock) Option {
|
||||
return func(provider *Provider) (err error) {
|
||||
provider.clock = clock
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package cached
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/go-webauthn/webauthn/metadata"
|
||||
)
|
||||
|
||||
// New returns a new cached Provider given a set of functional [Option]'s. This provider will download a new version and
|
||||
// save it to the configured file path if it doesn't exist or if it's out of date by default.
|
||||
func New(opts ...Option) (provider metadata.Provider, err error) {
|
||||
p := &Provider{
|
||||
update: true,
|
||||
uri: metadata.ProductionMDSURL,
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
if err = opt(p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if p.name == "" {
|
||||
return nil, fmt.Errorf("provider configured without setting a path for the cached file blob")
|
||||
}
|
||||
|
||||
if p.newup == nil {
|
||||
p.newup = defaultNew
|
||||
}
|
||||
|
||||
if p.decoder == nil {
|
||||
if p.decoder, err = metadata.NewDecoder(metadata.WithIgnoreEntryParsingErrors()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if p.clock == nil {
|
||||
p.clock = &metadata.RealClock{}
|
||||
}
|
||||
|
||||
if err = p.init(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Provider implements a [metadata.Provider] with a file-based cache.
|
||||
type Provider struct {
|
||||
metadata.Provider
|
||||
|
||||
name string
|
||||
uri string
|
||||
update bool
|
||||
force bool
|
||||
clock metadata.Clock
|
||||
client *http.Client
|
||||
decoder *metadata.Decoder
|
||||
newup NewFunc
|
||||
}
|
||||
|
||||
func (p *Provider) init() (err error) {
|
||||
var (
|
||||
f *os.File
|
||||
rc io.ReadCloser
|
||||
created bool
|
||||
mds *metadata.Metadata
|
||||
)
|
||||
|
||||
if f, created, err = doOpenOrCreate(p.name); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer f.Close()
|
||||
|
||||
if created || p.force {
|
||||
if rc, err = p.get(); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if mds, err = p.parse(f); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if p.outdated(mds) {
|
||||
if rc, err = p.get(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if rc != nil {
|
||||
if err = doTruncateCopyAndSeekStart(f, rc); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if mds, err = p.parse(f); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
var provider metadata.Provider
|
||||
|
||||
if provider, err = p.newup(mds); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
p.Provider = provider
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Provider) parse(rc io.ReadCloser) (data *metadata.Metadata, err error) {
|
||||
var payload *metadata.PayloadJSON
|
||||
|
||||
if payload, err = p.decoder.Decode(rc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if data, err = p.decoder.Parse(payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (p *Provider) outdated(mds *metadata.Metadata) bool {
|
||||
return p.update && p.clock.Now().After(mds.Parsed.NextUpdate)
|
||||
}
|
||||
|
||||
func (p *Provider) get() (f io.ReadCloser, err error) {
|
||||
if p.client == nil {
|
||||
p.client = &http.Client{}
|
||||
}
|
||||
|
||||
var res *http.Response
|
||||
|
||||
if res, err = p.client.Get(p.uri); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return res.Body, nil
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package cached
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/go-webauthn/webauthn/metadata"
|
||||
)
|
||||
|
||||
func TestNew_Errors(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
opts []Option
|
||||
err string
|
||||
}{
|
||||
{
|
||||
name: "ShouldFailWithoutPath",
|
||||
opts: nil,
|
||||
err: "provider configured without setting a path for the cached file blob",
|
||||
},
|
||||
{
|
||||
name: "ShouldFailWithEmptyPath",
|
||||
opts: []Option{WithPath("")},
|
||||
err: "provider configured without setting a path for the cached file blob",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
provider, err := New(tc.opts...)
|
||||
assert.Nil(t, provider)
|
||||
require.EqualError(t, err, tc.err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptions(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
opt Option
|
||||
verify func(t *testing.T, p *Provider)
|
||||
}{
|
||||
{
|
||||
name: "ShouldSetPath",
|
||||
opt: WithPath("/tmp/test.json"),
|
||||
verify: func(t *testing.T, p *Provider) {
|
||||
assert.Equal(t, "/tmp/test.json", p.name)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ShouldSetUpdate",
|
||||
opt: WithUpdate(false),
|
||||
verify: func(t *testing.T, p *Provider) {
|
||||
assert.False(t, p.update)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ShouldSetUpdateTrue",
|
||||
opt: WithUpdate(true),
|
||||
verify: func(t *testing.T, p *Provider) {
|
||||
assert.True(t, p.update)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ShouldSetForceUpdate",
|
||||
opt: WithForceUpdate(true),
|
||||
verify: func(t *testing.T, p *Provider) {
|
||||
assert.True(t, p.force)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ShouldSetClient",
|
||||
opt: WithClient(&http.Client{Timeout: 5 * time.Second}),
|
||||
verify: func(t *testing.T, p *Provider) {
|
||||
require.NotNil(t, p.client)
|
||||
assert.Equal(t, 5*time.Second, p.client.Timeout)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ShouldSetMetadataURL",
|
||||
opt: WithMetadataURL("https://example.com/mds"),
|
||||
verify: func(t *testing.T, p *Provider) {
|
||||
assert.Equal(t, "https://example.com/mds", p.uri)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ShouldSetDecoder",
|
||||
opt: func() Option {
|
||||
d, _ := metadata.NewDecoder()
|
||||
return WithDecoder(d)
|
||||
}(),
|
||||
verify: func(t *testing.T, p *Provider) {
|
||||
assert.NotNil(t, p.decoder)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ShouldSetClock",
|
||||
opt: WithClock(&metadata.RealClock{}),
|
||||
verify: func(t *testing.T, p *Provider) {
|
||||
assert.NotNil(t, p.clock)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
p := &Provider{}
|
||||
|
||||
err := tc.opt(p)
|
||||
require.NoError(t, err)
|
||||
|
||||
tc.verify(t, p)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithMetadataURL_Invalid(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
uri string
|
||||
err string
|
||||
}{
|
||||
{
|
||||
name: "ShouldRejectInvalidURL",
|
||||
uri: "not a valid url",
|
||||
err: `parse "not a valid url": invalid URI for request`,
|
||||
},
|
||||
{
|
||||
name: "ShouldRejectEmptyURL",
|
||||
uri: "",
|
||||
err: `parse "": empty url`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
p := &Provider{}
|
||||
err := WithMetadataURL(tc.uri)(p)
|
||||
require.EqualError(t, err, tc.err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithNew(t *testing.T) {
|
||||
called := false
|
||||
|
||||
fn := func(mds *metadata.Metadata) (metadata.Provider, error) {
|
||||
called = true
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
p := &Provider{}
|
||||
|
||||
require.NoError(t, WithNew(fn)(p))
|
||||
require.NotNil(t, p.newup)
|
||||
|
||||
_, _ = p.newup(nil)
|
||||
|
||||
assert.True(t, called)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package cached
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/go-webauthn/webauthn/metadata"
|
||||
"github.com/go-webauthn/webauthn/metadata/providers/memory"
|
||||
)
|
||||
|
||||
func doTruncateCopyAndSeekStart(f *os.File, rc io.ReadCloser) (err error) {
|
||||
if err = f.Truncate(0); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err = io.Copy(f, rc); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err = f.Seek(0, io.SeekStart); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return rc.Close()
|
||||
}
|
||||
|
||||
func doOpenOrCreate(name string) (f *os.File, created bool, err error) {
|
||||
if f, err = os.OpenFile(name, os.O_RDWR, 0); err == nil {
|
||||
return f, false, nil
|
||||
}
|
||||
|
||||
if os.IsNotExist(err) {
|
||||
if f, err = os.Create(name); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
return f, true, nil
|
||||
}
|
||||
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
func defaultNew(mds *metadata.Metadata) (provider metadata.Provider, err error) {
|
||||
return memory.New(
|
||||
memory.WithMetadata(mds.ToMap()),
|
||||
memory.WithValidateEntry(true),
|
||||
memory.WithValidateEntryPermitZeroAAGUID(false),
|
||||
memory.WithValidateTrustAnchor(true),
|
||||
memory.WithValidateStatus(true),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package cached
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/go-webauthn/webauthn/metadata"
|
||||
)
|
||||
|
||||
func TestDoOpenOrCreate(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
setup func(t *testing.T) string
|
||||
expectedCreated bool
|
||||
err string
|
||||
}{
|
||||
{
|
||||
name: "ShouldCreateNewFile",
|
||||
setup: func(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
return filepath.Join(t.TempDir(), "new-file.json")
|
||||
},
|
||||
expectedCreated: true,
|
||||
},
|
||||
{
|
||||
name: "ShouldOpenExistingFile",
|
||||
setup: func(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "existing-file.json")
|
||||
|
||||
f, err := os.Create(path)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, f.Close())
|
||||
|
||||
return path
|
||||
},
|
||||
expectedCreated: false,
|
||||
},
|
||||
{
|
||||
name: "ShouldFailInvalidPath",
|
||||
setup: func(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
return filepath.Join(t.TempDir(), "nonexistent-dir", "subdir", "file.json")
|
||||
},
|
||||
err: "no such file or directory",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
path := tc.setup(t)
|
||||
|
||||
f, created, err := doOpenOrCreate(path)
|
||||
|
||||
if tc.err != "" {
|
||||
assert.Nil(t, f)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tc.err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, f)
|
||||
assert.Equal(t, tc.expectedCreated, created)
|
||||
|
||||
require.NoError(t, f.Close())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoTruncateCopyAndSeekStart(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
initialContent string
|
||||
copyContent string
|
||||
expectedContent string
|
||||
err string
|
||||
}{
|
||||
{
|
||||
name: "ShouldTruncateAndCopy",
|
||||
initialContent: "old content that should be replaced",
|
||||
copyContent: "new data",
|
||||
expectedContent: "new data",
|
||||
},
|
||||
{
|
||||
name: "ShouldHandleEmptyInitialContent",
|
||||
initialContent: "",
|
||||
copyContent: "fresh content",
|
||||
expectedContent: "fresh content",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "test-file.json")
|
||||
|
||||
f, err := os.Create(path)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = f.WriteString(tc.initialContent)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = f.Seek(0, io.SeekStart)
|
||||
require.NoError(t, err)
|
||||
|
||||
rc := io.NopCloser(bytes.NewReader([]byte(tc.copyContent)))
|
||||
|
||||
err = doTruncateCopyAndSeekStart(f, rc)
|
||||
|
||||
if tc.err != "" {
|
||||
assert.EqualError(t, err, tc.err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
|
||||
content, err := io.ReadAll(f)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.expectedContent, string(content))
|
||||
}
|
||||
|
||||
require.NoError(t, f.Close())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultNew(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
have *metadata.Metadata
|
||||
err string
|
||||
}{
|
||||
{
|
||||
name: "ShouldSucceedWithEmptyMetadata",
|
||||
have: &metadata.Metadata{
|
||||
Parsed: metadata.Parsed{
|
||||
NextUpdate: time.Now().Add(time.Hour * 24),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ShouldSucceedWithEntries",
|
||||
have: &metadata.Metadata{
|
||||
Parsed: metadata.Parsed{
|
||||
NextUpdate: time.Now().Add(time.Hour * 24),
|
||||
Entries: []metadata.Entry{
|
||||
{
|
||||
AaGUID: uuid.MustParse("2369d4d0-13ce-48cb-9f26-f7ed8c9a6068"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
provider, err := defaultNew(tc.have)
|
||||
|
||||
if tc.err == "" {
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, provider)
|
||||
} else {
|
||||
assert.EqualError(t, err, tc.err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderOutdated(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
have struct {
|
||||
update bool
|
||||
clockAt time.Time
|
||||
nextUpd time.Time
|
||||
}
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "ShouldBeOutdatedWhenPastNextUpdate",
|
||||
have: struct {
|
||||
update bool
|
||||
clockAt time.Time
|
||||
nextUpd time.Time
|
||||
}{
|
||||
update: true,
|
||||
clockAt: time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC),
|
||||
nextUpd: time.Date(2025, 5, 1, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "ShouldNotBeOutdatedWhenBeforeNextUpdate",
|
||||
have: struct {
|
||||
update bool
|
||||
clockAt time.Time
|
||||
nextUpd time.Time
|
||||
}{
|
||||
update: true,
|
||||
clockAt: time.Date(2025, 4, 1, 0, 0, 0, 0, time.UTC),
|
||||
nextUpd: time.Date(2025, 5, 1, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "ShouldNotBeOutdatedWhenUpdateDisabled",
|
||||
have: struct {
|
||||
update bool
|
||||
clockAt time.Time
|
||||
nextUpd time.Time
|
||||
}{
|
||||
update: false,
|
||||
clockAt: time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC),
|
||||
nextUpd: time.Date(2025, 5, 1, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
p := &Provider{
|
||||
update: tc.have.update,
|
||||
clock: &mockClock{now: tc.have.clockAt},
|
||||
}
|
||||
|
||||
mds := &metadata.Metadata{
|
||||
Parsed: metadata.Parsed{
|
||||
NextUpdate: tc.have.nextUpd,
|
||||
},
|
||||
}
|
||||
|
||||
assert.Equal(t, tc.expected, p.outdated(mds))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type mockClock struct {
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func (c *mockClock) Now() time.Time {
|
||||
return c.now
|
||||
}
|
||||
Reference in New Issue
Block a user