mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-08-24 21:17:31 +00:00
Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
369 lines
13 KiB
Go
369 lines
13 KiB
Go
package webauthn
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-webauthn/webauthn/protocol"
|
|
gowebauthn "github.com/go-webauthn/webauthn/webauthn"
|
|
"github.com/lestrrat-go/jwx/v3/jwt"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
|
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
|
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
|
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
|
)
|
|
|
|
// fakeSigner is an in-memory TokenService that mints opaque tokens carrying a subject,
|
|
// an issued-at time and an optional authentication method, without any real signing
|
|
type fakeSigner struct {
|
|
tokens map[string]jwt.Token
|
|
counter int
|
|
}
|
|
|
|
func newFakeSigner() *fakeSigner {
|
|
return &fakeSigner{tokens: map[string]jwt.Token{}}
|
|
}
|
|
|
|
func (s *fakeSigner) GenerateAccessToken(user model.User, authenticationMethod string, _ time.Duration) (string, error) {
|
|
builder := jwt.NewBuilder().
|
|
Subject(user.ID).
|
|
IssuedAt(time.Now())
|
|
if authenticationMethod != "" {
|
|
builder = builder.Claim(common.AuthenticationMethodsClaim, []string{authenticationMethod})
|
|
}
|
|
token, err := builder.Build()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
s.counter++
|
|
raw := fmt.Sprintf("fake-access-token-%d", s.counter)
|
|
s.tokens[raw] = token
|
|
return raw, nil
|
|
}
|
|
|
|
func (s *fakeSigner) VerifyAccessToken(tokenString string) (jwt.Token, error) {
|
|
token, ok := s.tokens[tokenString]
|
|
if !ok {
|
|
return nil, errors.New("invalid token")
|
|
}
|
|
return token, nil
|
|
}
|
|
|
|
func (s *fakeSigner) GetAuthenticationMethod(token jwt.Token) (string, error) {
|
|
if !token.Has(common.AuthenticationMethodsClaim) {
|
|
return "", nil
|
|
}
|
|
var methods []string
|
|
if err := token.Get(common.AuthenticationMethodsClaim, &methods); err != nil {
|
|
return "", err
|
|
}
|
|
if len(methods) == 0 {
|
|
return "", nil
|
|
}
|
|
return methods[0], nil
|
|
}
|
|
|
|
func TestCreateReauthenticationTokenWithAccessToken(t *testing.T) {
|
|
setupService := func(t *testing.T) (*Service, *fakeSigner, model.User) {
|
|
t.Helper()
|
|
|
|
db := testutils.NewDatabaseForTest(t)
|
|
user := model.User{
|
|
Base: model.Base{ID: "reauth-user"},
|
|
Username: "reauth-user",
|
|
}
|
|
require.NoError(t, db.Create(&user).Error)
|
|
|
|
signer := newFakeSigner()
|
|
return &Service{db: db, signer: signer}, signer, user
|
|
}
|
|
|
|
t.Run("accepts a fresh access token from WebAuthn login", func(t *testing.T) {
|
|
service, signer, user := setupService(t)
|
|
accessToken, err := signer.GenerateAccessToken(user, authenticationMethodPhishingResistant, time.Hour)
|
|
require.NoError(t, err)
|
|
|
|
reauthenticationToken, err := service.CreateReauthenticationTokenWithAccessToken(t.Context(), accessToken)
|
|
|
|
require.NoError(t, err)
|
|
assert.NotEmpty(t, reauthenticationToken)
|
|
})
|
|
|
|
t.Run("rejects a fresh access token from one-time access login", func(t *testing.T) {
|
|
service, signer, user := setupService(t)
|
|
accessToken, err := signer.GenerateAccessToken(user, "otp", time.Hour)
|
|
require.NoError(t, err)
|
|
|
|
reauthenticationToken, err := service.CreateReauthenticationTokenWithAccessToken(t.Context(), accessToken)
|
|
|
|
assert.Empty(t, reauthenticationToken)
|
|
require.Error(t, err)
|
|
assert.True(t, apperror.IsCode(err, apperror.CodeReauthenticationRequired))
|
|
})
|
|
|
|
t.Run("rejects a fresh access token without an authentication method", func(t *testing.T) {
|
|
service, signer, user := setupService(t)
|
|
accessToken, err := signer.GenerateAccessToken(user, "", time.Hour)
|
|
require.NoError(t, err)
|
|
|
|
reauthenticationToken, err := service.CreateReauthenticationTokenWithAccessToken(t.Context(), accessToken)
|
|
|
|
assert.Empty(t, reauthenticationToken)
|
|
require.Error(t, err)
|
|
assert.True(t, apperror.IsCode(err, apperror.CodeReauthenticationRequired))
|
|
})
|
|
|
|
t.Run("classifies an invalid access token as missing reauthentication", func(t *testing.T) {
|
|
service, _, _ := setupService(t)
|
|
|
|
reauthenticationToken, err := service.CreateReauthenticationTokenWithAccessToken(t.Context(), "invalid")
|
|
|
|
assert.Empty(t, reauthenticationToken)
|
|
require.True(t, apperror.IsCode(err, apperror.CodeReauthenticationRequired))
|
|
})
|
|
}
|
|
|
|
func TestWebAuthnDisplayNameUsesRequestConfig(t *testing.T) {
|
|
service, err := newService(Dependencies{
|
|
DB: testutils.NewDatabaseForTest(t),
|
|
AppURL: "https://example.com",
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, defaultRPDisplayName, service.webAuthn.Config.RPDisplayName)
|
|
|
|
service.updateWebAuthnConfig(&appconfig.AppConfigModel{AppName: "Custom App"})
|
|
require.Equal(t, "Custom App", service.webAuthn.Config.RPDisplayName)
|
|
}
|
|
|
|
func TestBeginCeremoniesUseRequestConfig(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
userVerification appconfig.AppConfigValue
|
|
authenticator appconfig.AppConfigValue
|
|
wantUserVerification protocol.UserVerificationRequirement
|
|
wantAuthenticator protocol.AuthenticatorAttachment
|
|
}{
|
|
{
|
|
name: "required verification with any authenticator",
|
|
userVerification: "required",
|
|
authenticator: "any",
|
|
wantUserVerification: protocol.VerificationRequired,
|
|
wantAuthenticator: "",
|
|
},
|
|
{
|
|
name: "required verification with a platform authenticator",
|
|
userVerification: "required",
|
|
authenticator: "platform",
|
|
wantUserVerification: protocol.VerificationRequired,
|
|
wantAuthenticator: protocol.Platform,
|
|
},
|
|
{
|
|
name: "preferred verification with a cross-platform authenticator",
|
|
userVerification: "preferred",
|
|
authenticator: "cross-platform",
|
|
wantUserVerification: protocol.VerificationPreferred,
|
|
wantAuthenticator: protocol.CrossPlatform,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
db := testutils.NewDatabaseForTest(t)
|
|
user := model.User{
|
|
Base: model.Base{ID: "configured-user"},
|
|
Username: "configured-user",
|
|
}
|
|
require.NoError(t, db.Create(&user).Error)
|
|
|
|
service, err := newService(Dependencies{
|
|
DB: db,
|
|
AppURL: "https://example.com",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
dbConfig := &appconfig.AppConfigModel{
|
|
AppName: "Configured App",
|
|
WebauthnUserVerification: tc.userVerification,
|
|
WebauthnAuthenticatorAttachment: tc.authenticator,
|
|
}
|
|
|
|
registration, err := service.BeginRegistration(t.Context(), dbConfig, user.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, tc.wantUserVerification, registration.Response.AuthenticatorSelection.UserVerification)
|
|
assert.Equal(t, tc.wantAuthenticator, registration.Response.AuthenticatorSelection.AuthenticatorAttachment)
|
|
assert.Equal(t, protocol.ResidentKeyRequirementRequired, registration.Response.AuthenticatorSelection.ResidentKey)
|
|
|
|
login, err := service.BeginLogin(t.Context(), dbConfig)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, tc.wantUserVerification, login.Response.UserVerification)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSyncedPasskeyPolicy(t *testing.T) {
|
|
credential := &gowebauthn.Credential{
|
|
Flags: gowebauthn.CredentialFlags{BackupEligible: true},
|
|
}
|
|
|
|
require.NoError(t, validateCredentialPolicy(&appconfig.AppConfigModel{WebauthnAllowSyncedPasskeys: "true"}, credential))
|
|
|
|
err := validateCredentialPolicy(&appconfig.AppConfigModel{WebauthnAllowSyncedPasskeys: "false"}, credential)
|
|
require.True(t, apperror.IsCode(err, apperror.CodeSyncedPasskeyNotAllowed))
|
|
|
|
credential.Flags.BackupEligible = false
|
|
require.NoError(t, validateCredentialPolicy(&appconfig.AppConfigModel{WebauthnAllowSyncedPasskeys: "false"}, credential))
|
|
}
|
|
|
|
func TestClassifyPasskeyErrorRecognizesMissingUserVerification(t *testing.T) {
|
|
rpIDHash := make([]byte, 32)
|
|
authenticatorData := protocol.AuthenticatorData{
|
|
RPIDHash: rpIDHash,
|
|
Flags: protocol.FlagUserPresent,
|
|
}
|
|
cause := authenticatorData.Verify(rpIDHash, nil, true, true)
|
|
require.Error(t, cause)
|
|
|
|
err := classifyPasskeyError(cause, apperror.WebAuthnAuthenticationFailed)
|
|
|
|
require.True(t, apperror.IsCode(err, apperror.CodePasskeyUserVerificationRequired))
|
|
require.ErrorIs(t, err, cause)
|
|
|
|
other := protocol.ErrVerification.WithInfo("RP Hash mismatch")
|
|
err = classifyPasskeyError(other, apperror.WebAuthnAuthenticationFailed)
|
|
require.True(t, apperror.IsCode(err, apperror.CodeWebAuthnAuthenticationFailed))
|
|
}
|
|
|
|
func TestClassifyPasskeyErrorPreservesStructuredLookupFailure(t *testing.T) {
|
|
// Wrap a structured database failure exactly as go-webauthn wraps callback errors
|
|
cause := errors.New("database unavailable")
|
|
lookupErr := protocol.ErrBadRequest.WithError(apperror.Internal(cause))
|
|
|
|
// Verify the structured failure and diagnostic cause both survive classification
|
|
err := classifyPasskeyError(lookupErr, apperror.WebAuthnAuthenticationFailed)
|
|
|
|
require.True(t, apperror.IsCode(err, apperror.CodeInternal))
|
|
require.ErrorIs(t, err, cause)
|
|
}
|
|
|
|
func TestWebAuthnManagementOperationsReturnSpecificNotFoundErrors(t *testing.T) {
|
|
service, err := newService(Dependencies{
|
|
DB: testutils.NewDatabaseForTest(t),
|
|
AppURL: "https://example.com",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
_, err = service.BeginRegistration(t.Context(), &appconfig.AppConfigModel{}, "missing-user")
|
|
require.True(t, apperror.IsCode(err, apperror.CodeUserNotFound))
|
|
|
|
_, err = service.UpdateCredential(t.Context(), "missing-user", "missing-passkey", "New name")
|
|
require.True(t, apperror.IsCode(err, apperror.CodeNotFound))
|
|
|
|
err = service.DeleteCredential(t.Context(), "missing-user", "missing-passkey", "", "", "")
|
|
require.True(t, apperror.IsCode(err, apperror.CodeNotFound))
|
|
}
|
|
|
|
// A ceremony that references a session which does not exist must be rejected outright
|
|
// The delete-and-return leaves the struct zero-valued when nothing matched, and a zero session has an
|
|
// empty user verification requirement, a zero expiry and an empty challenge, so letting it reach the
|
|
// library would validate the assertion with user verification and expiry enforcement silently disabled
|
|
func TestCeremoniesRejectSessionThatDoesNotExist(t *testing.T) {
|
|
const userID = "ceremony-user"
|
|
|
|
setupService := func(t *testing.T) *Service {
|
|
t.Helper()
|
|
|
|
db := testutils.NewDatabaseForTest(t)
|
|
require.NoError(t, db.Create(&model.User{
|
|
Base: model.Base{ID: userID},
|
|
Username: userID,
|
|
}).Error)
|
|
|
|
return &Service{db: db}
|
|
}
|
|
|
|
t.Run("registration rejects an unknown session", func(t *testing.T) {
|
|
service := setupService(t)
|
|
|
|
_, err := service.VerifyRegistration(t.Context(), &appconfig.AppConfigModel{}, "does-not-exist", userID, nil, "127.0.0.1")
|
|
|
|
require.Error(t, err)
|
|
assert.True(t, apperror.IsCode(err, apperror.CodeInvalidWebAuthnSession))
|
|
})
|
|
|
|
t.Run("login rejects an unknown session", func(t *testing.T) {
|
|
service := setupService(t)
|
|
|
|
_, token, err := service.VerifyLogin(t.Context(), &appconfig.AppConfigModel{}, "does-not-exist", nil, "127.0.0.1", "test-agent")
|
|
|
|
assert.Empty(t, token)
|
|
require.Error(t, err)
|
|
assert.True(t, apperror.IsCode(err, apperror.CodeInvalidWebAuthnSession))
|
|
})
|
|
|
|
t.Run("reauthentication rejects an unknown session", func(t *testing.T) {
|
|
service := setupService(t)
|
|
|
|
token, err := service.CreateReauthenticationTokenWithWebauthn(t.Context(), "does-not-exist", nil)
|
|
|
|
assert.Empty(t, token)
|
|
require.Error(t, err)
|
|
assert.True(t, apperror.IsCode(err, apperror.CodeInvalidWebAuthnSession))
|
|
})
|
|
|
|
// The reauthentication query filters expired rows out in SQL, so an expired session matches no row
|
|
// and previously produced the same zero-valued session as an unknown one
|
|
t.Run("reauthentication rejects an expired session", func(t *testing.T) {
|
|
service := setupService(t)
|
|
|
|
expiredSession := WebauthnSession{
|
|
Challenge: "expired-challenge",
|
|
ExpiresAt: datatype.DateTime(time.Now().Add(-time.Minute)),
|
|
UserVerification: string(protocol.VerificationRequired),
|
|
}
|
|
require.NoError(t, service.db.Create(&expiredSession).Error)
|
|
|
|
token, err := service.CreateReauthenticationTokenWithWebauthn(t.Context(), expiredSession.ID, nil)
|
|
|
|
assert.Empty(t, token)
|
|
require.Error(t, err)
|
|
assert.True(t, apperror.IsCode(err, apperror.CodeInvalidWebAuthnSession))
|
|
})
|
|
}
|
|
|
|
func TestConsumeReauthenticationTokenReturnsTokenCreationTime(t *testing.T) {
|
|
db := testutils.NewDatabaseForTest(t)
|
|
service := &Service{db: db}
|
|
|
|
const (
|
|
userID = "reauth-user"
|
|
token = "reauthentication-token"
|
|
)
|
|
require.NoError(t, db.Create(&model.User{
|
|
Base: model.Base{ID: userID},
|
|
}).Error)
|
|
require.NoError(t, db.Create(&ReauthenticationToken{
|
|
Token: utils.CreateSha256Hash(token),
|
|
ExpiresAt: datatype.DateTime(time.Now().Add(time.Minute)),
|
|
UserID: userID,
|
|
}).Error)
|
|
|
|
var storedToken ReauthenticationToken
|
|
require.NoError(t, db.First(&storedToken, "user_id = ?", userID).Error)
|
|
|
|
tx := db.Begin()
|
|
reauthenticatedAt, err := service.ConsumeReauthenticationToken(t.Context(), tx, token, userID)
|
|
require.NoError(t, err)
|
|
require.NoError(t, tx.Commit().Error)
|
|
|
|
require.Equal(t, storedToken.CreatedAt.UTC(), reauthenticatedAt)
|
|
}
|