App Logo
Guides

Server-Side Plugins

Extend Authula functionality with custom plugins via Go.

Overview

Authula's plugin system lets you extend the authentication framework with custom functionality. Each plugin implements a base interface and can optionally add routes, migrations, middleware, hooks, or auth methods.


Core Architecture

Plugin Interface

Every plugin must implement this:

type Plugin interface {
    Metadata() PluginMetadata
    Config() any
    Init(ctx *PluginContext) error
    Close() error
}
MethodPurpose
Metadata()Returns plugin ID, version, description
Config()Returns the typed config struct
Init(ctx)Initialize with access to DB, logger, event bus, service registry
Close()Clean up resources on shutdown

Plugin Context

type PluginContext struct {
    DB              bun.IDB
    Logger          Logger
    EventBus        EventBus
    ServiceRegistry ServiceRegistry
    GetConfig       func() *Config
}

Service Registry

Plugins communicate by registering and retrieving services:

type ServiceRegistry interface {
    Register(name string, service any)
    Get(name string) any
}

Optional Capabilities

Plugins implement these optional interfaces to add functionality:

PluginWithMigrations — Database Migrations

type PluginWithMigrations interface {
    Migrations(provider string) []migrations.Migration
    DependsOn() []string
}
func (p *MyPlugin) Migrations(provider string) []migrations.Migration {
    return migrations.ForProvider(provider, migrations.ProviderVariants{
        "sqlite":   func() []migrations.Migration { return []migrations.Migration{mySQLite()} },
        "postgres": func() []migrations.Migration { return []migrations.Migration{myPostgres()} },
    })
}

func (p *MyPlugin) DependsOn() []string { return nil }

func mySQLite() migrations.Migration {
    return migrations.Migration{
        Version: "20250101000000_create_my_table",
        Up: func(ctx context.Context, tx bun.Tx) error {
            return migrations.ExecStatements(ctx, tx,
                `CREATE TABLE IF NOT EXISTS my_table (
                  id TEXT NOT NULL PRIMARY KEY
                );`,
            )
        },
        Down: func(ctx context.Context, tx bun.Tx) error {
            return migrations.ExecStatements(
              ctx,
              tx,
              `DROP TABLE IF EXISTS my_table;`
            )
        },
    }
}

PluginWithRoutes — HTTP Routes

type PluginWithRoutes interface {
    Routes() []Route
}
func (p *MyPlugin) Routes() []models.Route {
    return []models.Route{
        {
            Method:  "GET",
            Path:    "/api/my-plugin/status",
            Handler: http.HandlerFunc(p.handleStatus),
        },
    }
}

PluginWithMiddleware — Global Middleware

type PluginWithMiddleware interface {
    Middleware() []func(http.Handler) http.Handler
}

AuthMethodProvider — Authentication Middleware

type AuthMethodProvider interface {
    AuthMiddleware() func(http.Handler) http.Handler
    OptionalAuthMiddleware() func(http.Handler) http.Handler
}
  • AuthMiddleware() — rejects unauthenticated requests
  • OptionalAuthMiddleware() — authenticates if credentials present, allows anonymous

PluginWithHooks — Request Lifecycle Hooks

type PluginWithHooks interface {
    Hooks() []Hook
}

Four hook stages:

HookOnRequest  // Very start of every request
HookBefore     // Before route matching and handling
HookAfter      // After route handling, before response sent
HookResponse   // After response written

Hook struct:

type Hook struct {
    Stage    HookStage
    PluginID string              // Optional — filters by route metadata["plugins"]
    Matcher  HookMatcher         // Optional — filters by request context
    Handler  HookHandler         // The hook implementation
    Order    int                 // Execution order (local to PluginID)
    Async    bool                // Side-effects only — never auth/security
}

Execution rules:

  • Hooks without PluginID run for all routes; with PluginID only if listed in route metadata
  • Hooks sorted by PluginID (grouping), then by Order within each group
  • Matcher returning false skips the hook
  • Handler returning an error is logged but doesn't stop other hooks
  • Setting reqCtx.Handled = true stops subsequent hooks at that stage
  • Async hooks run in background goroutines for side-effects only (logging, analytics, events, webhooks). Never use for auth, CSRF, rate-limiting, or security.
func (p *MyPlugin) Hooks() []models.Hook {
    return []models.Hook{
        {
            Stage:    models.HookBefore,
            PluginID: "my_plugin",
            Matcher:  func(rc *models.RequestContext) bool { return strings.HasPrefix(rc.Path, "/api/secure") },
            Handler:  p.validateToken,
            Order:    10,
        },
    }
}

RequestContext

Available to all hooks and handlers:

reqCtx.Request        // *http.Request
reqCtx.ResponseWriter // http.ResponseWriter
reqCtx.Path           // string
reqCtx.Method         // string
reqCtx.Headers        // http.Header
reqCtx.Actor          // *Actor — resolved identity
reqCtx.ClientIP       // string
reqCtx.Values         // map[string]any — shared key-value store
reqCtx.Route          // *Route — matched route with metadata
reqCtx.Handled        // bool — short-circuit flag

Helper methods:

reqCtx.SetActorInContext(actor *Actor)            // Sets actor in both RequestContext and Go context
reqCtx.SetResponse(status int, headers http.Header, body []byte) // Override response
reqCtx.SetJSONResponse(status int, payload any)   // Override with JSON

Creating a Custom Plugin

1. Define the plugin

package myplugin

import (
    "net/http"

    "github.com/Authula/authula/internal/util"
    "github.com/Authula/authula/models"
    "github.com/Authula/authula/services"
)

