Server-Side Plugins
Extend Authula functionality with custom plugins via Go.
Overview
The Authula plugin system provides a flexible and extensible architecture that allows developers to extend the authentication framework with custom functionality. The plugin system is designed around interfaces and follows a modular approach that enables:
- Easy integration of new authentication methods
- Custom hooks and routing
- Database migrations
- Request lifecycle hooks
Core Architecture
Plugin IDs
Each plugin is identified by a PluginID constant e.g. email_password, oauth2, session. This allows plugins to be referenced in route metadata and hook definitions for conditional execution.
Plugin Interface
Every plugin must implement the base Plugin interface:
type Plugin interface {
Metadata() PluginMetadata
Config() any
Init(ctx *PluginContext) error
Close() error
}Metadata()- Returns information about the plugin (ID, version, description)Config()- Returns the plugin's configuration structureInit(ctx *PluginContext)- Initializes the plugin with the provided contextClose()- Cleans up resources when the plugin is shut down
Plugin Context
The PluginContext provides plugins with access to core services:
type PluginContext struct {
DB bun.IDB // Database connection
Logger Logger // Logging service
EventBus EventBus // Event publishing/subscribing
ServiceRegistry ServiceRegistry // Access to other services
GetConfig func() *Config // Function to get current config
}Plugin Registry
The PluginRegistry manages the lifecycle of all plugins:
type PluginRegistry interface {
Register(p Plugin) error
InitAll() error
RunMigrations(ctx context.Context) error
DropMigrations(ctx context.Context) error
Plugins() []Plugin
GetConfig() *Config
CloseAll()
GetPlugin(pluginID string) Plugin
}Plugin Capabilities
Plugins can implement optional interfaces to provide additional functionality:
Database Migrations
Implement PluginWithMigrations to include database schema changes:
type PluginWithMigrations interface {
Migrations(provider string) []migrations.Migration
DependsOn() []string
}Example implementation:
func (p *MyPlugin) Migrations(provider string) []migrations.Migration {
return migrations.ForProvider(provider, migrations.ProviderVariants{
"sqlite": func() []migrations.Migration { return []migrations.Migration{sqliteInitial()} },
"postgres": func() []migrations.Migration { return []migrations.Migration{postgresInitial()} },
"mysql": func() []migrations.Migration { return []migrations.Migration{mysqlInitial()} },
})
}
// Ideally place these in the following location within the plugin folder: /plugin-folder/migrationset/migrations.go
func sqliteInitial() migrations.Migration {
return migrations.Migration{
Version: "yyyymmdd000000_some_table_initial",
Up: func(ctx context.Context, tx bun.Tx) error {
return migrations.ExecStatements(
ctx,
tx,
`PRAGMA foreign_keys = ON;`,
`CREATE TABLE IF NOT EXISTS some_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 some_table;`,
)
},
}
}
func postgresInitial() migrations.Migration {
// ...
}
func mysqlInitial() migrations.Migration {
// ...
}
func (p *MyPlugin) DependsOn() []string {
// Return IDs of plugins this one depends on
return []string{} // or e.g., []string{"session", "email"}
}Migration files should follow the naming pattern:
<timestamp>_<description>- Should be descriptive and unique
HTTP Routes
Implement PluginWithRoutes to add custom API endpoints:
type PluginWithRoutes interface {
Routes() []Route
}Example implementation:
func (p *MyPlugin) Routes() []models.Route {
return []models.Route{
{
Method: "GET",
Path: "/api/my-plugin/status",
Handler: http.HandlerFunc(p.handleStatus),
},
{
Method: "POST",
Path: "/api/my-plugin/action",
Middleware: []func(http.Handler) http.Handler{
p.authMiddleware(),
},
Handler: http.HandlerFunc(p.handleAction),
},
}
}Middleware (will be removed in favor of hooks in a future version)
Implement PluginWithMiddleware to add global middleware:
type PluginWithMiddleware interface {
Middleware() []func(http.Handler) http.Handler
}Authentication Method Provider
Implement AuthMethodProvider to provide authentication mechanisms (e.g., session, bearer token, OAuth2):
type AuthMethodProvider interface {
AuthMiddleware() func(http.Handler) http.Handler
OptionalAuthMiddleware() func(http.Handler) http.Handler
}AuthMiddleware()- Returns middleware that enforces authentication (fails if user not authenticated)OptionalAuthMiddleware()- Returns middleware that authenticates if credentials present, but allows unauthenticated requests
Global Middleware Provider
Implement MiddlewareProvider to provide global middleware independent of routes:
type MiddlewareProvider interface {
Middleware() func(http.Handler) http.Handler
}Request Lifecycle Hooks
Implement PluginWithHooks to intercept and modify the request lifecycle:
type PluginWithHooks interface {
Hooks() []Hook
}Hook Stages define when a hook executes:
type HookStage int
const (
HookOnRequest HookStage = iota // Executed for every request at the very start
HookBefore // Executed before route matching and handling
HookAfter // Executed after route handling but before response is sent
HookOnResponse // Executed after the response has been written
)Hook Definition:
type Hook struct {
Stage HookStage // When the hook executes
PluginID string // Plugin capability ID (optional)
Matcher HookMatcher // Optional function to filter requests
Handler HookHandler // The hook implementation
Order int // Execution order (lower first, local to PluginID)
Async bool // If true, runs in background without blocking
}
type HookMatcher func(reqCtx *RequestContext) bool
type HookHandler func(reqCtx *RequestContext) errorYou can find all the info regarding context here: RequestContext
Common helper methods:
SetUserIDInContext(userID string)- Sets user ID in both RequestContext and Go contextSetResponse(status int, headers http.Header, body []byte)- Capture custom responseSetJSONResponse(status int, payload any)- Capture JSON response
Execution Semantics:
- Hooks at each stage execute in order: sorted by
PluginIDfirst (grouping), then byOrderwithin each plugin Ordervalues are local to a plugin (only compared against other hooks with the samePluginID)- Hooks without a
PluginIDexecute for all routes; hooks withPluginIDonly execute if listed in route metadatapluginsfield - If a hook's
Matcherreturns false, the hook is skipped for that request - If a hook's
Handlerreturns an error, it is logged but does not stop further hook execution - If a hook sets
reqCtx.Handled=true, execution of subsequent hooks at that stage stops - Async hooks (
Async=true) execute in background goroutines and do not block the response- Use async only for side-effects: logging, analytics, events, webhooks, secondary storage
- Must never be used for auth validation, CSRF checks, rate-limiting, or security-critical operations
- Execute with timeout to prevent goroutine leaks and have no access to response writer
Example hook implementation:
func (p *MyPlugin) Hooks() []Hook {
return []Hook{
{
Stage: HookBefore,
PluginID: "my-plugin",
Matcher: p.matchSpecificPaths,
Handler: p.validateRequest,
Order: 10,
Async: false,
},
{
Stage: HookAfter,
Handler: p.logRequestAsync, // Side-effect only
Order: 100,
Async: true,
},
}
}
func (p *MyPlugin) matchSpecificPaths(reqCtx *RequestContext) bool {
// Only execute hook for specific paths
return strings.HasPrefix(reqCtx.Path, "/api/protected")
}
func (p *MyPlugin) validateRequest(reqCtx *RequestContext) error {
// Critical auth logic - must NOT be async
token := reqCtx.Headers.Get("Authorization")
if token == "" {
return fmt.Errorf("missing authorization token")
}
// Set user ID using the provided helper method
p.validateToken(token, reqCtx)
return nil
}
func (p *MyPlugin) validateToken(token string, reqCtx *RequestContext) {
// Validate token and extract user ID
userID := "validated-user-id"
reqCtx.SetUserIDInContext(userID)
}
func (p *MyPlugin) logRequestAsync(reqCtx *RequestContext) error {
// Side-effect logging - safe to be async
userIDStr := ""
if reqCtx.UserID != nil {
userIDStr = *reqCtx.UserID
}
p.logger.Info("Request processed",
"method", reqCtx.Method,
"path", reqCtx.Path,
"client_ip", reqCtx.ClientIP,
"user", userIDStr,
)
return nil
}Creating a Custom Plugin
Here's a step-by-step guide to creating a custom plugin:
1. Define the Plugin Struct
package myplugin
import (
"context"
"net/http"
"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"`
APIKey string `json:"api_key" toml:"api_key"`
Timeout int `json:"timeout" toml:"timeout"`
}2. Implement the Base Plugin 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: "Provides custom functionality for my application",
}
}
func (p *MyPlugin) Config() any {
return p.config
}
func (p *MyPlugin) Init(ctx *models.PluginContext) error {
p.ctx = ctx
p.logger = ctx.Logger
// Initialize services from the service registry
userService, ok := ctx.ServiceRegistry.Get(models.ServiceUser.String()).(services.UserService)
if !ok {
return fmt.Errorf("user service not available")
}
p.userService = userService
// Additional initialization logic here
p.logger.Info("MyPlugin initialized successfully")
return nil
}
func (p *MyPlugin) Close() error {
// Cleanup resources
p.logger.Info("MyPlugin closed")
return nil
}3. Implement Optional Capabilities
Add HTTP Routes (Optional)
func (p *MyPlugin) Routes() []Route {
return []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]interface{}{
"plugin_id": p.Metadata().ID,
"version": p.Metadata().Version,
"description": p.Metadata().Description,
})
}Add Middleware (Optional)
func (p *MyPlugin) Middleware() []func(http.Handler) http.Handler {
return []func(http.Handler) http.Handler{
p.customLoggingMiddleware,
}
}
func (p *MyPlugin) customLoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
startTime := time.Now()
p.logger.Info(
"Request started",
"method", r.Method,
"path", r.URL.Path,
"timestamp", startTime,
)
next.ServeHTTP(w, r)
duration := time.Since(startTime)
p.logger.Info(
"Request completed",
"method", r.Method,
"path", r.URL.Path,
"duration", duration,
)
})
}4. Register the Plugin
To use your plugin, you need to add it to your plugins array in library mode:
import (
"github.com/Authula/authula"
authulaconfig "github.com/Authula/authula/config"
authulamodels "github.com/Authula/authula/models"
myplugin "path/to/your/plugin"
myplugintypes "path/to/your/plugin/types"
)
config := authulaconfig.NewConfig(/*...*/)
auth := authula.New(&authula.AuthConfig{
Config: config,
Plugins: []authulamodels.Plugin{
myplugin.New(myplugintypes.MyPluginConfig{
Enabled: true,
APIKey: "your-api-key",
Timeout: 30,
}),
},
})Configuration
Plugins are configured through the main configuration file. Example configuration:
[plugins.session]
enabled = true
[plugins.oauth2]
enabled = true
[plugins.oauth2.providers.google]
enabled = true
redirect_url = "http://localhost:8080/api/auth/oauth2/callback/google"
[plugins.ratelimit]
enabled = true
window = "1m"
max = 100
prefix = "ratelimit:"
provider = "redis" # Options: "memory", "redis", "database"
# [plugins.ratelimit.custom_rules]
# "/path/to/your/endpoint" = { disabled = false, window = "1m", max = 5, prefix = "" }Best Practices
- Always implement proper error handling in the
Initmethod - Clean up resources in the
Closemethod - Use the provided logger for logging instead of external loggers
- Validate configuration during initialization
- Follow naming conventions for plugin IDs (lowercase with underscores)
- Provide meaningful metadata for your plugins
- Use async hooks for side-effect operations that shouldn't block responses
- Consider security implications when implementing authentication methods
- Test your plugin thoroughly, especially edge cases and error conditions
- Document your plugin's configuration options and behavior
Example: Complete Request Logging Plugin
Here's a complete example of a plugin that logs incoming requests with timing and detailed information:
package logger
import (
"fmt"
"net/http"
"strings"
"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"` // "debug", "info"
}
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 and detailed information",
}
}
func (p *LoggerPlugin) Config() any {
return p.config
}
func (p *LoggerPlugin) Init(ctx *models.PluginContext) error {
p.ctx = ctx
p.logger = ctx.Logger
if p.config.LogRequests {
p.logger.Info("Request logging enabled", "level", p.config.LogLevel)
}
return nil
}
func (p *LoggerPlugin) Hooks() []models.Hook {
return []models.Hook{
// Log the start of each request
{
Stage: models.HookOnRequest,
Matcher: func(reqCtx *models.RequestContext) bool {
return p.config.LogRequests
},
Handler: p.logRequestStart,
Order: 10,
Async: false, // Must be sync to capture start time
},
// Log after request handling (side-effect logging)
{
Stage: models.HookAfter,
Matcher: func(reqCtx *models.RequestContext) bool {
return p.config.LogRequests
},
Handler: p.logRequestMetrics,
Order: 20,
Async: true, // Safe as async - side-effect only
},
}
}
func (p *LoggerPlugin) logRequestStart(reqCtx *models.RequestContext) error {
startTime := time.Now()
reqCtx.Values["start_time"] = startTime
if p.config.LogLevel == "debug" {
p.logger.Debug(fmt.Sprintf(
"Request started: %s %s from %s",
reqCtx.Method,
reqCtx.Path,
reqCtx.ClientIP,
))
}
return nil
}
func (p *LoggerPlugin) logRequestMetrics(reqCtx *models.RequestContext) error {
startTime, ok := reqCtx.Values["start_time"].(time.Time)
if !ok {
return nil // No start time recorded
}
duration := time.Since(startTime)
statusCode := reqCtx.ResponseStatus
userIDStr := ""
if reqCtx.UserID != nil {
userIDStr = *reqCtx.UserID
}
logMsg := fmt.Sprintf(
"Request completed: %s %s -> %d in %v",
reqCtx.Method,
reqCtx.Path,
statusCode,
duration,
)
if userIDStr != "" {
logMsg += fmt.Sprintf(" (User: %s)", userIDStr)
}
// Log errors with Error level, others with Info
if statusCode >= 400 {
p.logger.Error(logMsg)
} else {
p.logger.Info(logMsg)
}
return nil
}
func (p *LoggerPlugin) Close() error {
p.logger.Info("Request logger plugin shutting down")
return nil
}Key points from this example:
- The
logRequestStarthook is synchronous (Async: false) because it needs to capture the start time before any other processing - The
logRequestMetricshook is asynchronous (Async: true) because it's a side-effect (logging) that shouldn't hold up the response - Both hooks use matchers to conditionally execute based on configuration
- Errors are logged at the appropriate level based on HTTP status code
- The plugin gracefully handles missing start time in the metrics hook
