Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
323 changes: 323 additions & 0 deletions internal/storetest/storetest.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,27 @@ package storetest
import (
"context"
"crypto/sha256"
"encoding/base64"
"sort"
"sync"
"testing"
"time"

"github.com/go-webauthn/webauthn/webauthn"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/meigma/authkit"
"github.com/meigma/authkit/apikey"
"github.com/meigma/authkit/oidc"
"github.com/meigma/authkit/passkey"
)

const (
concurrentProvisionAttempts = 8
listedTokenMetadataCount = 2
passkeyCredentialCount = 2
passkeyUpdatedSignCount = 9
secondTokenRevokeOffset = 2 * time.Hour
testAction = "notes:read"
testProvider = "oidc"
Expand Down Expand Up @@ -50,6 +55,7 @@ type Store interface {
apikey.TokenStore
apikey.TokenMetadataLister
oidc.ProviderTrustStore
passkey.Store
}

// Run runs the shared storage behavior suite against newStore.
Expand Down Expand Up @@ -757,6 +763,239 @@ func Run(t *testing.T, newStore func(t *testing.T) Store) {
}
})

t.Run("passkey registration creates user credential and link", func(t *testing.T) {
store := newStore(t)
principal := createPrincipal(t, store)
registration := passkeyRegistration(principal.ID, "credential-1")

result, err := store.CreateRegistration(context.Background(), registration)
require.NoError(t, err)
assert.Equal(t, registration.User, result.User)
assert.Equal(t, registration.Credential, result.Credential)
assert.Equal(t, authkit.ExternalIdentity{
Provider: registration.Identity.Provider,
Subject: registration.Identity.Subject,
PrincipalID: principal.ID,
}, result.Link)

foundByPrincipal, err := store.FindUserByPrincipal(
context.Background(),
registration.User.RPID,
principal.ID,
)
require.NoError(t, err)
assert.Equal(t, registration.User, foundByPrincipal)

foundByHandle, err := store.FindUserByHandle(
context.Background(),
registration.User.RPID,
registration.User.Handle,
)
require.NoError(t, err)
assert.Equal(t, registration.User, foundByHandle)

credentials, err := store.ListCredentials(
context.Background(),
registration.User.RPID,
registration.User.Handle,
)
require.NoError(t, err)
require.Len(t, credentials, 1)
assert.Equal(t, registration.Credential, credentials[0])

resolved, err := store.ResolveIdentity(context.Background(), registration.Identity)
require.NoError(t, err)
require.NotNil(t, resolved)
assert.Equal(t, principal.ID, resolved.ID)
})

t.Run("passkey missing user behavior", func(t *testing.T) {
store := newStore(t)

foundByPrincipal, err := store.FindUserByPrincipal(context.Background(), "example.test", "missing")
require.ErrorIs(t, err, passkey.ErrUserNotFound)
assert.Empty(t, foundByPrincipal)

foundByHandle, err := store.FindUserByHandle(context.Background(), "example.test", []byte("missing"))
require.ErrorIs(t, err, passkey.ErrUserNotFound)
assert.Empty(t, foundByHandle)

credentials, err := store.ListCredentials(context.Background(), "example.test", []byte("missing"))
require.NoError(t, err)
assert.Empty(t, credentials)
})

t.Run("passkey registration allows additional credential for same user", func(t *testing.T) {
store := newStore(t)
principal := createPrincipal(t, store)
first := passkeyRegistration(principal.ID, "credential-1")
second := passkeyRegistration(principal.ID, "credential-2")
second.Credential.CredentialID = []byte("credential-2")
second.Credential.WebAuthn.ID = []byte("credential-2")
second.Identity.CredentialID = passkeyCredentialSubject([]byte("credential-2"))

_, err := store.CreateRegistration(context.Background(), first)
require.NoError(t, err)
result, err := store.CreateRegistration(context.Background(), second)
require.NoError(t, err)
assert.Equal(t, first.Identity.Provider, result.Link.Provider)
assert.Equal(t, first.Identity.Subject, result.Link.Subject)
assert.Equal(t, principal.ID, result.Link.PrincipalID)

credentials, err := store.ListCredentials(
context.Background(),
first.User.RPID,
first.User.Handle,
)
require.NoError(t, err)
require.Len(t, credentials, passkeyCredentialCount)
assert.Equal(t, []byte("credential-1"), credentials[0].CredentialID)
assert.Equal(t, []byte("credential-2"), credentials[1].CredentialID)
})