type MyPlugin struct {
    config      MyPluginConfig
    logger      models.Logger
    ctx         *models.PluginContext
    userService services.UserService
}

type MyPluginConfig struct {
    Enabled bool   `json:"enabled" toml:"enabled"`
    SomeField  string `json:"some_field" toml:"some_field"`
}

2. Implement the base interface

func New(config MyPluginConfig) *MyPlugin {
    return &MyPlugin{config: config}
}

func (p *MyPlugin) Metadata() models.PluginMetadata {
    return models.PluginMetadata{
        ID:          "my_plugin",
        Version:     "1.0.0",
        Description: "Custom functionality",
    }
}

func (p *MyPlugin) Config() any { return p.config }

func (p *MyPlugin) Init(ctx *models.PluginContext) error {
    p.ctx = ctx
    p.logger = ctx.Logger

    if err := util.LoadPluginConfig(ctx.GetConfig(), p.Metadata().ID, &p.config); err != nil {
        return err
    }

    userSvc, ok := ctx.ServiceRegistry.Get(models.ServiceUser.String()).(services.UserService)
    if !ok {
        return fmt.Errorf("user service not available")
    }
    p.userService = userSvc

    return nil
}

func (p *MyPlugin) Close() error { return nil }

3. Add routes (optional)

func (p *MyPlugin) Routes() []models.Route {
    return []models.Route{
        {
            Method:  "GET",
            Path:    "/api/my-plugin/info",
            Handler: http.HandlerFunc(p.handleInfo),
        },
    }
}

func (p *MyPlugin) handleInfo(w http.ResponseWriter, r *http.Request) {
    util.JSONResponse(w, http.StatusOK, map[string]string{
        "plugin_id": p.Metadata().ID,
    })
}

4. Register with Authula

config := authulaconfig.NewConfig()
auth := authula.New(&authula.AuthConfig{
    Config: config,
    Plugins: []models.Plugin{
        myplugin.New(myplugin.MyPluginConfig{
            SomeField: "your-value",
        }),
    },
})

Plugins can also be loaded automatically from a config file — see the configuration docs for details.


Configuration

Example TOML:

[plugins.session]
enabled = true

[plugins.oauth2]
enabled = true

[plugins.ratelimit]
enabled = true
window = "1m"
max = 100

[plugins.my_plugin]
enabled = true
some_field = "your-value"

Best Practices

  1. Validate config in Init — return an error if invalid
  2. Clean up resources in Close
  3. Use the provided logger, not external ones
  4. Use Async: true only for side-effects (logging, analytics, events)
  5. Never make auth validation, CSRF, or rate-limiting hooks async
  6. Plugin IDs should be lowercase with underscores
  7. Use reqCtx.SetActorInContext() to set the authenticated actor
  8. Use util.JSONResponse() for consistent API responses

Complete Example: Request Logger Plugin

package logger

import (
    "fmt"
    "time"

    "github.com/Authula/authula/models"
)

type LoggerPlugin struct {
    config LoggerPluginConfig
    logger models.Logger
    ctx    *models.PluginContext
}

type LoggerPluginConfig struct {
    Enabled     bool   `json:"enabled" toml:"enabled"`
    LogRequests bool   `json:"log_requests" toml:"log_requests"`
    LogLevel    string `json:"log_level" toml:"log_level"`
}

func New(config LoggerPluginConfig) *LoggerPlugin {
    return &LoggerPlugin{config: config}
}

func (p *LoggerPlugin) Metadata() models.PluginMetadata {
    return models.PluginMetadata{
        ID:          "logger",
        Version:     "1.0.0",
        Description: "Logs incoming requests with timing",
    }
}

func (p *LoggerPlugin) Config() any { return p.config }

func (p *LoggerPlugin) Init(ctx *models.PluginContext) error {
    p.ctx = ctx
    p.logger = ctx.Logger
    return nil
}

func (p *LoggerPlugin) Close() error { return nil }

func (p *LoggerPlugin) Hooks() []models.Hook {
    return []models.Hook{
        {
            Stage:   models.HookOnRequest,
            Matcher: func(rc *models.RequestContext) bool { return p.config.LogRequests },
            Handler: p.logStart,
            Order:   10,
        },
        {
            Stage:   models.HookAfter,
            Matcher: func(rc *models.RequestContext) bool { return p.config.LogRequests },
            Handler: p.logComplete,
            Order:   20,
            Async:   true,
        },
    }
}

func (p *LoggerPlugin) logStart(rc *models.RequestContext) error {
    rc.Values["start_time"] = time.Now()
    if p.config.LogLevel == "debug" {
        p.logger.Debug(fmt.Sprintf("→ %s %s from %s", rc.Method, rc.Path, rc.ClientIP))
    }
    return nil
}

func (p *LoggerPlugin) logComplete(rc *models.RequestContext) error {
    start, ok := rc.Values["start_time"].(time.Time)
    if !ok {
        return nil
    }
    userID := ""
    if rc.Actor != nil && rc.Actor.Type == models.ActorUser {
        userID = rc.Actor.ID
    }
    elapsed := time.Since(start)
    msg := fmt.Sprintf("← %s %s%d (%v)", rc.Method, rc.Path, rc.ResponseStatus, elapsed)
    if userID != "" {
        msg += fmt.Sprintf(" user=%s", userID)
    }
    if rc.ResponseStatus >= 400 {
        p.logger.Error(msg)
    } else {
        p.logger.Info(msg)
    }
    return nil
}

On this page