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.Migration{
{
Name: "20250115000001_create_my_table",
Up: `CREATE TABLE my_table (...)`,
Down: `DROP TABLE my_table`,
},
}
}
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
}Configuration Watchers (will be removed in a future version)
Implement PluginWithConfigWatcher to receive real-time config updates:
type PluginWithConfigWatcher interface {
OnConfigUpdate(config *Config) error
}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,
)
})
}Add Configuration Watcher (Optional)
When the configuration is updated in the database, the Config Manager plugin will call OnConfigUpdate with the new config. Use ParsePluginConfig to update your internal config struct while preserving pointer references:
func (p *MyPlugin) OnConfigUpdate(config *Config) error {
// Handle configuration updates
var newConfig MyPluginConfig
if err := util.ParsePluginConfig(config, p.Metadata().ID, &newConfig); err != nil {
p.logger.Error("Failed to load updated config", "error", err)
return err
}
p.config = newConfig
p.logger.Info("Configuration updated for MyPlugin")
return nil
}4. Register the Plugin
To use your plugin, you need to add it to your plugins array in library mode:
import (
authula "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
