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
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Package memory handles a [metadata.Provider] implementation that solely exists in memory. It's intended as a basis
|
||||
// for other providers and generally not recommended to use directly unless you're implementing your own logic to handle
|
||||
// the download and potential caching of the MDS3 blob yourself.
|
||||
package memory
|
||||
@@ -0,0 +1,90 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/go-webauthn/webauthn/metadata"
|
||||
)
|
||||
|
||||
// Option describes an optional pattern for this provider.
|
||||
type Option func(provider *Provider) (err error)
|
||||
|
||||
// WithMetadata provides the required metadata for the memory provider.
|
||||
func WithMetadata(mds map[uuid.UUID]*metadata.Entry) Option {
|
||||
return func(provider *Provider) (err error) {
|
||||
provider.mds = mds
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithValidateEntry requires that the provided metadata has an entry for the given authenticator to be considered
|
||||
// valid. By default an AAGUID which has a zero value should fail validation if [WithValidateEntryPermitZeroAAGUID] is not
|
||||
// provided with the value of true. Default is true.
|
||||
func WithValidateEntry(require bool) Option {
|
||||
return func(provider *Provider) (err error) {
|
||||
provider.entry = require
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithValidateEntryPermitZeroAAGUID is an option that permits a zero'd AAGUID from an attestation statement to
|
||||
// automatically pass metadata validations. Generally helpful to use with [WithValidateEntry]. Default is false.
|
||||
func WithValidateEntryPermitZeroAAGUID(permit bool) Option {
|
||||
return func(provider *Provider) (err error) {
|
||||
provider.entryPermitZero = permit
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithValidateTrustAnchor when set to true enables the validation of the attestation statement against the trust anchor
|
||||
// from the metadata. Default is true.
|
||||
func WithValidateTrustAnchor(validate bool) Option {
|
||||
return func(provider *Provider) (err error) {
|
||||
provider.anchors = validate
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithValidateStatus when set to true enables the validation of the attestation statements AAGUID against the desired
|
||||
// and undesired [metadata.AuthenticatorStatus] lists. Default is true.
|
||||
func WithValidateStatus(validate bool) Option {
|
||||
return func(provider *Provider) (err error) {
|
||||
provider.status = validate
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithValidateAttestationTypes when set to true enables the validation of the attestation statements type against the
|
||||
// known types the authenticator can produce. Default is true.
|
||||
func WithValidateAttestationTypes(validate bool) Option {
|
||||
return func(provider *Provider) (err error) {
|
||||
provider.attestation = validate
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithStatusUndesired provides the list of statuses which are considered undesirable for status report validation
|
||||
// purposes. Should be used with [WithValidateStatus] set to true.
|
||||
func WithStatusUndesired(statuses []metadata.AuthenticatorStatus) Option {
|
||||
return func(provider *Provider) (err error) {
|
||||
provider.undesired = statuses
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithStatusDesired provides the list of statuses which are considered desired and will be required for status report
|
||||
// validation purposes. Should be used with [WithValidateStatus] set to true.
|
||||
func WithStatusDesired(statuses []metadata.AuthenticatorStatus) Option {
|
||||
return func(provider *Provider) (err error) {
|
||||
provider.desired = statuses
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/go-webauthn/webauthn/metadata"
|
||||
)
|
||||
|
||||
// New returns a new memory Provider given a set of functional Option's.
|
||||
func New(opts ...Option) (provider metadata.Provider, err error) {
|
||||
p := &Provider{
|
||||
undesired: metadata.DefaultUndesiredAuthenticatorStatuses(),
|
||||
entry: true,
|
||||
anchors: true,
|
||||
status: true,
|
||||
attestation: true,
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
if err = opt(p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if p.mds == nil {
|
||||
return nil, fmt.Errorf("memory metadata provider has not been initialized with metadata")
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Provider is a concrete implementation of the [metadata.Provider] that utilizes memory for validation. This provider is
|
||||
// a simple one-shot that doesn't perform any locking, provide dynamic functionality, or download the metadata at any
|
||||
// stage (it expects it's provided via one of the Option's).
|
||||
type Provider struct {
|
||||
mds map[uuid.UUID]*metadata.Entry
|
||||
desired []metadata.AuthenticatorStatus
|
||||
undesired []metadata.AuthenticatorStatus
|
||||
entry bool
|
||||
entryPermitZero bool
|
||||
anchors bool
|
||||
status bool
|
||||
attestation bool
|
||||
}
|
||||
|
||||
func (p *Provider) GetEntry(ctx context.Context, aaguid uuid.UUID) (entry *metadata.Entry, err error) {
|
||||
if p.mds == nil {
|
||||
return nil, metadata.ErrNotInitialized
|
||||
}
|
||||
|
||||
var ok bool
|
||||
|
||||
if entry, ok = p.mds[aaguid]; ok {
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetValidateEntry(ctx context.Context) (require bool) {
|
||||
return p.entry
|
||||
}
|
||||
|
||||
func (p *Provider) GetValidateEntryPermitZeroAAGUID(ctx context.Context) (skip bool) {
|
||||
return p.entryPermitZero
|
||||
}
|
||||
|
||||
func (p *Provider) GetValidateTrustAnchor(ctx context.Context) (validate bool) {
|
||||
return p.anchors
|
||||
}
|
||||
|
||||
func (p *Provider) GetValidateStatus(ctx context.Context) (validate bool) {
|
||||
return p.status
|
||||
}
|
||||
|
||||
func (p *Provider) GetValidateAttestationTypes(ctx context.Context) (validate bool) {
|
||||
return p.attestation
|
||||
}
|
||||
|
||||
func (p *Provider) ValidateStatusReports(ctx context.Context, reports []metadata.StatusReport) (err error) {
|
||||
if !p.status {
|
||||
return nil
|
||||
}
|
||||
|
||||
return metadata.ValidateStatusReports(reports, p.desired, p.undesired)
|
||||
}
|
||||
|
||||
var (
|
||||
_ metadata.Provider = (*Provider)(nil)
|
||||
)
|
||||
@@ -0,0 +1,236 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/go-webauthn/webauthn/metadata"
|
||||
)
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
id := uuid.New()
|
||||
|
||||
entry := &metadata.Entry{
|
||||
AaGUID: id,
|
||||
MetadataStatement: metadata.Statement{
|
||||
Description: "Test Authenticator",
|
||||
},
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
opts []Option
|
||||
err string
|
||||
}{
|
||||
{
|
||||
name: "ShouldSucceedWithMetadata",
|
||||
opts: []Option{WithMetadata(map[uuid.UUID]*metadata.Entry{id: entry})},
|
||||
},
|
||||
{
|
||||
name: "ShouldFailWithoutMetadata",
|
||||
opts: nil,
|
||||
err: "memory metadata provider has not been initialized with metadata",
|
||||
},
|
||||
{
|
||||
name: "ShouldSucceedWithAllOptions",
|
||||
opts: []Option{
|
||||
WithMetadata(map[uuid.UUID]*metadata.Entry{id: entry}),
|
||||
WithValidateEntry(false),
|
||||
WithValidateEntryPermitZeroAAGUID(true),
|
||||
WithValidateTrustAnchor(false),
|
||||
WithValidateStatus(false),
|
||||
WithValidateAttestationTypes(false),
|
||||
WithStatusUndesired([]metadata.AuthenticatorStatus{metadata.Revoked}),
|
||||
WithStatusDesired([]metadata.AuthenticatorStatus{metadata.FidoCertified}),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
provider, err := New(tc.opts...)
|
||||
|
||||
if tc.err != "" {
|
||||
assert.Nil(t, provider)
|
||||
require.EqualError(t, err, tc.err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, provider)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_GetEntry(t *testing.T) {
|
||||
id := uuid.New()
|
||||
missing := uuid.New()
|
||||
|
||||
entry := &metadata.Entry{
|
||||
AaGUID: id,
|
||||
MetadataStatement: metadata.Statement{
|
||||
Description: "Test Authenticator",
|
||||
},
|
||||
}
|
||||
|
||||
provider, err := New(WithMetadata(map[uuid.UUID]*metadata.Entry{id: entry}))
|
||||
require.NoError(t, err)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
aaguid uuid.UUID
|
||||
expected *metadata.Entry
|
||||
}{
|
||||
{
|
||||
name: "ShouldReturnEntryWhenExists",
|
||||
aaguid: id,
|
||||
expected: entry,
|
||||
},
|
||||
{
|
||||
name: "ShouldReturnNilWhenNotExists",
|
||||
aaguid: missing,
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "ShouldReturnNilForNilUUID",
|
||||
aaguid: uuid.Nil,
|
||||
expected: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result, err := provider.GetEntry(context.Background(), tc.aaguid)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_ConfigurationFlags(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
opts []Option
|
||||
expectedEntry bool
|
||||
expectedPermitZero bool
|
||||
expectedTrustAnchor bool
|
||||
expectedStatus bool
|
||||
expectedAttestationTypes bool
|
||||
}{
|
||||
{
|
||||
name: "ShouldReturnDefaultFlags",
|
||||
opts: []Option{
|
||||
WithMetadata(map[uuid.UUID]*metadata.Entry{}),
|
||||
},
|
||||
expectedEntry: true,
|
||||
expectedPermitZero: false,
|
||||
expectedTrustAnchor: true,
|
||||
expectedStatus: true,
|
||||
expectedAttestationTypes: true,
|
||||
},
|
||||
{
|
||||
name: "ShouldReturnCustomFlags",
|
||||
opts: []Option{
|
||||
WithMetadata(map[uuid.UUID]*metadata.Entry{}),
|
||||
WithValidateEntry(false),
|
||||
WithValidateEntryPermitZeroAAGUID(true),
|
||||
WithValidateTrustAnchor(false),
|
||||
WithValidateStatus(false),
|
||||
WithValidateAttestationTypes(false),
|
||||
},
|
||||
expectedEntry: false,
|
||||
expectedPermitZero: true,
|
||||
expectedTrustAnchor: false,
|
||||
expectedStatus: false,
|
||||
expectedAttestationTypes: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
provider, err := New(tc.opts...)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
assert.Equal(t, tc.expectedEntry, provider.GetValidateEntry(ctx))
|
||||
assert.Equal(t, tc.expectedPermitZero, provider.GetValidateEntryPermitZeroAAGUID(ctx))
|
||||
assert.Equal(t, tc.expectedTrustAnchor, provider.GetValidateTrustAnchor(ctx))
|
||||
assert.Equal(t, tc.expectedStatus, provider.GetValidateStatus(ctx))
|
||||
assert.Equal(t, tc.expectedAttestationTypes, provider.GetValidateAttestationTypes(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_ValidateStatusReports(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
opts []Option
|
||||
reports []metadata.StatusReport
|
||||
err string
|
||||
}{
|
||||
{
|
||||
name: "ShouldPassWithNoUndesiredStatuses",
|
||||
opts: []Option{
|
||||
WithMetadata(map[uuid.UUID]*metadata.Entry{}),
|
||||
WithValidateStatus(true),
|
||||
},
|
||||
reports: []metadata.StatusReport{{Status: metadata.FidoCertified}},
|
||||
},
|
||||
{
|
||||
name: "ShouldFailWithUndesiredStatus",
|
||||
opts: []Option{
|
||||
WithMetadata(map[uuid.UUID]*metadata.Entry{}),
|
||||
WithValidateStatus(true),
|
||||
WithStatusUndesired([]metadata.AuthenticatorStatus{metadata.Revoked}),
|
||||
},
|
||||
reports: []metadata.StatusReport{{Status: metadata.Revoked}},
|
||||
err: "The following undesired status reports were present: REVOKED",
|
||||
},
|
||||
{
|
||||
name: "ShouldPassWhenStatusValidationDisabled",
|
||||
opts: []Option{
|
||||
WithMetadata(map[uuid.UUID]*metadata.Entry{}),
|
||||
WithValidateStatus(false),
|
||||
},
|
||||
reports: []metadata.StatusReport{{Status: metadata.Revoked}},
|
||||
},
|
||||
{
|
||||
name: "ShouldFailWithDesiredStatusAbsent",
|
||||
opts: []Option{
|
||||
WithMetadata(map[uuid.UUID]*metadata.Entry{}),
|
||||
WithValidateStatus(true),
|
||||
WithStatusDesired([]metadata.AuthenticatorStatus{metadata.FidoCertified}),
|
||||
WithStatusUndesired(nil),
|
||||
},
|
||||
reports: []metadata.StatusReport{{Status: metadata.NotFidoCertified}},
|
||||
err: "The following desired status reports were absent: FIDO_CERTIFIED",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
provider, err := New(tc.opts...)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = provider.ValidateStatusReports(context.Background(), tc.reports)
|
||||
|
||||
if tc.err != "" {
|
||||
require.EqualError(t, err, tc.err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_GetEntry_NilMDS(t *testing.T) {
|
||||
p := &Provider{}
|
||||
|
||||
entry, err := p.GetEntry(context.Background(), uuid.New())
|
||||
assert.Nil(t, entry)
|
||||
require.EqualError(t, err, "metadata: not initialized")
|
||||
}
|
||||
Reference in New Issue
Block a user