t.Run("passkey registration rejects duplicate credential", func(t *testing.T) {
store := newStore(t)
principal := createPrincipal(t, store)
registration := passkeyRegistration(principal.ID, "credential-1")

_, err := store.CreateRegistration(context.Background(), registration)
require.NoError(t, err)
result, err := store.CreateRegistration(context.Background(), registration)

require.ErrorIs(t, err, passkey.ErrCredentialExists)
assert.Empty(t, result)
})

t.Run("passkey registration rejects conflicting users", func(t *testing.T) {
tests := []struct {
name string
mutate func(passkey.Registration, string) passkey.Registration
}{
{
name: "same principal different handle",
mutate: func(registration passkey.Registration, _ string) passkey.Registration {
registration.User.Handle = []byte("other-handle")
registration.Credential.UserHandle = []byte("other-handle")
registration.Identity.Subject = passkeyUserSubject(registration.User.Handle)
return registration
},
},
{
name: "same handle different principal",
mutate: func(registration passkey.Registration, otherPrincipalID string) passkey.Registration {
registration.User.PrincipalID = otherPrincipalID
registration.Credential.PrincipalID = otherPrincipalID
return registration
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
store := newStore(t)
principal := createPrincipal(t, store)
other := createPrincipal(t, store)
registration := passkeyRegistration(principal.ID, "credential-1")
_, err := store.CreateRegistration(context.Background(), registration)
require.NoError(t, err)

conflict := tt.mutate(passkeyRegistration(principal.ID, "credential-2"), other.ID)
result, err := store.CreateRegistration(context.Background(), conflict)

require.ErrorIs(t, err, passkey.ErrUserExists)
assert.Empty(t, result)
})
}
})

t.Run("passkey registration rolls back on identity link conflict", func(t *testing.T) {
store := newStore(t)
principal := createPrincipal(t, store)
other := createPrincipal(t, store)
registration := passkeyRegistration(principal.ID, "credential-1")
_, err := store.LinkIdentity(context.Background(), authkit.LinkIdentityRequest{
Provider: registration.Identity.Provider,
Subject: registration.Identity.Subject,
PrincipalID: other.ID,
})
require.NoError(t, err)

result, err := store.CreateRegistration(context.Background(), registration)
require.Error(t, err)
require.NotErrorIs(t, err, passkey.ErrUserExists)
require.NotErrorIs(t, err, passkey.ErrCredentialExists)
assert.Empty(t, result)

found, err := store.FindUserByPrincipal(context.Background(), registration.User.RPID, principal.ID)
require.ErrorIs(t, err, passkey.ErrUserNotFound)
assert.Empty(t, found)
credentials, err := store.ListCredentials(
context.Background(),
registration.User.RPID,
registration.User.Handle,
)
require.NoError(t, err)
assert.Empty(t, credentials)
})

t.Run("passkey credential update persists login metadata", func(t *testing.T) {
store := newStore(t)
principal := createPrincipal(t, store)
registration := passkeyRegistration(principal.ID, "credential-1")
_, err := store.CreateRegistration(context.Background(), registration)
require.NoError(t, err)

updated := registration.Credential
updated.WebAuthn.PublicKey = []byte("updated-public-key")
updated.WebAuthn.Authenticator.SignCount = passkeyUpdatedSignCount
require.NoError(t, store.UpdateCredentialAfterLogin(context.Background(), updated))

credentials, err := store.ListCredentials(
context.Background(),
registration.User.RPID,
registration.User.Handle,
)
require.NoError(t, err)
require.Len(t, credentials, 1)
assert.Equal(t, []byte("updated-public-key"), credentials[0].WebAuthn.PublicKey)
assert.Equal(t, uint32(passkeyUpdatedSignCount), credentials[0].WebAuthn.Authenticator.SignCount)
})

