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

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions