+ Bare-bones HTML to exercise the passkey plugin end-to-end. Use Chrome
+ DevTools → WebAuthn panel to enable a virtual authenticator so
+ no physical key is required.
+
+
+
1. Create an account or sign in
+
Passkey registration requires an authenticated session. Use email + password to get one.
+
+
+
+
+
+
+
+
+
2. Register a passkey
+
+
+
+
+
3. Sign in with a passkey
+
+
+
+
+
4. Manage passkeys
+
+
+
+
+
+
diff --git a/examples/passkey/main.go b/examples/passkey/main.go
new file mode 100644
index 0000000..915b350
--- /dev/null
+++ b/examples/passkey/main.go
@@ -0,0 +1,63 @@
+package main
+
+import (
+ "database/sql"
+ "embed"
+ "log"
+ "net/http"
+ "os"
+
+ _ "github.com/lib/pq"
+
+ "github.com/thecodearcher/limen"
+ sqladapter "github.com/thecodearcher/limen/adapters/sql"
+ credentialpassword "github.com/thecodearcher/limen/plugins/credential-password"
+ "github.com/thecodearcher/limen/plugins/passkey"
+)
+
+//go:embed index.html
+var staticFS embed.FS
+
+func main() {
+ dsn := os.Getenv("DATABASE_URL")
+ if dsn == "" {
+ log.Fatal("set DATABASE_URL")
+ }
+
+ db, err := sql.Open("postgres", dsn)
+ if err != nil {
+ log.Fatal(err)
+ }
+ defer db.Close()
+
+ auth, err := limen.New(&limen.Config{
+ BaseURL: "http://localhost:8080",
+ Database: sqladapter.NewPostgreSQL(db),
+ Secret: []byte("0123456789abcdef0123456789abcdef"),
+ HTTP: limen.NewDefaultHTTPConfig(limen.WithHTTPBasePath("/api/auth")),
+ CLI: &limen.CLIConfig{Enabled: true},
+ Plugins: []limen.Plugin{
+ // We pair passkey with credential-password so testers can
+ // create an account first, then register a passkey against
+ // that account.
+ credentialpassword.New(),
+ passkey.New(
+ passkey.WithRPID("localhost"),
+ passkey.WithOrigins("http://localhost:8080"),
+ passkey.WithRPName("Limen Passkey Demo"),
+ ),
+ },
+ })
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ mux := http.NewServeMux()
+ mux.Handle("/api/auth/", auth.Handler())
+ mux.Handle("/", http.FileServer(http.FS(staticFS)))
+
+ log.Println("passkey example listening on http://localhost:8080")
+ log.Println(" open http://localhost:8080 in Chrome and use the virtual authenticator")
+ log.Println(" (DevTools → ... → More tools → WebAuthn → Enable virtual authenticator)")
+ log.Fatal(http.ListenAndServe(":8080", mux))
+}
diff --git a/plugins/passkey/api.go b/plugins/passkey/api.go
new file mode 100644
index 0000000..afb5d53
--- /dev/null
+++ b/plugins/passkey/api.go
@@ -0,0 +1,29 @@
+package passkey
+
+import (
+ "context"
+ "io"
+ "net/http"
+
+ "github.com/go-webauthn/webauthn/protocol"
+
+ "github.com/thecodearcher/limen"
+)
+
+// API is the public interface for the passkey plugin. Obtain a
+// type-safe reference via Use().
+type API interface {
+ BeginRegistration(ctx context.Context, r *http.Request, w http.ResponseWriter, nameHint string) (*protocol.CredentialCreation, error)
+ FinishRegistration(ctx context.Context, r *http.Request, body io.Reader) (*Passkey, error)
+ BeginAuthentication(ctx context.Context, w http.ResponseWriter) (*protocol.CredentialAssertion, error)
+ FinishAuthentication(ctx context.Context, r *http.Request, body io.Reader) (*limen.AuthenticationResult, error)
+ ListPasskeys(ctx context.Context, userID any) ([]Passkey, error)
+ DeletePasskey(ctx context.Context, userID, passkeyID any) error
+ UpdatePasskey(ctx context.Context, userID, passkeyID any, newName string) (*Passkey, error)
+}
+
+// Use returns the passkey plugin's API from a Limen instance. Panics
+// if the plugin was not registered in Config.Plugins.
+func Use(a *limen.Limen) API {
+ return limen.Use[API](a, limen.PluginPasskey)
+}
diff --git a/plugins/passkey/authentication.go b/plugins/passkey/authentication.go
new file mode 100644
index 0000000..b14756e
--- /dev/null
+++ b/plugins/passkey/authentication.go
@@ -0,0 +1,132 @@
+package passkey
+
+import (
+ "context"
+ "encoding/base64"
+ "errors"
+ "io"
+ "net/http"
+
+ "github.com/go-webauthn/webauthn/protocol"
+ "github.com/go-webauthn/webauthn/webauthn"
+
+ "github.com/thecodearcher/limen"
+)
+
+// BeginAuthentication kicks off a passkey sign-in. We use the
+// "discoverable credential" flow: no user identifier is required up
+// front; the client's authenticator presents a credential picker and
+// the server resolves the user from the returned credential ID. This
+// is what every modern passkey UX expects.
+func (p *passkeyPlugin) BeginAuthentication(ctx context.Context, w http.ResponseWriter) (*protocol.CredentialAssertion, error) {
+ options, sessionData, err := p.webauthn.BeginDiscoverableLogin()
+ if err != nil {
+ return nil, err
+ }
+
+ if err := p.storeChallenge(ctx, w, &challengeState{
+ Ceremony: CeremonyAuthentication,
+ Session: *sessionData,
+ }); err != nil {
+ return nil, err
+ }
+
+ return options, nil
+}
+
+// FinishAuthentication validates the assertion returned by the
+// client's authenticator, looks up the registered credential, updates
+// its sign counter, and returns the authenticated user.
+func (p *passkeyPlugin) FinishAuthentication(ctx context.Context, r *http.Request, body io.Reader) (*limen.AuthenticationResult, error) {
+ state, err := p.consumeChallenge(ctx, r)
+ if err != nil {
+ return nil, err
+ }
+ if state.Ceremony != CeremonyAuthentication {
+ return nil, ErrChallengeMismatch
+ }
+
+ parsed, err := protocol.ParseCredentialRequestResponseBody(body)
+ if err != nil {
+ return nil, ErrInvalidResponse
+ }
+
+ // Discoverable login means the authenticator picked which credential
+ // to present; we resolve the user via the handler the library calls
+ // with the rawID + userHandle from the assertion.
+ var resolvedOwner *limen.User
+ var resolvedRecord *PasskeyRecord
+ handler := func(rawID, userHandle []byte) (webauthn.User, error) {
+ credIDB64 := base64.RawURLEncoding.EncodeToString(rawID)
+ rec, err := p.findByCredentialID(ctx, credIDB64)
+ if err != nil {
+ return nil, err
+ }
+ owner, err := p.findUserByID(ctx, rec.UserID)
+ if err != nil {
+ return nil, err
+ }
+ waUser, err := p.toWebAuthnUser(owner, []*PasskeyRecord{rec})
+ if err != nil {
+ return nil, err
+ }
+ resolvedOwner = owner
+ resolvedRecord = rec
+ return waUser, nil
+ }
+
+ credential, err := p.webauthn.ValidateDiscoverableLogin(handler, state.Session, parsed)
+ if err != nil {
+ if errors.Is(err, limen.ErrRecordNotFound) {
+ return nil, ErrPasskeyNotFound
+ }
+ return nil, ErrAuthenticationFailed
+ }
+ if resolvedOwner == nil || resolvedRecord == nil {
+ return nil, ErrAuthenticationFailed
+ }
+ rec := resolvedRecord
+ owner := resolvedOwner
+
+ // Update sign counter / backup state on every successful auth.
+ rec.Counter = int64(credential.Authenticator.SignCount)
+ rec.BackedUp = credential.Flags.BackupState
+ if err := p.core.Update(ctx, p.passkeySchema, rec, []limen.Where{
+ limen.Eq(p.passkeySchema.GetIDField(), rec.ID),
+ }); err != nil {
+ return nil, err
+ }
+
+ return &limen.AuthenticationResult{User: owner}, nil
+}
+
+func (p *passkeyPlugin) findUserByID(ctx context.Context, id any) (*limen.User, error) {
+ res, err := p.core.FindOne(ctx, p.userSchema, []limen.Where{
+ limen.Eq(p.userSchema.GetIDField(), id),
+ }, nil)
+ if err != nil {
+ return nil, err
+ }
+ return res.(*limen.User), nil
+}
+
+// discoverableHandler matches the signature used by ValidateDiscoverable
+// in newer go-webauthn versions; we keep it for completeness even
+// though the discoverable login path used here resolves the user via
+// FinishAuthentication's explicit credential lookup.
+//
+//nolint:unused // kept for future use when go-webauthn formalizes the API
+func (p *passkeyPlugin) discoverableHandler(ctx context.Context) webauthn.DiscoverableUserHandler {
+ return func(rawID, userHandle []byte) (webauthn.User, error) {
+ credIDB64 := base64.RawURLEncoding.EncodeToString(rawID)
+ rec, err := p.findByCredentialID(ctx, credIDB64)
+ if err != nil {
+ return nil, err
+ }
+ owner, err := p.findUserByID(ctx, rec.UserID)
+ if err != nil {
+ return nil, err
+ }
+ return p.toWebAuthnUser(owner, []*PasskeyRecord{rec})
+ }
+}
diff --git a/plugins/passkey/constants.go b/plugins/passkey/constants.go
new file mode 100644
index 0000000..a423ef2
--- /dev/null
+++ b/plugins/passkey/constants.go
@@ -0,0 +1,44 @@
+package passkey
+
+import (
+ "time"
+
+ "github.com/thecodearcher/limen"
+)
+
+const (
+ // PasskeySchemaTableName is the physical table name used for stored credentials.
+ PasskeySchemaTableName limen.SchemaTableName = "passkeys"
+
+ PasskeySchemaUserIDField limen.SchemaField = "user_id"
+ PasskeySchemaNameField limen.SchemaField = "name"
+ PasskeySchemaCredentialIDField limen.SchemaField = "credential_id"
+ PasskeySchemaPublicKeyField limen.SchemaField = "public_key"
+ PasskeySchemaCounterField limen.SchemaField = "counter"
+ PasskeySchemaDeviceTypeField limen.SchemaField = "device_type"
+ PasskeySchemaBackedUpField limen.SchemaField = "backed_up"
+ PasskeySchemaTransportsField limen.SchemaField = "transports"
+ PasskeySchemaAAGUIDField limen.SchemaField = "aaguid"
+)
+
+const (
+ // passkeyChallengeAction is the verification action used to persist a
+ // challenge while the client completes a WebAuthn ceremony.
+ passkeyChallengeAction = "passkey_challenge" // #nosec G101 -- action name, not a secret
+
+ defaultRPName = "Limen App"
+ defaultChallengeCookieName = "limen_passkey_challenge"
+ defaultChallengeExpiration = 5 * time.Minute
+ defaultUserHandleByteLength = 32
+ verificationTokenByteLength = 32
+)
+
+// CeremonyKind names the two WebAuthn ceremonies. It is persisted in the
+// challenge state so a registration cookie cannot be replayed against an
+// authentication endpoint, or vice versa.
+type CeremonyKind string
+
+const (
+ CeremonyRegistration CeremonyKind = "registration"
+ CeremonyAuthentication CeremonyKind = "authentication"
+)
diff --git a/plugins/passkey/defaults.go b/plugins/passkey/defaults.go
new file mode 100644
index 0000000..97154cc
--- /dev/null
+++ b/plugins/passkey/defaults.go
@@ -0,0 +1,17 @@
+package passkey
+
+import "github.com/go-webauthn/webauthn/protocol"
+
+// authSelectionOrDefault returns the configured AuthenticatorSelection
+// criteria or a sensible default that mirrors better-auth: prefer a
+// resident (passkey-capable) key and prefer user verification, but
+// don't make either mandatory unless the caller opts in.
+func authSelectionOrDefault(sel *protocol.AuthenticatorSelection) protocol.AuthenticatorSelection {
+ if sel != nil {
+ return *sel
+ }
+ return protocol.AuthenticatorSelection{
+ ResidentKey: protocol.ResidentKeyRequirementPreferred,
+ UserVerification: protocol.VerificationPreferred,
+ }
+}
diff --git a/plugins/passkey/errors.go b/plugins/passkey/errors.go
new file mode 100644
index 0000000..4b20753
--- /dev/null
+++ b/plugins/passkey/errors.go
@@ -0,0 +1,23 @@
+package passkey
+
+import (
+ "net/http"
+
+ "github.com/thecodearcher/limen"
+)
+
+var (
+ ErrSessionRequired = limen.NewLimenError("passkey registration requires an authenticated session", http.StatusUnauthorized, nil)
+ ErrChallengeNotFound = limen.NewLimenError("passkey challenge not found", http.StatusBadRequest, nil)
+ ErrChallengeMismatch = limen.NewLimenError("passkey challenge does not match", http.StatusBadRequest, nil)
+ ErrInvalidResponse = limen.NewLimenError("invalid passkey response", http.StatusBadRequest, nil)
+ ErrRegistrationFailed = limen.NewLimenError("failed to verify passkey registration", http.StatusBadRequest, nil)
+ ErrAuthenticationFailed = limen.NewLimenError("passkey authentication failed", http.StatusUnauthorized, nil)
+ ErrPasskeyNotFound = limen.NewLimenError("passkey not found", http.StatusNotFound, nil)
+ ErrPasskeyAlreadyExists = limen.NewLimenError("passkey already registered", http.StatusConflict, nil)
+ ErrOriginRequired = limen.NewLimenError("at least one allowed origin is required", http.StatusInternalServerError, nil)
+ ErrIDRequired = limen.NewLimenError("id is required", http.StatusUnprocessableEntity, nil)
+ ErrNameRequired = limen.NewLimenError("name is required", http.StatusUnprocessableEntity, nil)
+ ErrForbidden = limen.NewLimenError("you are not allowed to perform this action on this passkey", http.StatusForbidden, nil)
+ ErrUnauthorized = limen.NewLimenError("authentication required", http.StatusUnauthorized, nil)
+)
diff --git a/plugins/passkey/go.mod b/plugins/passkey/go.mod
new file mode 100644
index 0000000..b7c2df2
--- /dev/null
+++ b/plugins/passkey/go.mod
@@ -0,0 +1,27 @@
+module github.com/thecodearcher/limen/plugins/passkey
+
+go 1.25.0
+
+require (
+ github.com/go-webauthn/webauthn v0.13.4
+ github.com/stretchr/testify v1.11.1
+ github.com/thecodearcher/limen v0.1.1
+)
+
+require (
+ github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/fxamacker/cbor/v2 v2.9.0 // indirect
+ github.com/go-webauthn/x v0.1.23 // indirect
+ github.com/golang-jwt/jwt/v5 v5.2.3 // indirect
+ github.com/google/go-tpm v0.9.5 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/kr/text v0.2.0 // indirect
+ github.com/mitchellh/mapstructure v1.5.0 // indirect
+ github.com/pmezard/go-difflib v1.0.0 // indirect
+ github.com/x448/float16 v0.8.4 // indirect
+ golang.org/x/crypto v0.48.0 // indirect
+ golang.org/x/sys v0.41.0 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+)
+
+replace github.com/thecodearcher/limen => ../..
diff --git a/plugins/passkey/go.sum b/plugins/passkey/go.sum
new file mode 100644
index 0000000..dc20349
--- /dev/null
+++ b/plugins/passkey/go.sum
@@ -0,0 +1,38 @@
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
+github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
+github.com/go-webauthn/webauthn v0.13.4 h1:q68qusWPcqHbg9STSxBLBHnsKaLxNO0RnVKaAqMuAuQ=
+github.com/go-webauthn/webauthn v0.13.4/go.mod h1:MglN6OH9ECxvhDqoq1wMoF6P6JRYDiQpC9nc5OomQmI=
+github.com/go-webauthn/x v0.1.23 h1:9lEO0s+g8iTyz5Vszlg/rXTGrx3CjcD0RZQ1GPZCaxI=
+github.com/go-webauthn/x v0.1.23/go.mod h1:AJd3hI7NfEp/4fI6T4CHD753u91l510lglU7/NMN6+E=
+github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0=
+github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
+github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU=
+github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
+github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
+github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
+github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
+golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
+golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
+golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
+golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/plugins/passkey/handlers.go b/plugins/passkey/handlers.go
new file mode 100644
index 0000000..bfe6d5b
--- /dev/null
+++ b/plugins/passkey/handlers.go
@@ -0,0 +1,128 @@
+package passkey
+
+import (
+ "net/http"
+
+ "github.com/thecodearcher/limen"
+)
+
+type passkeyHandlers struct {
+ plugin *passkeyPlugin
+ httpCore *limen.LimenHTTPCore
+ responder *limen.Responder
+}
+
+func newPasskeyHandlers(plugin *passkeyPlugin, httpCore *limen.LimenHTTPCore) *passkeyHandlers {
+ return &passkeyHandlers{
+ plugin: plugin,
+ httpCore: httpCore,
+ responder: httpCore.Responder,
+ }
+}
+
+func (h *passkeyHandlers) GenerateRegistrationOptions(w http.ResponseWriter, r *http.Request) {
+ name := r.URL.Query().Get("name")
+ options, err := h.plugin.BeginRegistration(r.Context(), r, w, name)
+ if err != nil {
+ h.responder.Error(w, r, err)
+ return
+ }
+ h.responder.JSON(w, r, http.StatusOK, options)
+}
+
+func (h *passkeyHandlers) VerifyRegistration(w http.ResponseWriter, r *http.Request) {
+ if r.Body == nil {
+ h.responder.Error(w, r, ErrInvalidResponse)
+ return
+ }
+ defer r.Body.Close()
+ passkey, err := h.plugin.FinishRegistration(r.Context(), r, r.Body)
+ if err != nil {
+ h.responder.Error(w, r, err)
+ return
+ }
+ h.responder.JSON(w, r, http.StatusOK, passkey)
+}
+
+func (h *passkeyHandlers) GenerateAuthenticationOptions(w http.ResponseWriter, r *http.Request) {
+ options, err := h.plugin.BeginAuthentication(r.Context(), w)
+ if err != nil {
+ h.responder.Error(w, r, err)
+ return
+ }
+ h.responder.JSON(w, r, http.StatusOK, options)
+}
+
+func (h *passkeyHandlers) VerifyAuthentication(w http.ResponseWriter, r *http.Request) {
+ if r.Body == nil {
+ h.responder.Error(w, r, ErrInvalidResponse)
+ return
+ }
+ defer r.Body.Close()
+ result, err := h.plugin.FinishAuthentication(r.Context(), r, r.Body)
+ if err != nil {
+ h.responder.Error(w, r, err)
+ return
+ }
+ sessionResult, err := h.plugin.core.CreateSession(r.Context(), r, w, result)
+ if err != nil {
+ h.responder.Error(w, r, err)
+ return
+ }
+ h.responder.SessionResponse(w, r, h.plugin.core, result, sessionResult)
+}
+
+func (h *passkeyHandlers) ListPasskeys(w http.ResponseWriter, r *http.Request) {
+ session, err := requireValidatedSession(r)
+ if err != nil {
+ h.responder.Error(w, r, err)
+ return
+ }
+ passkeys, err := h.plugin.ListPasskeys(r.Context(), session.User.ID)
+ if err != nil {
+ h.responder.Error(w, r, err)
+ return
+ }
+ h.responder.JSON(w, r, http.StatusOK, passkeys)
+}
+
+func (h *passkeyHandlers) DeletePasskey(w http.ResponseWriter, r *http.Request) {
+ session, err := requireValidatedSession(r)
+ if err != nil {
+ h.responder.Error(w, r, err)
+ return
+ }
+ body := limen.ValidateJSON(w, r, h.responder, func(v *limen.Validator, data map[string]any) *limen.Validator {
+ return v.RequiredString("id", data["id"])
+ })
+ if body == nil {
+ return
+ }
+ if err := h.plugin.DeletePasskey(r.Context(), session.User.ID, body["id"]); err != nil {
+ h.responder.Error(w, r, err)
+ return
+ }
+ h.responder.JSON(w, r, http.StatusOK, map[string]any{"success": true})
+}
+
+func (h *passkeyHandlers) UpdatePasskey(w http.ResponseWriter, r *http.Request) {
+ session, err := requireValidatedSession(r)
+ if err != nil {
+ h.responder.Error(w, r, err)
+ return
+ }
+ body := limen.ValidateJSON(w, r, h.responder, func(v *limen.Validator, data map[string]any) *limen.Validator {
+ return v.
+ RequiredString("id", data["id"]).
+ RequiredString("name", data["name"])
+ })
+ if body == nil {
+ return
+ }
+ updated, err := h.plugin.UpdatePasskey(r.Context(), session.User.ID, body["id"], body["name"].(string))
+ if err != nil {
+ h.responder.Error(w, r, err)
+ return
+ }
+ h.responder.JSON(w, r, http.StatusOK, updated)
+}
diff --git a/plugins/passkey/management.go b/plugins/passkey/management.go
new file mode 100644
index 0000000..f13c393
--- /dev/null
+++ b/plugins/passkey/management.go
@@ -0,0 +1,89 @@
+package passkey
+
+import (
+ "context"
+ "errors"
+ "time"
+
+ "github.com/thecodearcher/limen"
+)
+
+// ListPasskeys returns every credential registered for the given user,
+// newest first.
+func (p *passkeyPlugin) ListPasskeys(ctx context.Context, userID any) ([]Passkey, error) {
+ recs, err := p.listForUser(ctx, userID)
+ if err != nil {
+ return nil, err
+ }
+ out := make([]Passkey, 0, len(recs))
+ for _, rec := range recs {
+ out = append(out, publicPasskey(rec))
+ }
+ return out, nil
+}
+
+// DeletePasskey removes a single credential. The caller must own the
+// credential; cross-user deletion attempts return ErrForbidden so we
+// don't leak the existence of credentials registered to other users.
+func (p *passkeyPlugin) DeletePasskey(ctx context.Context, userID, passkeyID any) error {
+ rec, err := p.findByID(ctx, passkeyID)
+ if err != nil {
+ if errors.Is(err, limen.ErrRecordNotFound) {
+ return ErrPasskeyNotFound
+ }
+ return err
+ }
+ if !sameID(rec.UserID, userID) {
+ return ErrForbidden
+ }
+ return p.core.Delete(ctx, p.passkeySchema, []limen.Where{
+ limen.Eq(p.passkeySchema.GetIDField(), rec.ID),
+ })
+}
+
+// UpdatePasskey renames a credential. Ownership is enforced the same
+// way as DeletePasskey.
+func (p *passkeyPlugin) UpdatePasskey(ctx context.Context, userID, passkeyID any, newName string) (*Passkey, error) {
+ rec, err := p.findByID(ctx, passkeyID)
+ if err != nil {
+ if errors.Is(err, limen.ErrRecordNotFound) {
+ return nil, ErrPasskeyNotFound
+ }
+ return nil, err
+ }
+ if !sameID(rec.UserID, userID) {
+ return nil, ErrForbidden
+ }
+
+ rec.Name = newName
+ rec.UpdatedAt = time.Now()
+ if err := p.core.Update(ctx, p.passkeySchema, rec, []limen.Where{
+ limen.Eq(p.passkeySchema.GetIDField(), rec.ID),
+ }); err != nil {
+ return nil, err
+ }
+
+ refreshed, err := p.findByID(ctx, rec.ID)
+ if err != nil {
+ return nil, err
+ }
+ out := publicPasskey(refreshed)
+ return &out, nil
+}
+
+// publicPasskey converts a storage record into the caller-facing shape
+// returned by every passkey endpoint.
+func publicPasskey(rec *PasskeyRecord) Passkey {
+ return Passkey{
+ ID: rec.ID,
+ UserID: rec.UserID,
+ Name: rec.Name,
+ CredentialID: rec.CredentialIDBase64,
+ DeviceType: rec.DeviceType,
+ BackedUp: rec.BackedUp,
+ Transports: rec.Transports,
+ AAGUID: rec.AAGUID,
+ CreatedAt: rec.CreatedAt,
+ UpdatedAt: rec.UpdatedAt,
+ }
+}
diff --git a/plugins/passkey/management_test.go b/plugins/passkey/management_test.go
new file mode 100644
index 0000000..eb54183
--- /dev/null
+++ b/plugins/passkey/management_test.go
@@ -0,0 +1,120 @@
+package passkey
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/thecodearcher/limen"
+)
+
+// seedPasskey directly inserts a row into the passkeys table so tests
+// can exercise List/Delete/Update without going through the WebAuthn
+// registration ceremony (which needs a real browser).
+func seedPasskey(t *testing.T, plugin *passkeyPlugin, userID any, credID, name string) *PasskeyRecord {
+ t.Helper()
+ now := time.Now()
+ rec := &PasskeyRecord{
+ UserID: userID,
+ Name: name,
+ CredentialIDBase64: credID,
+ PublicKeyBase64: "AAAA",
+ Counter: 0,
+ DeviceType: "multiDevice",
+ BackedUp: true,
+ Transports: "internal",
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ require.NoError(t, plugin.core.Create(context.Background(), plugin.passkeySchema, rec, nil))
+ stored, err := plugin.findByCredentialID(context.Background(), credID)
+ require.NoError(t, err)
+ return stored
+}
+
+func TestListPasskeys_ReturnsCallerCredentialsOnly(t *testing.T) {
+ t.Parallel()
+
+ l, plugin := newTestLimenAndPlugin(t)
+ alice := limen.SeedTestUser(t, l, "alice@test.com")
+ bob := limen.SeedTestUser(t, l, "bob@test.com")
+
+ seedPasskey(t, plugin, alice.ID, "cred-a-1", "Alice MacBook")
+ seedPasskey(t, plugin, alice.ID, "cred-a-2", "Alice iPhone")
+ seedPasskey(t, plugin, bob.ID, "cred-b-1", "Bob Laptop")
+
+ got, err := plugin.ListPasskeys(context.Background(), alice.ID)
+ require.NoError(t, err)
+ assert.Len(t, got, 2)
+ for _, pk := range got {
+ assert.Equal(t, alice.ID, pk.UserID)
+ }
+}
+
+func TestDeletePasskey_OwnerCanDelete(t *testing.T) {
+ t.Parallel()
+
+ l, plugin := newTestLimenAndPlugin(t)
+ alice := limen.SeedTestUser(t, l, "owner@test.com")
+ rec := seedPasskey(t, plugin, alice.ID, "cred-owned", "Owned")
+
+ require.NoError(t, plugin.DeletePasskey(context.Background(), alice.ID, rec.ID))
+
+ list, err := plugin.ListPasskeys(context.Background(), alice.ID)
+ require.NoError(t, err)
+ assert.Empty(t, list)
+}
+
+func TestDeletePasskey_NonOwnerForbidden(t *testing.T) {
+ t.Parallel()
+
+ l, plugin := newTestLimenAndPlugin(t)
+ alice := limen.SeedTestUser(t, l, "owner@test.com")
+ bob := limen.SeedTestUser(t, l, "intruder@test.com")
+ rec := seedPasskey(t, plugin, alice.ID, "cred-protected", "Alice key")
+
+ err := plugin.DeletePasskey(context.Background(), bob.ID, rec.ID)
+ assert.ErrorIs(t, err, ErrForbidden)
+
+ // Row must still exist.
+ list, err := plugin.ListPasskeys(context.Background(), alice.ID)
+ require.NoError(t, err)
+ assert.Len(t, list, 1)
+}
+
+func TestDeletePasskey_NotFound(t *testing.T) {
+ t.Parallel()
+
+ l, plugin := newTestLimenAndPlugin(t)
+ alice := limen.SeedTestUser(t, l, "u@test.com")
+
+ err := plugin.DeletePasskey(context.Background(), alice.ID, 999999)
+ assert.ErrorIs(t, err, ErrPasskeyNotFound)
+}
+
+func TestUpdatePasskey_RenamesOwnedCredential(t *testing.T) {
+ t.Parallel()
+
+ l, plugin := newTestLimenAndPlugin(t)
+ alice := limen.SeedTestUser(t, l, "rename@test.com")
+ rec := seedPasskey(t, plugin, alice.ID, "cred-rename", "Old name")
+
+ updated, err := plugin.UpdatePasskey(context.Background(), alice.ID, rec.ID, "Brand new name")
+ require.NoError(t, err)
+ assert.Equal(t, "Brand new name", updated.Name)
+}
+
+func TestUpdatePasskey_NonOwnerForbidden(t *testing.T) {
+ t.Parallel()
+
+ l, plugin := newTestLimenAndPlugin(t)
+ alice := limen.SeedTestUser(t, l, "owner@test.com")
+ bob := limen.SeedTestUser(t, l, "intruder@test.com")
+ rec := seedPasskey(t, plugin, alice.ID, "cred-protected2", "Original")
+
+ _, err := plugin.UpdatePasskey(context.Background(), bob.ID, rec.ID, "Hijacked")
+ assert.ErrorIs(t, err, ErrForbidden)
+}
diff --git a/plugins/passkey/plugin.go b/plugins/passkey/plugin.go
new file mode 100644
index 0000000..128ef73
--- /dev/null
+++ b/plugins/passkey/plugin.go
@@ -0,0 +1,180 @@
+// Package passkey provides WebAuthn / passkey authentication for Limen.
+//
+// The plugin implements the four ceremonies required for a complete
+// passkey experience plus credential management:
+//
+// GET /passkey/generate-register-options - kick off a registration
+// POST /passkey/verify-registration - finish registration
+// GET /passkey/generate-authenticate-options - kick off an authentication
+// POST /passkey/verify-authentication - finish authentication, sets session
+// GET /passkey/list - list the current user's passkeys
+// POST /passkey/delete - delete a passkey by id
+// POST /passkey/update - rename a passkey
+//
+// It is modelled after better-auth's passkey plugin and uses
+// github.com/go-webauthn/webauthn for the protocol-level work.
+package passkey
+
+import (
+ "fmt"
+ "net/url"
+ "time"
+
+ "github.com/go-webauthn/webauthn/webauthn"
+
+ "github.com/thecodearcher/limen"
+)
+
+type passkeyPlugin struct {
+ core *limen.LimenCore
+ config *config
+ userSchema *limen.UserSchema
+ passkeySchema *passkeySchema
+ verifSchema *limen.VerificationSchema
+ dbAction *limen.DatabaseActionHelper
+ webauthn *webauthn.WebAuthn
+}
+
+// New returns a passkey plugin configured with sensible defaults. At
+// least one of RPID or origins should be set in production; the
+// defaults are tuned for "localhost" development.
+func New(opts ...ConfigOption) *passkeyPlugin {
+ cfg := &config{
+ rpName: defaultRPName,
+ challengeExpiration: defaultChallengeExpiration,
+ challengeCookieName: defaultChallengeCookieName,
+ requireSessionToReg: true,
+ }
+ for _, opt := range opts {
+ opt(cfg)
+ }
+ return &passkeyPlugin{config: cfg}
+}
+
+func (p *passkeyPlugin) Name() limen.PluginName { return limen.PluginPasskey }
+
+func (p *passkeyPlugin) Initialize(core *limen.LimenCore) error {
+ p.core = core
+ p.userSchema = core.Schema.User
+ p.verifSchema = core.Schema.Verification
+ p.dbAction = core.DBAction
+
+ if p.config == nil {
+ return fmt.Errorf("passkey: config is required")
+ }
+ if p.config.challengeExpiration <= 0 {
+ return fmt.Errorf("passkey: challenge expiration must be positive")
+ }
+
+ // Derive RP origins + RP ID from Limen's BaseURL when unset.
+ baseURL := core.GetBaseURL()
+ if len(p.config.origins) == 0 && baseURL != "" {
+ p.config.origins = []string{baseURL}
+ }
+ if p.config.rpID == "" {
+ p.config.rpID = inferRPID(baseURL)
+ }
+ if len(p.config.origins) == 0 {
+ return ErrOriginRequired
+ }
+
+ w, err := webauthn.New(&webauthn.Config{
+ RPDisplayName: p.config.rpName,
+ RPID: p.config.rpID,
+ RPOrigins: p.config.origins,
+ AuthenticatorSelection: authSelectionOrDefault(p.config.authenticatorSelect),
+ Timeouts: webauthn.TimeoutsConfig{
+ Login: webauthn.TimeoutConfig{
+ Enforce: true,
+ Timeout: p.config.challengeExpiration,
+ TimeoutUVD: p.config.challengeExpiration,
+ },
+ Registration: webauthn.TimeoutConfig{
+ Enforce: true,
+ Timeout: p.config.challengeExpiration,
+ TimeoutUVD: p.config.challengeExpiration,
+ },
+ },
+ })
+ if err != nil {
+ return fmt.Errorf("passkey: webauthn init: %w", err)
+ }
+ p.webauthn = w
+
+ return nil
+}
+
+func (p *passkeyPlugin) PluginHTTPConfig() limen.PluginHTTPConfig {
+ return limen.PluginHTTPConfig{
+ BasePath: "/passkey",
+ RateLimitRules: []*limen.RateLimitRule{
+ limen.NewRateLimitRule("/generate-register-options", 10, time.Minute),
+ limen.NewRateLimitRule("/verify-registration", 10, time.Minute),
+ limen.NewRateLimitRule("/generate-authenticate-options", 20, time.Minute),
+ limen.NewRateLimitRule("/verify-authentication", 20, time.Minute),
+ },
+ }
+}
+
+func (p *passkeyPlugin) RegisterRoutes(httpCore *limen.LimenHTTPCore, routeBuilder *limen.RouteBuilder) {
+ h := newPasskeyHandlers(p, httpCore)
+ routeBuilder.ProtectedGET("/generate-register-options", "passkey-gen-register", h.GenerateRegistrationOptions)
+ routeBuilder.ProtectedPOST("/verify-registration", "passkey-verify-register", h.VerifyRegistration)
+ // Authentication endpoints do NOT require a session — they create one.
+ routeBuilder.GET("/generate-authenticate-options", "passkey-gen-auth", h.GenerateAuthenticationOptions)
+ routeBuilder.POST("/verify-authentication", "passkey-verify-auth", h.VerifyAuthentication)
+ routeBuilder.ProtectedGET("/list", "passkey-list", h.ListPasskeys)
+ routeBuilder.ProtectedPOST("/delete", "passkey-delete", h.DeletePasskey)
+ routeBuilder.ProtectedPOST("/update", "passkey-update", h.UpdatePasskey)
+}
+
+func (p *passkeyPlugin) GetSchemas(schema *limen.SchemaConfig) []limen.SchemaIntrospector {
+ s := newPasskeySchema()
+ p.passkeySchema = s
+
+ table := limen.NewSchemaDefinitionForTable(
+ limen.SchemaName(PasskeySchemaTableName),
+ PasskeySchemaTableName,
+ s,
+ limen.WithSchemaIDField(schema),
+ limen.WithSchemaField(string(PasskeySchemaUserIDField), schema.GetIDColumnType()),
+ limen.WithSchemaField(string(PasskeySchemaNameField), limen.ColumnTypeString, limen.WithNullable(true)),
+ limen.WithSchemaField(string(PasskeySchemaCredentialIDField), limen.ColumnTypeText),
+ limen.WithSchemaField(string(PasskeySchemaPublicKeyField), limen.ColumnTypeText),
+ limen.WithSchemaField(string(PasskeySchemaCounterField), limen.ColumnTypeInt64, limen.WithDefaultValue("0")),
+ limen.WithSchemaField(string(PasskeySchemaDeviceTypeField), limen.ColumnTypeString),
+ limen.WithSchemaField(string(PasskeySchemaBackedUpField), limen.ColumnTypeBool, limen.WithDefaultValue("false")),
+ limen.WithSchemaField(string(PasskeySchemaTransportsField), limen.ColumnTypeString, limen.WithNullable(true)),
+ limen.WithSchemaField(string(PasskeySchemaAAGUIDField), limen.ColumnTypeString, limen.WithNullable(true)),
+ limen.WithSchemaField(string(limen.SchemaCreatedAtField), limen.ColumnTypeTime, limen.WithDefaultValue(string(limen.DatabaseDefaultValueNow))),
+ limen.WithSchemaField(string(limen.SchemaUpdatedAtField), limen.ColumnTypeTime),
+ limen.WithSchemaForeignKey(limen.ForeignKeyDefinition{
+ Name: "fk_passkeys_users_user_id",
+ Column: PasskeySchemaUserIDField,
+ ReferencedSchema: limen.CoreSchemaUsers,
+ ReferencedField: limen.SchemaIDField,
+ OnDelete: limen.FKActionCascade,
+ OnUpdate: limen.FKActionCascade,
+ }),
+ limen.WithSchemaUniqueIndex("idx_passkeys_credential_id", []limen.SchemaField{PasskeySchemaCredentialIDField}),
+ )
+
+ return []limen.SchemaIntrospector{table}
+}
+
+// inferRPID derives the WebAuthn Relying Party ID from a base URL.
+// Defaults to "localhost" if the URL can't be parsed or has no host.
+func inferRPID(baseURL string) string {
+ if baseURL == "" {
+ return "localhost"
+ }
+ u, err := url.Parse(baseURL)
+ if err != nil || u.Host == "" {
+ return "localhost"
+ }
+ host := u.Hostname()
+ if host == "" {
+ return "localhost"
+ }
+ return host
+}
diff --git a/plugins/passkey/plugin_test.go b/plugins/passkey/plugin_test.go
new file mode 100644
index 0000000..77c0912
--- /dev/null
+++ b/plugins/passkey/plugin_test.go
@@ -0,0 +1,121 @@
+package passkey
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPluginInit_DerivesRPIDFromBaseURL(t *testing.T) {
+ t.Parallel()
+
+ _, plugin := newTestLimenAndPlugin(t)
+ require.NotNil(t, plugin.webauthn)
+ // The default test Limen base URL is http://localhost:8080.
+ assert.Equal(t, "localhost", plugin.config.rpID)
+ assert.NotEmpty(t, plugin.config.origins, "origins should default to the base URL")
+}
+
+func TestPluginInit_RespectsWithRPID(t *testing.T) {
+ t.Parallel()
+
+ _, plugin := newTestLimenAndPlugin(t,
+ WithRPID("example.com"),
+ WithRPName("Example"),
+ WithOrigins("https://example.com"),
+ )
+ assert.Equal(t, "example.com", plugin.config.rpID)
+ assert.Equal(t, "Example", plugin.config.rpName)
+ assert.Equal(t, []string{"https://example.com"}, plugin.config.origins)
+}
+
+func TestGenerateRegistrationOptions_RequiresSession(t *testing.T) {
+ t.Parallel()
+
+ l, _ := newTestLimenAndPlugin(t)
+
+ req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/auth/passkey/generate-register-options", http.NoBody)
+ w := httptest.NewRecorder()
+ l.Handler().ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusUnauthorized, w.Code, w.Body.String())
+}
+
+func TestGenerateAuthenticationOptions_PublicEndpoint(t *testing.T) {
+ t.Parallel()
+
+ l, _ := newTestLimenAndPlugin(t,
+ WithRPID("localhost"),
+ WithOrigins("http://localhost:8080"),
+ )
+
+ req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/auth/passkey/generate-authenticate-options", http.NoBody)
+ w := httptest.NewRecorder()
+ l.Handler().ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code, w.Body.String())
+ // The response must include a challenge field.
+ assert.Contains(t, w.Body.String(), "challenge")
+ // A signed challenge cookie must be set so the verify endpoint
+ // can correlate the assertion to its stored state.
+ found := false
+ for _, c := range w.Result().Cookies() {
+ if c.Name == defaultChallengeCookieName {
+ found = true
+ break
+ }
+ }
+ assert.True(t, found, "challenge cookie should be set")
+}
+
+func TestVerifyAuthentication_MissingCookieReturnsChallengeNotFound(t *testing.T) {
+ t.Parallel()
+
+ l, _ := newTestLimenAndPlugin(t)
+
+ req := newJSONRequest(t, http.MethodPost, "/auth/passkey/verify-authentication", `{"response":{}}`)
+ w := httptest.NewRecorder()
+ l.Handler().ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusBadRequest, w.Code)
+ assert.Contains(t, w.Body.String(), "challenge")
+}
+
+func TestVerifyRegistration_RequiresSession(t *testing.T) {
+ t.Parallel()
+
+ l, _ := newTestLimenAndPlugin(t)
+
+ req := newJSONRequest(t, http.MethodPost, "/auth/passkey/verify-registration", `{"response":{}}`)
+ w := httptest.NewRecorder()
+ l.Handler().ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusUnauthorized, w.Code)
+}
+
+func TestListPasskeys_RequiresSession(t *testing.T) {
+ t.Parallel()
+
+ l, _ := newTestLimenAndPlugin(t)
+
+ req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/auth/passkey/list", http.NoBody)
+ w := httptest.NewRecorder()
+ l.Handler().ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusUnauthorized, w.Code)
+}
+
+func TestDeletePasskey_RequiresSession(t *testing.T) {
+ t.Parallel()
+
+ l, _ := newTestLimenAndPlugin(t)
+
+ req := newJSONRequest(t, http.MethodPost, "/auth/passkey/delete", `{"id":"1"}`)
+ w := httptest.NewRecorder()
+ l.Handler().ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusUnauthorized, w.Code)
+}
diff --git a/plugins/passkey/registration.go b/plugins/passkey/registration.go
new file mode 100644
index 0000000..942e651
--- /dev/null
+++ b/plugins/passkey/registration.go
@@ -0,0 +1,209 @@
+package passkey
+
+import (
+ "context"
+ "encoding/base64"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/go-webauthn/webauthn/protocol"
+ "github.com/go-webauthn/webauthn/webauthn"
+
+ "github.com/thecodearcher/limen"
+)
+
+// BeginRegistration creates the WebAuthn registration options the
+// caller's client must hand to navigator.credentials.create(). It also
+// persists the per-ceremony challenge state and sets a signed cookie
+// the corresponding FinishRegistration call will use to look it up.
+//
+// nameHint, when non-empty, is stored on the resulting PasskeyRecord
+// so users can manage credentials by a human-friendly label (e.g.
+// "MacBook Air — Touch ID").
+func (p *passkeyPlugin) BeginRegistration(ctx context.Context, r *http.Request, w http.ResponseWriter, nameHint string) (*protocol.CredentialCreation, error) {
+ session, err := requireValidatedSession(r)
+ if err != nil {
+ return nil, err
+ }
+ user := session.User
+
+ existing, err := p.listForUser(ctx, user.ID)
+ if err != nil {
+ return nil, err
+ }
+ waUser, err := p.toWebAuthnUser(user, existing)
+ if err != nil {
+ return nil, err
+ }
+
+ options, sessionData, err := p.webauthn.BeginRegistration(
+ waUser,
+ webauthn.WithExclusions(excludedDescriptors(existing)),
+ )
+ if err != nil {
+ return nil, fmt.Errorf("passkey: begin registration: %w", err)
+ }
+
+ if err := p.storeChallenge(ctx, w, &challengeState{
+ Ceremony: CeremonyRegistration,
+ UserID: user.ID,
+ UserName: nameHint,
+ Session: *sessionData,
+ }); err != nil {
+ return nil, err
+ }
+
+ return options, nil
+}
+
+// FinishRegistration validates the WebAuthn registration response sent
+// by the client, persists the new credential, and returns the public
+// passkey record. The challenge state is consumed regardless of
+// outcome so the same response cannot be replayed.
+func (p *passkeyPlugin) FinishRegistration(ctx context.Context, r *http.Request, body io.Reader) (*Passkey, error) {
+ session, err := requireValidatedSession(r)
+ if err != nil {
+ return nil, err
+ }
+ state, err := p.consumeChallenge(ctx, r)
+ if err != nil {
+ return nil, err
+ }
+ if state.Ceremony != CeremonyRegistration {
+ return nil, ErrChallengeMismatch
+ }
+ if !sameID(state.UserID, session.User.ID) {
+ return nil, ErrForbidden
+ }
+
+ parsed, err := protocol.ParseCredentialCreationResponseBody(body)
+ if err != nil {
+ return nil, ErrInvalidResponse
+ }
+
+ existing, err := p.listForUser(ctx, session.User.ID)
+ if err != nil {
+ return nil, err
+ }
+ waUser, err := p.toWebAuthnUser(session.User, existing)
+ if err != nil {
+ return nil, err
+ }
+
+ credential, err := p.webauthn.CreateCredential(waUser, state.Session, parsed)
+ if err != nil {
+ return nil, ErrRegistrationFailed
+ }
+
+ credIDB64 := base64.RawURLEncoding.EncodeToString(credential.ID)
+ for _, e := range existing {
+ if e.CredentialIDBase64 == credIDB64 {
+ return nil, ErrPasskeyAlreadyExists
+ }
+ }
+
+ transports := make([]string, 0, len(credential.Transport))
+ for _, t := range credential.Transport {
+ if s := strings.TrimSpace(string(t)); s != "" {
+ transports = append(transports, s)
+ }
+ }
+
+ now := time.Now()
+ rec := &PasskeyRecord{
+ UserID: session.User.ID,
+ Name: state.UserName,
+ CredentialIDBase64: credIDB64,
+ PublicKeyBase64: base64.StdEncoding.EncodeToString(credential.PublicKey),
+ Counter: int64(credential.Authenticator.SignCount),
+ DeviceType: deviceTypeFromFlags(credential.Flags),
+ BackedUp: credential.Flags.BackupState,
+ Transports: strings.Join(transports, ","),
+ AAGUID: base64.StdEncoding.EncodeToString(credential.Authenticator.AAGUID),
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ if err := p.core.Create(ctx, p.passkeySchema, rec, nil); err != nil {
+ return nil, err
+ }
+
+ stored, err := p.findByCredentialID(ctx, credIDB64)
+ if err != nil {
+ return nil, err
+ }
+ pk := publicPasskey(stored)
+ return &pk, nil
+}
+
+// requireValidatedSession pulls the active session out of the request
+// context. The ProtectedXxx route helpers wire the session middleware
+// for us — this just returns a typed error when the middleware was not
+// applied (e.g., during direct programmatic calls).
+func requireValidatedSession(r *http.Request) (*limen.ValidatedSession, error) {
+ if r == nil {
+ return nil, ErrSessionRequired
+ }
+ session, err := limen.GetCurrentSessionFromCtx(r)
+ if err != nil || session == nil || session.User == nil {
+ return nil, ErrSessionRequired
+ }
+ return session, nil
+}
+
+func (p *passkeyPlugin) toWebAuthnUser(user *limen.User, recs []*PasskeyRecord) (*webAuthnUser, error) {
+ creds := make([]webauthn.Credential, 0, len(recs))
+ for _, rec := range recs {
+ c, err := recordToCredential(rec)
+ if err != nil {
+ return nil, err
+ }
+ creds = append(creds, c)
+ }
+ name := user.Email
+ if name == "" {
+ name = fmt.Sprintf("user-%v", user.ID)
+ }
+ return &webAuthnUser{
+ id: userHandleFor(user.ID),
+ name: name,
+ displayName: name,
+ credentials: creds,
+ }, nil
+}
+
+func excludedDescriptors(recs []*PasskeyRecord) []protocol.CredentialDescriptor {
+ out := make([]protocol.CredentialDescriptor, 0, len(recs))
+ for _, rec := range recs {
+ c, err := recordToCredential(rec)
+ if err != nil {
+ continue
+ }
+ out = append(out, protocol.CredentialDescriptor{
+ Type: protocol.PublicKeyCredentialType,
+ CredentialID: c.ID,
+ Transport: c.Transport,
+ })
+ }
+ return out
+}
+
+// sameID compares two id values typed as any. Limen adapters return
+// int64, string, or driver-specific types depending on the database,
+// so we compare on the string form.
+func sameID(a, b any) bool {
+ if a == nil || b == nil {
+ return false
+ }
+ return fmt.Sprintf("%v", a) == fmt.Sprintf("%v", b)
+}
+
+func deviceTypeFromFlags(f webauthn.CredentialFlags) string {
+ if f.BackupEligible {
+ return "multiDevice"
+ }
+ return "singleDevice"
+}
+
diff --git a/plugins/passkey/schema_passkey.go b/plugins/passkey/schema_passkey.go
new file mode 100644
index 0000000..0f6eb4f
--- /dev/null
+++ b/plugins/passkey/schema_passkey.go
@@ -0,0 +1,150 @@
+package passkey
+
+import (
+ "time"
+
+ "github.com/thecodearcher/limen"
+)
+
+// PasskeyRecord is the internal storage row for a single registered
+// credential. The CredentialID and PublicKey are stored base64url-encoded
+// so they can live in string columns.
+type PasskeyRecord struct {
+ ID any
+ UserID any
+ Name string
+ CredentialIDBase64 string
+ PublicKeyBase64 string
+ Counter int64
+ DeviceType string
+ BackedUp bool
+ Transports string
+ AAGUID string
+ CreatedAt time.Time
+ UpdatedAt time.Time
+ raw map[string]any
+}
+
+func (p *PasskeyRecord) Raw() map[string]any { return p.raw }
+
+type passkeySchema struct {
+ limen.BaseSchema
+}
+
+func newPasskeySchema() *passkeySchema {
+ return &passkeySchema{BaseSchema: limen.BaseSchema{}}
+}
+
+func (s *passkeySchema) GetUserIDField() string {
+ return s.GetField(PasskeySchemaUserIDField)
+}
+func (s *passkeySchema) GetNameField() string {
+ return s.GetField(PasskeySchemaNameField)
+}
+func (s *passkeySchema) GetCredentialIDField() string {
+ return s.GetField(PasskeySchemaCredentialIDField)
+}
+func (s *passkeySchema) GetPublicKeyField() string {
+ return s.GetField(PasskeySchemaPublicKeyField)
+}
+func (s *passkeySchema) GetCounterField() string {
+ return s.GetField(PasskeySchemaCounterField)
+}
+func (s *passkeySchema) GetDeviceTypeField() string {
+ return s.GetField(PasskeySchemaDeviceTypeField)
+}
+func (s *passkeySchema) GetBackedUpField() string {
+ return s.GetField(PasskeySchemaBackedUpField)
+}
+func (s *passkeySchema) GetTransportsField() string {
+ return s.GetField(PasskeySchemaTransportsField)
+}
+func (s *passkeySchema) GetAAGUIDField() string {
+ return s.GetField(PasskeySchemaAAGUIDField)
+}
+func (s *passkeySchema) GetCreatedAtField() string {
+ return s.GetField(limen.SchemaCreatedAtField)
+}
+func (s *passkeySchema) GetUpdatedAtField() string {
+ return s.GetField(limen.SchemaUpdatedAtField)
+}
+
+func (s *passkeySchema) ToStorage(data limen.Model) map[string]any {
+ rec := data.(*PasskeyRecord)
+ return map[string]any{
+ s.GetUserIDField(): rec.UserID,
+ s.GetNameField(): rec.Name,
+ s.GetCredentialIDField(): rec.CredentialIDBase64,
+ s.GetPublicKeyField(): rec.PublicKeyBase64,
+ s.GetCounterField(): rec.Counter,
+ s.GetDeviceTypeField(): rec.DeviceType,
+ s.GetBackedUpField(): rec.BackedUp,
+ s.GetTransportsField(): rec.Transports,
+ s.GetAAGUIDField(): rec.AAGUID,
+ }
+}
+
+func (s *passkeySchema) FromStorage(data map[string]any) limen.Model {
+ rec := &PasskeyRecord{
+ ID: data[s.GetIDField()],
+ UserID: data[s.GetUserIDField()],
+ CredentialIDBase64: stringOr(data[s.GetCredentialIDField()], ""),
+ PublicKeyBase64: stringOr(data[s.GetPublicKeyField()], ""),
+ Counter: int64Or(data[s.GetCounterField()], 0),
+ DeviceType: stringOr(data[s.GetDeviceTypeField()], ""),
+ BackedUp: boolOr(data[s.GetBackedUpField()], false),
+ Transports: stringOr(data[s.GetTransportsField()], ""),
+ AAGUID: stringOr(data[s.GetAAGUIDField()], ""),
+ Name: stringOr(data[s.GetNameField()], ""),
+ raw: data,
+ }
+ if v, ok := data[s.GetCreatedAtField()].(time.Time); ok {
+ rec.CreatedAt = v
+ }
+ if v, ok := data[s.GetUpdatedAtField()].(time.Time); ok {
+ rec.UpdatedAt = v
+ }
+ return rec
+}
+
+// stringOr / int64Or / boolOr defend against drivers that emit different
+// concrete types for the same logical column (e.g. lib/pq returning
+// []byte instead of string for TEXT, or int64 vs int for counters).
+func stringOr(v any, fallback string) string {
+ switch t := v.(type) {
+ case nil:
+ return fallback
+ case string:
+ return t
+ case []byte:
+ return string(t)
+ default:
+ return fallback
+ }
+}
+
+func int64Or(v any, fallback int64) int64 {
+ switch t := v.(type) {
+ case nil:
+ return fallback
+ case int64:
+ return t
+ case int:
+ return int64(t)
+ case int32:
+ return int64(t)
+ default:
+ return fallback
+ }
+}
+
+func boolOr(v any, fallback bool) bool {
+ switch t := v.(type) {
+ case nil:
+ return fallback
+ case bool:
+ return t
+ default:
+ return fallback
+ }
+}
diff --git a/plugins/passkey/state.go b/plugins/passkey/state.go
new file mode 100644
index 0000000..e0a1ed0
--- /dev/null
+++ b/plugins/passkey/state.go
@@ -0,0 +1,48 @@
+package passkey
+
+import (
+ "crypto/rand"
+ "encoding/base64"
+ "encoding/json"
+
+ "github.com/go-webauthn/webauthn/webauthn"
+)
+
+// challengeState is what we persist in the verifications.value column
+// for each in-flight ceremony. It carries the WebAuthn library's
+// SessionData (challenge, allowed credentials, user verification
+// requirement, etc.) plus identifying information that lets the
+// "finish" endpoint look up the user without trusting the client.
+type challengeState struct {
+ Ceremony CeremonyKind `json:"k"`
+ UserID any `json:"u,omitempty"`
+ UserName string `json:"un,omitempty"`
+ Session webauthn.SessionData `json:"s"`
+}
+
+func encodeChallengeState(s *challengeState) (string, error) {
+ raw, err := json.Marshal(s)
+ if err != nil {
+ return "", err
+ }
+ return string(raw), nil
+}
+
+func decodeChallengeState(value string) (*challengeState, error) {
+ var s challengeState
+ if err := json.Unmarshal([]byte(value), &s); err != nil {
+ return nil, err
+ }
+ return &s, nil
+}
+
+// newVerificationToken returns a random URL-safe identifier used both
+// as the signed cookie value handed to the client and as the lookup
+// key for the persisted challenge row.
+func newVerificationToken() (string, error) {
+ b := make([]byte, verificationTokenByteLength)
+ if _, err := rand.Read(b); err != nil {
+ return "", err
+ }
+ return base64.RawURLEncoding.EncodeToString(b), nil
+}
diff --git a/plugins/passkey/state_test.go b/plugins/passkey/state_test.go
new file mode 100644
index 0000000..aed306e
--- /dev/null
+++ b/plugins/passkey/state_test.go
@@ -0,0 +1,57 @@
+package passkey
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/go-webauthn/webauthn/webauthn"
+)
+
+func TestEncodeDecodeChallengeState_RoundTrips(t *testing.T) {
+ t.Parallel()
+
+ state := &challengeState{
+ Ceremony: CeremonyRegistration,
+ UserID: "user-123",
+ UserName: "MacBook Air",
+ Session: webauthn.SessionData{
+ Challenge: "challenge-bytes",
+ UserID: []byte("limen-user-user-123"),
+ UserVerification: "preferred",
+ },
+ }
+
+ encoded, err := encodeChallengeState(state)
+ require.NoError(t, err)
+ require.NotEmpty(t, encoded)
+
+ got, err := decodeChallengeState(encoded)
+ require.NoError(t, err)
+ assert.Equal(t, state.Ceremony, got.Ceremony)
+ assert.Equal(t, state.UserName, got.UserName)
+ assert.Equal(t, state.Session.Challenge, got.Session.Challenge)
+}
+
+func TestNewVerificationToken_Unique(t *testing.T) {
+ t.Parallel()
+
+ seen := map[string]bool{}
+ for i := 0; i < 100; i++ {
+ tok, err := newVerificationToken()
+ require.NoError(t, err)
+ assert.NotEmpty(t, tok)
+ assert.False(t, seen[tok], "duplicate token returned: %s", tok)
+ seen[tok] = true
+ }
+}
+
+func TestSameID_StringifiedComparison(t *testing.T) {
+ t.Parallel()
+
+ assert.True(t, sameID(int64(7), int64(7)))
+ assert.True(t, sameID(int64(7), "7"), "id comparison should be type-insensitive")
+ assert.False(t, sameID(int64(7), int64(8)))
+ assert.False(t, sameID(nil, int64(7)))
+}
diff --git a/plugins/passkey/store.go b/plugins/passkey/store.go
new file mode 100644
index 0000000..a83f988
--- /dev/null
+++ b/plugins/passkey/store.go
@@ -0,0 +1,87 @@
+package passkey
+
+import (
+ "context"
+ "net/http"
+
+ "github.com/thecodearcher/limen"
+)
+
+// storeChallenge persists a ceremony state into the verifications table
+// and drops the lookup token into a signed cookie. The cookie is
+// HttpOnly and short-lived, and the verification row is the only
+// source of truth — we never trust client-supplied state when
+// finishing a ceremony.
+func (p *passkeyPlugin) storeChallenge(ctx context.Context, w http.ResponseWriter, state *challengeState) error {
+ token, err := newVerificationToken()
+ if err != nil {
+ return err
+ }
+ value, err := encodeChallengeState(state)
+ if err != nil {
+ return err
+ }
+ if _, err := p.dbAction.CreateVerification(ctx, passkeyChallengeAction, token, value, p.config.challengeExpiration); err != nil {
+ return err
+ }
+ maxAge := int(p.config.challengeExpiration.Seconds())
+ return p.core.Cookies().SetSignedCookie(w, p.config.challengeCookieName, token, maxAge)
+}
+
+// consumeChallenge resolves the signed-cookie token to its persisted
+// challenge state, then deletes the row so the response cannot be
+// replayed. Any error (missing cookie, expired row, decode failure)
+// surfaces as ErrChallengeNotFound to avoid leaking ceremony state.
+func (p *passkeyPlugin) consumeChallenge(ctx context.Context, r *http.Request) (*challengeState, error) {
+ token, err := p.core.Cookies().GetSignedCookie(r, p.config.challengeCookieName)
+ if err != nil || token == "" {
+ return nil, ErrChallengeNotFound
+ }
+ verification, err := p.dbAction.FindVerificationByAction(ctx, passkeyChallengeAction, token)
+ if err != nil {
+ return nil, ErrChallengeNotFound
+ }
+ // Single-use semantics: delete regardless of decode/verify outcome.
+ if err := p.dbAction.DeleteVerification(ctx, verification.ID); err != nil {
+ return nil, err
+ }
+ state, err := decodeChallengeState(verification.Value)
+ if err != nil {
+ return nil, ErrChallengeNotFound
+ }
+ return state, nil
+}
+
+func (p *passkeyPlugin) listForUser(ctx context.Context, userID any) ([]*PasskeyRecord, error) {
+ rows, err := p.core.FindMany(ctx, p.passkeySchema, []limen.Where{
+ limen.Eq(p.passkeySchema.GetUserIDField(), userID),
+ })
+ if err != nil {
+ return nil, err
+ }
+ out := make([]*PasskeyRecord, 0, len(rows))
+ for _, row := range rows {
+ out = append(out, row.(*PasskeyRecord))
+ }
+ return out, nil
+}
+
+func (p *passkeyPlugin) findByCredentialID(ctx context.Context, credentialIDB64 string) (*PasskeyRecord, error) {
+ row, err := p.core.FindOne(ctx, p.passkeySchema, []limen.Where{
+ limen.Eq(p.passkeySchema.GetCredentialIDField(), credentialIDB64),
+ }, nil)
+ if err != nil {
+ return nil, err
+ }
+ return row.(*PasskeyRecord), nil
+}
+
+func (p *passkeyPlugin) findByID(ctx context.Context, id any) (*PasskeyRecord, error) {
+ row, err := p.core.FindOne(ctx, p.passkeySchema, []limen.Where{
+ limen.Eq(p.passkeySchema.GetIDField(), id),
+ }, nil)
+ if err != nil {
+ return nil, err
+ }
+ return row.(*PasskeyRecord), nil
+}
diff --git a/plugins/passkey/testutil_test.go b/plugins/passkey/testutil_test.go
new file mode 100644
index 0000000..b054729
--- /dev/null
+++ b/plugins/passkey/testutil_test.go
@@ -0,0 +1,24 @@
+package passkey
+
+import (
+ "bytes"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/thecodearcher/limen"
+)
+
+func newTestLimenAndPlugin(t *testing.T, opts ...ConfigOption) (*limen.Limen, *passkeyPlugin) {
+ t.Helper()
+ plugin := New(opts...)
+ l, _ := limen.NewTestLimen(t, plugin)
+ return l, plugin
+}
+
+func newJSONRequest(t *testing.T, method, path, body string) *http.Request {
+ t.Helper()
+ req := httptest.NewRequestWithContext(t.Context(), method, path, bytes.NewBufferString(body))
+ req.Header.Set("Content-Type", "application/json")
+ return req
+}
diff --git a/plugins/passkey/types.go b/plugins/passkey/types.go
new file mode 100644
index 0000000..cf896c6
--- /dev/null
+++ b/plugins/passkey/types.go
@@ -0,0 +1,92 @@
+package passkey
+
+import (
+ "time"
+
+ "github.com/go-webauthn/webauthn/protocol"
+)
+
+type config struct {
+ rpID string
+ rpName string
+ origins []string
+ challengeExpiration time.Duration
+ challengeCookieName string
+ requireSessionToReg bool
+ requireUserPresence bool
+ requireUserVerified bool
+ authenticatorSelect *protocol.AuthenticatorSelection
+}
+
+// ConfigOption configures the passkey plugin.
+type ConfigOption func(*config)
+
+// WithRPID sets the WebAuthn Relying Party ID. Defaults to the hostname
+// parsed from the Limen BaseURL (e.g., "localhost" or "example.com").
+// The RPID must match the effective domain of every origin that uses
+// passkeys for the application.
+func WithRPID(id string) ConfigOption {
+ return func(c *config) { c.rpID = id }
+}
+
+// WithRPName sets the human-readable Relying Party name shown to the user
+// by their authenticator. Defaults to "Limen App".
+func WithRPName(name string) ConfigOption {
+ return func(c *config) { c.rpName = name }
+}
+
+// WithOrigins sets the list of allowed origins (e.g.,
+// "https://app.example.com"). If unset, the Limen BaseURL is used.
+func WithOrigins(origins ...string) ConfigOption {
+ return func(c *config) { c.origins = append([]string{}, origins...) }
+}
+
+// WithChallengeExpiration sets how long a WebAuthn challenge remains
+// valid. Defaults to 5 minutes — the browser will typically time out
+// well before this.
+func WithChallengeExpiration(d time.Duration) ConfigOption {
+ return func(c *config) { c.challengeExpiration = d }
+}
+
+// WithChallengeCookieName overrides the signed cookie name used to
+// correlate an in-flight ceremony with its server-side challenge state.
+func WithChallengeCookieName(name string) ConfigOption {
+ return func(c *config) { c.challengeCookieName = name }
+}
+
+// WithRequireSessionForRegistration controls whether a user must be
+// signed in to register a new passkey. Defaults to true. When false, the
+// caller must provide credentials via another means (e.g. a magic-link
+// signed cookie) before invoking the registration endpoints.
+func WithRequireSessionForRegistration(require bool) ConfigOption {
+ return func(c *config) { c.requireSessionToReg = require }
+}
+
+// WithRequireUserVerification requires that the authenticator perform
+// user verification (PIN, biometric, etc.) before issuing a credential
+// or assertion. Defaults to false, matching better-auth.
+func WithRequireUserVerification(require bool) ConfigOption {
+ return func(c *config) { c.requireUserVerified = require }
+}
+
+// WithAuthenticatorSelection overrides the default authenticator
+// selection criteria sent in registration options.
+func WithAuthenticatorSelection(sel *protocol.AuthenticatorSelection) ConfigOption {
+ return func(c *config) { c.authenticatorSelect = sel }
+}
+
+// Passkey is the public representation of a stored credential returned
+// by management endpoints. The PublicKey is omitted from JSON since it
+// is implementation detail that callers should not need.
+type Passkey struct {
+ ID any `json:"id"`
+ UserID any `json:"user_id"`
+ Name string `json:"name,omitempty"`
+ CredentialID string `json:"credential_id"`
+ DeviceType string `json:"device_type"`
+ BackedUp bool `json:"backed_up"`
+ Transports string `json:"transports,omitempty"`
+ AAGUID string `json:"aaguid,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
diff --git a/plugins/passkey/webauthn_user.go b/plugins/passkey/webauthn_user.go
new file mode 100644
index 0000000..ce79aca
--- /dev/null
+++ b/plugins/passkey/webauthn_user.go
@@ -0,0 +1,87 @@
+package passkey
+
+import (
+ "encoding/base64"
+ "fmt"
+ "strings"
+
+ "github.com/go-webauthn/webauthn/protocol"
+ "github.com/go-webauthn/webauthn/webauthn"
+)
+
+// webAuthnUser adapts a Limen user plus their stored credentials into
+// the interface required by the go-webauthn library.
+type webAuthnUser struct {
+ id []byte
+ name string
+ displayName string
+ credentials []webauthn.Credential
+}
+
+func (u *webAuthnUser) WebAuthnID() []byte { return u.id }
+func (u *webAuthnUser) WebAuthnName() string { return u.name }
+func (u *webAuthnUser) WebAuthnDisplayName() string { return u.displayName }
+func (u *webAuthnUser) WebAuthnCredentials() []webauthn.Credential { return u.credentials }
+
+// userHandleFor produces the WebAuthnID for a given Limen user id.
+// WebAuthn requires a non-PII handle <= 64 bytes; we use a deterministic
+// string derived from the user id so the same user always presents the
+// same handle to their authenticator.
+func userHandleFor(userID any) []byte {
+ return []byte(fmt.Sprintf("limen-user-%v", userID))
+}
+
+// recordToCredential rehydrates a stored PasskeyRecord into the shape
+// the go-webauthn library expects when verifying an authentication
+// assertion or excluding an already-registered credential from
+// registration.
+func recordToCredential(rec *PasskeyRecord) (webauthn.Credential, error) {
+ credID, err := base64.RawURLEncoding.DecodeString(rec.CredentialIDBase64)
+ if err != nil {
+ // Some clients use base64 with padding; tolerate both.
+ credID, err = base64.StdEncoding.DecodeString(rec.CredentialIDBase64)
+ if err != nil {
+ return webauthn.Credential{}, fmt.Errorf("invalid credential id encoding: %w", err)
+ }
+ }
+ pubKey, err := base64.StdEncoding.DecodeString(rec.PublicKeyBase64)
+ if err != nil {
+ pubKey, err = base64.RawURLEncoding.DecodeString(rec.PublicKeyBase64)
+ if err != nil {
+ return webauthn.Credential{}, fmt.Errorf("invalid public key encoding: %w", err)
+ }
+ }
+
+ transports := []protocol.AuthenticatorTransport{}
+ if rec.Transports != "" {
+ for _, t := range strings.Split(rec.Transports, ",") {
+ t = strings.TrimSpace(t)
+ if t != "" {
+ transports = append(transports, protocol.AuthenticatorTransport(t))
+ }
+ }
+ }
+
+ aaguid := []byte{}
+ if rec.AAGUID != "" {
+ if decoded, err := base64.StdEncoding.DecodeString(rec.AAGUID); err == nil {
+ aaguid = decoded
+ }
+ }
+
+ flags := webauthn.CredentialFlags{
+ BackupEligible: rec.BackedUp,
+ BackupState: rec.BackedUp,
+ }
+
+ return webauthn.Credential{
+ ID: credID,
+ PublicKey: pubKey,
+ Transport: transports,
+ Flags: flags,
+ Authenticator: webauthn.Authenticator{
+ AAGUID: aaguid,
+ SignCount: uint32(rec.Counter),
+ },
+ }, nil
+}