t.Run("passkey values are copied", func(t *testing.T) {
store := newStore(t)
principal := createPrincipal(t, store)
registration := passkeyRegistration(principal.ID, "credential-1")
wantUserHandle := append([]byte(nil), registration.User.Handle...)
wantPublicKey := append([]byte(nil), registration.Credential.WebAuthn.PublicKey...)

result, err := store.CreateRegistration(context.Background(), registration)
require.NoError(t, err)
registration.User.Handle[0] = 'x'
registration.Credential.WebAuthn.PublicKey[0] = 'x'
result.User.Handle[0] = 'y'
result.Credential.WebAuthn.PublicKey[0] = 'y'

found, err := store.FindUserByPrincipal(context.Background(), "example.test", principal.ID)
require.NoError(t, err)
assert.Equal(t, wantUserHandle, found.Handle)
found.Handle[0] = 'z'

foundAgain, err := store.FindUserByPrincipal(context.Background(), "example.test", principal.ID)
require.NoError(t, err)
assert.Equal(t, wantUserHandle, foundAgain.Handle)

credentials, err := store.ListCredentials(context.Background(), "example.test", wantUserHandle)
require.NoError(t, err)
require.Len(t, credentials, 1)
assert.Equal(t, wantPublicKey, credentials[0].WebAuthn.PublicKey)
credentials[0].WebAuthn.PublicKey[0] = 'z'

credentialsAgain, err := store.ListCredentials(context.Background(), "example.test", wantUserHandle)
require.NoError(t, err)
require.Len(t, credentialsAgain, 1)
assert.Equal(t, wantPublicKey, credentialsAgain[0].WebAuthn.PublicKey)
})

t.Run("provision identity creates principal and link", func(t *testing.T) {
store := newStore(t)
req := provisionRequest()
Expand Down Expand Up @@ -1056,6 +1295,9 @@ func Run(t *testing.T, newStore func(t *testing.T) Store) {
require.NoError(t, err)
token := tokenFixture(fixedStoreTime(), principal.ID)
require.NoError(t, store.CreateToken(context.Background(), token))
registration := passkeyRegistration(principal.ID, "credential-1")
_, err = store.CreateRegistration(context.Background(), registration)
require.NoError(t, err)

ctx, cancel := context.WithCancel(context.Background())
cancel()
Expand Down Expand Up @@ -1228,6 +1470,44 @@ func Run(t *testing.T, newStore func(t *testing.T) Store) {
return runErr
},
},
{
name: "find passkey user by principal",
run: func() error {
_, runErr := store.FindUserByPrincipal(ctx, registration.User.RPID, principal.ID)

return runErr
},
},
{
name: "find passkey user by handle",
run: func() error {
_, runErr := store.FindUserByHandle(ctx, registration.User.RPID, registration.User.Handle)

return runErr
},
},
{
name: "list passkey credentials",
run: func() error {
_, runErr := store.ListCredentials(ctx, registration.User.RPID, registration.User.Handle)

return runErr
},
},
{
name: "create passkey registration",
run: func() error {
_, runErr := store.CreateRegistration(ctx, passkeyRegistration(principal.ID, "credential-2"))

return runErr
},
},
{
name: "update passkey credential after login",
run: func() error {
return store.UpdateCredentialAfterLogin(ctx, registration.Credential)
},
},
}

for _, tt := range tests {
Expand Down Expand Up @@ -1492,6 +1772,49 @@ func tokenFixture(now time.Time, principalID string) apikey.StoredToken {
}
}

func passkeyRegistration(principalID string, credentialID string) passkey.Registration {
userHandle := []byte("passkey-user-handle-1")
credentialIDBytes := []byte(credentialID)
user := passkey.User{
RPID: "example.test",
PrincipalID: principalID,
Handle: userHandle,
Name: "ada@example.test",
DisplayName: testDisplayName,
}

return passkey.Registration{
User: user,
Credential: passkey.Credential{
RPID: user.RPID,
PrincipalID: user.PrincipalID,
UserHandle: append([]byte(nil), userHandle...),
CredentialID: credentialIDBytes,
WebAuthn: webauthn.Credential{
ID: append([]byte(nil), credentialIDBytes...),
PublicKey: []byte("public-key-" + credentialID),
Authenticator: webauthn.Authenticator{
AAGUID: []byte("authenticator-aaguid"),
SignCount: 1,
},
},
},
Identity: authkit.Identity{
Provider: "passkey:" + user.RPID,
Subject: passkeyUserSubject(userHandle),
CredentialID: passkeyCredentialSubject(credentialIDBytes),
},
}
}

func passkeyUserSubject(handle []byte) string {
return base64.RawURLEncoding.EncodeToString(handle)
}

func passkeyCredentialSubject(credentialID []byte) string {
return base64.RawURLEncoding.EncodeToString(credentialID)
}

func fixedStoreTime() time.Time {
return time.Date(2026, time.May, 7, 18, 0, 0, 0, time.UTC)
}
Loading