Skip to content

RFC: Passkeys / WebAuthn Support #519

@lakhansamani

Description

@lakhansamani

RFC: Passkeys / WebAuthn Support

Phase: 5 — Advanced Security & Enterprise
Priority: P2 — Medium
Estimated Effort: Medium


Problem Statement

Authorizer has no passwordless authentication via passkeys (FIDO2/WebAuthn). Keycloak 26.4 and Clerk both support passkeys. The industry is moving toward passwordless — Apple, Google, and Microsoft all promote passkeys as the primary authentication method. Passkeys are phishing-resistant, require no passwords to remember, and provide a superior user experience.


Current Architecture Context

  • MFA/TOTP exists via internal/authenticators/ (Google Authenticator)
  • Authenticator schema: ID, UserID, Method, Secret, RecoveryCodes, VerifiedAt
  • Login flow in internal/graphql/login.go returns AuthResponse with optional MFA challenge
  • No WebAuthn library in go.mod
  • web/app/ (React) handles login UI

Proposed Solution

1. WebAuthn Library

Library: go-webauthn/webauthn — the most maintained Go WebAuthn library, supports FIDO2, passkeys, and all attestation formats.

2. WebAuthn Credential Schema

New schema: internal/storage/schemas/webauthn_credential.go

type WebAuthnCredential struct {
    ID              string `json:"id" gorm:"primaryKey;type:char(36)"`
    UserID          string `json:"user_id" gorm:"type:char(36);index:idx_webauthn_user"`
    CredentialID    string `json:"credential_id" gorm:"type:text;uniqueIndex"`          // base64url-encoded
    PublicKey       string `json:"public_key" gorm:"type:text"`                         // CBOR-encoded public key
    AttestationType string `json:"attestation_type" gorm:"type:varchar(50)"`            // none, packed, tpm, etc.
    Transport       string `json:"transport" gorm:"type:varchar(256)"`                  // JSON array: ["internal", "usb", "ble", "nfc"]
    SignCount       int64  `json:"sign_count" gorm:"default:0"`                         // monotonic counter for cloning detection
    AAGUID          string `json:"aaguid" gorm:"type:varchar(36)"`                      // authenticator attestation GUID
    Name            string `json:"name" gorm:"type:varchar(256)"`                       // user-provided label: "MacBook Pro Touch ID"
    LastUsedAt      int64  `json:"last_used_at"`
    CreatedAt       int64  `json:"created_at" gorm:"autoCreateTime"`
}

3. Registration Flow

Step 1: Begin registration — server generates challenge

New REST endpoint: POST /webauthn/register/begin

func (h *httpProvider) WebAuthnRegisterBeginHandler() gin.HandlerFunc {
    return func(c *gin.Context) {
        // Requires authenticated session
        user := getAuthenticatedUser(c)
        
        // Check credential limit
        existingCreds, _ := store.ListWebAuthnCredentialsByUserID(ctx, user.ID)
        if len(existingCreds) >= maxPasskeysPerUser {
            return error("max_passkeys_reached")
        }
        
        // Create WebAuthn user adapter
        webauthnUser := &WebAuthnUser{
            ID:          user.ID,
            Name:        user.Email,
            DisplayName: user.GivenName + " " + user.FamilyName,
            Credentials: convertToWebAuthnCredentials(existingCreds),
        }
        
        // Generate registration options
        options, sessionData, err := webauthn.BeginRegistration(webauthnUser,
            webauthn.WithExcludeCredentials(webauthnUser.CredentialDescriptors()),
            webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementPreferred),
            webauthn.WithUserVerification(protocol.VerificationPreferred),
        )
        
        // Store session data in memory store (TTL: 5 minutes)
        memoryStore.SetWebAuthnSession(user.ID, "register", sessionData, 5*time.Minute)
        
        c.JSON(200, options)
    }
}

Step 2: Finish registration — client sends attestation

New REST endpoint: POST /webauthn/register/finish

func (h *httpProvider) WebAuthnRegisterFinishHandler() gin.HandlerFunc {
    return func(c *gin.Context) {
        user := getAuthenticatedUser(c)
        
        // Retrieve session data
        sessionData, _ := memoryStore.GetWebAuthnSession(user.ID, "register")
        
        // Verify attestation
        credential, err := webauthn.FinishRegistration(webauthnUser, *sessionData, c.Request)
        
        // Store credential
        store.AddWebAuthnCredential(ctx, &schemas.WebAuthnCredential{
            UserID:          user.ID,
            CredentialID:    base64.RawURLEncoding.EncodeToString(credential.ID),
            PublicKey:       base64.StdEncoding.EncodeToString(credential.PublicKey),
            AttestationType: credential.AttestationType,
            Transport:       marshalTransports(credential.Transport),
            AAGUID:          credential.Authenticator.AAGUID.String(),
            Name:            c.PostForm("name"), // user-provided label
        })
        
        // Cleanup session
        memoryStore.DeleteWebAuthnSession(user.ID, "register")
        
        c.JSON(200, gin.H{"message": "passkey_registered"})
    }
}

4. Authentication Flow

Step 1: Begin login — POST /webauthn/login/begin

