Skip to content

MFA Providers

MFA (Multi-Factor Authentication) Providers are pluggable components that handle the delivery and verification of authentication challenges. They allow you to implement various MFA mechanisms without coupling your application logic to specific delivery services.

Overview

The MFAProvider interface defines a standard contract for all MFA providers, supporting multiple delivery channels:

  • Email - Send one-time codes via email (SendGrid)
  • SMS - Send codes via text message (Twilio)
  • TOTP - Time-based One-Time Passwords (Google Authenticator, Authy)
  • WebAuthn - FIDO2/Hardware security keys
  • Push - Push notifications to mobile apps
  • Custom - Your own implementation
  • NoOp - No-operation provider for testing

The MFAProvider Interface

go
type MFAProvider interface {
    // SendChallenge sends a one-time token/challenge to the user
    SendChallenge(ctx context.Context, params MFAChallengeParams) (*MFADeliveryReceipt, error)

    // VerifyChallenge verifies the user's response (optional for token-based MFA)
    VerifyChallenge(ctx context.Context, params MFAVerifyParams) error

    // StoreChallenge stores the challenge token
    StoreChallenge(ctx context.Context, params MFAChallengeStoreParams) error

    // ValidateChallenge validates a challenge token
    ValidateChallenge(ctx context.Context, token, action, object string) (*MFAChallengeScope, error)

    // RetrieveChallenge retrieves a challenge without validation
    RetrieveChallenge(ctx context.Context, token string) (*MFAChallengeScope, error)

    // DeleteChallenge removes a challenge token
    DeleteChallenge(ctx context.Context, token string) error

    // Type returns the provider type
    Type() MFAProviderType

    // String returns a string representation
    String() string
}

Available Implementations

Email-Based MFA (SendGrid)

Send verification codes via email using SendGrid.

Installation

go
import "github.com/sendgrid/sendgrid-go"

Configuration

go
provider := providers.NewSendgridMFAProvider(
    "SG.xxxxx",                    // SendGrid API key
    "noreply@example.com",         // Sender email
    "Example App",                 // Sender name
    cacheStore,                    // CacheStore for token storage
)

Template Customization

Override the default template for specific actions:

go
provider.WithTemplate(
    "auth-account-login-opaque",
    "Login Verification for {appName}",
    "Your login code is: {code}\n\nValid for {expiresIn} minutes.\n\nClick here: https://myapp.com/auth/login/{code}",
)

Available Placeholders:

  • {code} - The one-time verification code
  • {appName} - Application name from metadata
  • {expiresIn} - Expiration duration from metadata
  • Any key from MFAChallengeParams.Metadata

Features

  • Privacy-friendly (tracking disabled by default)
  • Custom templates per action
  • Metadata placeholder support
  • Delivery receipts with status tracking

SMS-Based MFA (Twilio)

Send verification codes via SMS using Twilio.

Installation

go
import "github.com/twilio/twilio-go"

Configuration

go
provider := providers.NewTwilioMFAProvider(
    "ACxxxxx",         // Twilio Account SID
    "auth_token",      // Twilio Auth Token
    "+1234567890",     // From phone number (E.164 format)
    cacheStore,        // CacheStore for token storage
)

Template Customization

go
provider.WithTemplate(
    "auth-account-login-opaque",
    "Your login code is: {code}. Valid for {expiresIn} minutes.",
)

Note: SMS providers only use the message template (no subject line).

Available Placeholders:

  • {code} - The one-time verification code
  • Any key from MFAChallengeParams.Metadata

Template System

How Templates Work

  1. Action-Based: Templates are registered for specific actions
  2. Placeholder Replacement: {key} placeholders are replaced with actual values
  3. Fallback: If no template is registered, a default template is used
  4. Metadata Integration: All metadata values are available as placeholders

Common Actions

Actions are typically formatted as: domain-component-operation-details

Examples:

  • auth-account-login-opaque - Opaque login authentication
  • auth-account-login-emailpassword - Email/password login
  • auth-account-register-opaque - Opaque registration
  • auth-account-password-reset - Password reset flow

Template Example

go
// Register custom template
provider.WithTemplate(
    "auth-account-login-opaque",
    "Security Code for {appName}",
    `Your security code is: {code}

This code will expire in {expiresIn} minutes.

If you didn't request this code, please ignore this message.

IP Address: {ipAddress}`,
)

// When sending, provide metadata
params := comby.MFAChallengeParams{
    Recipient:     "user@example.com",
    ChallengeCode: "123456",
    Action:        "auth-account-login-opaque",
    Metadata: map[string]string{
        "appName":   "My Application",
        "expiresIn": "5",
        "ipAddress": "192.168.1.1",
    },
}

receipt, err := provider.SendChallenge(ctx, params)

Resulting Message:

Subject: Security Code for My Application

Your security code is: 123456

This code will expire in 5 minutes.

If you didn't request this code, please ignore this message.

IP Address: 192.168.1.1

Challenge Lifecycle

1. Send Challenge

go
receipt, err := provider.SendChallenge(ctx, comby.MFAChallengeParams{
    Recipient:     "user@example.com",
    ChallengeCode: "123456",
    Action:        "auth-account-login-opaque",
    Metadata: map[string]string{
        "appName":   "My App",
        "expiresIn": "5",
    },
})

2. Store Challenge

go
err := provider.StoreChallenge(ctx, comby.MFAChallengeStoreParams{
    OneTimeToken:       "123456",
    Action:             "auth-account-login-opaque",
    Object:             "user@example.com",
    ExpirationDuration: 5 * time.Minute,
})

3. Validate Challenge

go
scope, err := provider.ValidateChallenge(
    ctx,
    "123456",                      // Token provided by user
    "auth-account-login-opaque",   // Expected action
    "user@example.com",            // Expected object
)

4. Delete Challenge

go
err := provider.DeleteChallenge(ctx, "123456")

Implementing Custom MFA Providers

To implement your own MFA provider:

  1. Implement the Interface: Implement all methods of MFAProvider
  2. Choose Provider Type: Return appropriate MFAProviderType
  3. Handle Token Storage: Use CacheStore or your own storage mechanism
  4. Support Templates: Allow users to customize messages
  5. Provide Configuration: Accept necessary credentials and settings

Example Structure

go
type CustomMFAProvider struct {
    config Config
    cacheStore comby.CacheStore
    templates map[string]comby.MFATemplate
}

func NewCustomMFAProvider(config Config, cache comby.CacheStore) *CustomMFAProvider {
    return &CustomMFAProvider{
        config:     config,
        cacheStore: cache,
        templates:  make(map[string]comby.MFATemplate),
    }
}

func (p *CustomMFAProvider) WithTemplate(action, subject, message string) *CustomMFAProvider {
    p.templates[action] = comby.MFATemplate{
        Subject: subject,
        Message: message,
    }
    return p
}

func (p *CustomMFAProvider) SendChallenge(ctx context.Context, params comby.MFAChallengeParams) (*comby.MFADeliveryReceipt, error) {
    // 1. Get template for action
    template := p.getTemplate(params.Action)

    // 2. Replace placeholders
    message := strings.ReplaceAll(template.Message, "{code}", params.ChallengeCode)
    for key, value := range params.Metadata {
        placeholder := fmt.Sprintf("{%s}", key)
        message = strings.ReplaceAll(message, placeholder, value)
    }

    // 3. Send via your delivery mechanism
    deliveryID, err := p.sendViaCustomChannel(params.Recipient, message)
    if err != nil {
        return nil, err
    }

    // 4. Return receipt
    return &comby.MFADeliveryReceipt{
        DeliveryID: deliveryID,
        Timestamp:  time.Now().Unix(),
        Metadata: map[string]string{
            "recipient": params.Recipient,
            "action":    params.Action,
        },
    }, nil
}

// Implement remaining interface methods...

Token Verification Methods

Simple Token-Based (Email/SMS)

For email and SMS-based MFA, verification is straightforward:

go
func (p *Provider) VerifyChallenge(ctx context.Context, params comby.MFAVerifyParams) error {
    // No server-side verification needed
    // Token possession proves email/phone access
    return nil
}

The actual token validation happens through ValidateChallenge, which checks:

  • Token exists in cache
  • Token hasn't expired
  • Action matches
  • Object matches

Provider-Side Verification (TOTP/WebAuthn)

For TOTP and WebAuthn, implement actual verification:

go
func (p *TOTPProvider) VerifyChallenge(ctx context.Context, params comby.MFAVerifyParams) error {
    // Verify TOTP code using user's secret
    secret, err := p.getUserSecret(ctx, params.UserID)
    if err != nil {
        return err
    }

    valid := totp.Validate(params.Code, secret)
    if !valid {
        return fmt.Errorf("invalid TOTP code")
    }

    return nil
}

Best Practices

Security

  1. Short Expiration: Keep token expiration short (5-15 minutes)
  2. Single Use: Delete tokens after successful authentication
  3. Rate Limiting: Implement throttling on challenge generation
  4. Secure Storage: Use secure cache stores for token storage
  5. No Secrets in Logs: Never log challenge codes or sensitive data

Template Design

  1. Clear Instructions: Make it obvious what the user should do
  2. Expiration Notice: Always mention when the code expires
  3. Branding: Include your app name for recognition
  4. Action Context: Explain why they're receiving the code
  5. Security Warning: Advise users not to share the code

Error Handling

go
receipt, err := provider.SendChallenge(ctx, params)
if err != nil {
    // Log error details for debugging
    logger.Error("failed to send MFA challenge",
        "error", err,
        "action", params.Action,
        "recipient", maskEmail(params.Recipient))

    // Return generic error to user
    return fmt.Errorf("failed to send verification code")
}

Testing

Use NoOp provider during testing:

go
// In test environment
mfaProvider = &NoOpMFAProvider{
    cacheStore: testCacheStore,
}

// In production
mfaProvider = providers.NewSendgridMFAProvider(
    os.Getenv("SENDGRID_API_KEY"),
    "noreply@example.com",
    "My App",
    cacheStore,
)

Common Metadata Keys

KeyDescriptionExample
appNameApplication name"My Application"
expiresInExpiration duration"5" (minutes)
linkUrlDeep link URL"https://app.com/verify/{code}"
ipAddressRequest IP address"192.168.1.1"
userAgentUser's browser/device"Mozilla/5.0..."
timestampRequest timestamp"2025-01-15 10:30:00 UTC"

Troubleshooting

Challenge Not Delivered

Email Providers:

  • Verify API credentials are correct
  • Check sender email is verified with provider
  • Review provider's sending limits
  • Check spam/junk folder

SMS Providers:

  • Verify phone number format (E.164)
  • Check Twilio account balance
  • Ensure phone number is verified (sandbox mode)
  • Review regional restrictions

Token Validation Fails

go
scope, err := provider.ValidateChallenge(ctx, token, action, object)
if err != nil {
    // Common causes:
    // - Token expired (check ExpirationDuration)
    // - Action mismatch (ensure consistent action strings)
    // - Object mismatch (case-sensitive email comparison)
    // - Token not found (already deleted or never stored)
}

Template Placeholders Not Replaced

Ensure metadata keys match placeholder names:

go
// Template: "Code {code} expires in {expiresIn} minutes"
// Metadata must include exact keys:
Metadata: map[string]string{
    "expiresIn": "5",  // NOT "expires_in" or "expirationTime"
}