package handlers import ( "encoding/base64" "fmt" "net/http" "time" "veza-backend-api/internal/services" "github.com/gin-gonic/gin" "github.com/google/uuid" "go.uber.org/zap" ) // WebAuthnHandler handles FIDO2/WebAuthn passkey endpoints. // F022: ORIGIN_FEATURES_REGISTRY.md — WebAuthn/Passkeys. type WebAuthnHandler struct { webauthnService *services.WebAuthnService logger *zap.Logger } // NewWebAuthnHandler creates a new WebAuthn handler. func NewWebAuthnHandler(webauthnService *services.WebAuthnService, logger *zap.Logger) *WebAuthnHandler { return &WebAuthnHandler{ webauthnService: webauthnService, logger: logger, } } // BeginRegistration starts the passkey registration ceremony. // POST /auth/passkeys/register/begin func (h *WebAuthnHandler) BeginRegistration(c *gin.Context) { userID, exists := c.Get("userID") if !exists { c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) return } uid, ok := userID.(uuid.UUID) if !ok { c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid user ID"}) return } username, _ := c.Get("username") usernameStr, _ := username.(string) if usernameStr == "" { usernameStr = uid.String() } ctx, cancel := WithTimeout(c.Request.Context(), 5*time.Second) defer cancel() challenge, options, err := h.webauthnService.BeginRegistration(ctx, uid, usernameStr) if err != nil { h.logger.Error("WebAuthn begin registration failed", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to begin registration"}) return } c.JSON(http.StatusOK, gin.H{ "challenge": challenge.Challenge, "options": options, }) } // FinishRegistrationRequest represents the client's registration response. type FinishRegistrationRequest struct { CredentialID string `json:"credential_id" binding:"required"` PublicKey string `json:"public_key" binding:"required"` AttestationType string `json:"attestation_type"` AAGUID string `json:"aaguid"` Name string `json:"name"` } // FinishRegistration completes the passkey registration. // POST /auth/passkeys/register/finish func (h *WebAuthnHandler) FinishRegistration(c *gin.Context) { userID, exists := c.Get("userID") if !exists { c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) return } uid, ok := userID.(uuid.UUID) if !ok { c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid user ID"}) return } var req FinishRegistrationRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) return } credentialID, err := decodeBase64URL(req.CredentialID) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid credential_id encoding"}) return } publicKey, err := decodeBase64URL(req.PublicKey) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid public_key encoding"}) return } var aaguid []byte if req.AAGUID != "" { aaguid, _ = decodeBase64URL(req.AAGUID) } attestation := req.AttestationType if attestation == "" { attestation = "none" } ctx, cancel := WithTimeout(c.Request.Context(), 5*time.Second) defer cancel() cred, err := h.webauthnService.FinishRegistration(ctx, uid, credentialID, publicKey, aaguid, attestation, req.Name) if err != nil { h.logger.Error("WebAuthn finish registration failed", zap.Error(err)) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } c.JSON(http.StatusCreated, gin.H{ "credential": cred.ToPublicInfo(), }) } // ListPasskeys returns the user's registered passkeys. // GET /auth/passkeys func (h *WebAuthnHandler) ListPasskeys(c *gin.Context) { userID, exists := c.Get("userID") if !exists { c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) return } uid, ok := userID.(uuid.UUID) if !ok { c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid user ID"}) return } ctx, cancel := WithTimeout(c.Request.Context(), 5*time.Second) defer cancel() creds, err := h.webauthnService.GetUserCredentials(ctx, uid) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list passkeys"}) return } result := make([]interface{}, 0, len(creds)) for _, cr := range creds { result = append(result, cr.ToPublicInfo()) } c.JSON(http.StatusOK, gin.H{"passkeys": result}) } // DeletePasskey removes a registered passkey. // DELETE /auth/passkeys/:id func (h *WebAuthnHandler) DeletePasskey(c *gin.Context) { userID, exists := c.Get("userID") if !exists { c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) return } uid, ok := userID.(uuid.UUID) if !ok { c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid user ID"}) return } credID, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid passkey ID"}) return } ctx, cancel := WithTimeout(c.Request.Context(), 5*time.Second) defer cancel() if err := h.webauthnService.DeleteCredential(ctx, uid, credID); err != nil { c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"message": "passkey deleted"}) } // RenamePasskeyRequest holds the new name for a passkey. type RenamePasskeyRequest struct { Name string `json:"name" binding:"required,max=100"` } // RenamePasskey updates the display name of a passkey. // PUT /auth/passkeys/:id func (h *WebAuthnHandler) RenamePasskey(c *gin.Context) { userID, exists := c.Get("userID") if !exists { c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) return } uid, ok := userID.(uuid.UUID) if !ok { c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid user ID"}) return } credID, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid passkey ID"}) return } var req RenamePasskeyRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) return } ctx, cancel := WithTimeout(c.Request.Context(), 5*time.Second) defer cancel() if err := h.webauthnService.RenameCredential(ctx, uid, credID, req.Name); err != nil { c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"message": "passkey renamed"}) } // decodeBase64URL decodes a base64url-encoded string (no padding). func decodeBase64URL(s string) ([]byte, error) { b, err := base64.RawURLEncoding.DecodeString(s) if err != nil { return nil, fmt.Errorf("invalid base64url encoding: %w", err) } return b, nil }