func (h *httpProvider) WebAuthnLoginBeginHandler() gin.HandlerFunc {
    return func(c *gin.Context) {
        email := c.PostForm("email") // optional — for non-discoverable credentials
        
        var options *protocol.CredentialAssertion
        var sessionData *webauthn.SessionData
        
        if email != "" {
            // User-identified flow — show only this user's credentials
            user, _ := store.GetUserByEmail(ctx, email)
            creds, _ := store.ListWebAuthnCredentialsByUserID(ctx, user.ID)
            webauthnUser := &WebAuthnUser{ID: user.ID, Credentials: convertCreds(creds)}
            options, sessionData, _ = webauthn.BeginLogin(webauthnUser)
        } else {
            // Discoverable credential flow (passkeys) — no email needed
            options, sessionData, _ = webauthn.BeginDiscoverableLogin()
        }
        
        // Store session in memory store
        sessionKey := email
        if sessionKey == "" {
            sessionKey = "discoverable"
        }
        memoryStore.SetWebAuthnSession(sessionKey, "login", sessionData, 5*time.Minute)
        
        c.JSON(200, options)
    }
}

Step 2: Finish login — POST /webauthn/login/finish

func (h *httpProvider) WebAuthnLoginFinishHandler() gin.HandlerFunc {
    return func(c *gin.Context) {
        // For discoverable credentials, the credential response includes the user handle
        // which maps to user.ID — no email needed
        
        credential, err := webauthn.FinishDiscoverableLogin(
            func(rawID, userHandle []byte) (webauthn.User, error) {
                userID := string(userHandle)
                user, _ := store.GetUserByID(ctx, userID)
                creds, _ := store.ListWebAuthnCredentialsByUserID(ctx, userID)
                return &WebAuthnUser{ID: user.ID, Credentials: convertCreds(creds)}, nil
            },
            *sessionData,
            c.Request,
        )
        
        // Update sign count (clone detection)
        store.UpdateWebAuthnCredentialSignCount(ctx, credentialID, credential.Authenticator.SignCount)
        
        // Sign count validation: if new count <= stored count, possible cloned authenticator
        if credential.Authenticator.SignCount <= storedSignCount && storedSignCount > 0 {
            // Log security warning, optionally reject
            auditProvider.Log(ctx, audit.AuditEvent{
                Action: "user.webauthn_clone_detected",
                // ...
            })
        }
        
        // Create session and issue tokens (same as password login)
        createSessionAndReturnTokens(c, user, "passkey")
    }
}

5. Passkey Modes

As primary auth (skip password):

  • User registers passkey → can log in with only passkey, no password needed
  • SignupMethods updated to include "passkey"

As MFA second factor:

  • After password verification, prompt for passkey instead of TOTP
  • Stronger than TOTP (phishing-resistant)

Configuration: --passkey-mode=primary (primary = standalone login, mfa = second factor only, both = user choice)

6. Conditional UI (Autofill)

When WebAuthn conditional mediation is available, the browser can suggest passkeys in the login form's email field autofill dropdown:

// In web/app/ login component:
if (window.PublicKeyCredential?.isConditionalMediationAvailable) {
    const available = await PublicKeyCredential.isConditionalMediationAvailable();
    if (available) {
        // Add autocomplete="webauthn" to email input
        // Browser shows passkey suggestions in autofill dropdown
        navigator.credentials.get({
            publicKey: assertionOptions,
            mediation: "conditional"
        });
    }
}

7. Storage Interface Methods

AddWebAuthnCredential(ctx context.Context, cred *schemas.WebAuthnCredential) (*schemas.WebAuthnCredential, error)
GetWebAuthnCredentialByCredentialID(ctx context.Context, credentialID string) (*schemas.WebAuthnCredential, error)
ListWebAuthnCredentialsByUserID(ctx context.Context, userID string) ([]*schemas.WebAuthnCredential, error)
UpdateWebAuthnCredentialSignCount(ctx context.Context, credentialID string, signCount int64) error
UpdateWebAuthnCredentialLastUsed(ctx context.Context, credentialID string) error
DeleteWebAuthnCredential(ctx context.Context, id string) error
CountWebAuthnCredentialsByUserID(ctx context.Context, userID string) (int64, error)

8. GraphQL API

type WebAuthnCredential {
    id: ID!
    name: String!
    credential_id_prefix: String!    # first 8 chars for identification
    transport: [String!]
    last_used_at: Int64
    created_at: Int64!
}

type Query {
    passkeys: [WebAuthnCredential!]!  # List current user's passkeys
}

type Mutation {
    rename_passkey(id: ID!, name: String!): WebAuthnCredential!
    delete_passkey(id: ID!): Response!
}

CLI Configuration Flags

--enable-passkeys=false                    # Enable WebAuthn/Passkeys
--passkey-mode=primary                     # primary | mfa | both
--max-passkeys-per-user=10                 # Max registered passkeys
--webauthn-rp-name=Authorizer             # Relying Party display name
--webauthn-rp-id=                          # Relying Party ID (defaults to host)
--webauthn-rp-origins=                     # Allowed origins for WebAuthn

Testing Plan

  • Integration test: full registration flow (begin → finish → credential stored)
  • Integration test: full login flow (begin → finish → session created)
  • Test discoverable credentials (passkey login without email)
  • Test sign count validation and clone detection
  • Test max passkeys per user limit
  • Test passkey deletion
  • Test passkey as MFA second factor
  • Test conditional UI (frontend E2E)

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions