auth: publish passkey foundations preview
verify / verify (push) Successful in 3m40s

This commit is contained in:
2026-08-21 17:33:00 -04:00
parent fb6bbd0dad
commit bfe6cfd29e
230 changed files with 44547 additions and 17 deletions
@@ -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
}