Wiki Security Composable
Description
wiki-security-composable
is a composable security plugin that separates authentication from authorization while maintaining full compatibility with the existing wiki server.
This architecture allows you to mix and match authentication providers (like friends or passportjs) with multiple authorization enhancers (like user access tokens, rate limiting, etc.) to create a flexible security system.
Components
Security Plugin
wiki-security-composable
- The foundation plugin that:
- Loads a base authentication provider (friends, passportjs, etc.)
- Loads multiple authorization enhancers
- Composes them into a single security interface
- Maintains compatibility with existing wiki server expectations
The plugin uses a function composition pattern where authorization enhancers wrap the base authentication provider's methods. Each enhancer receives both the original request and a reference to the previous function in the chain, allowing it to call, modify, or completely override the previous behavior. This creates a middleware-like system where enhancers can participate in user identification, authorization checks, and admin verification.
Order matters: Enhancers are processed sequentially, with each one wrapping the previous function. The final composed function represents a chain where the last enhancer is called first. This means enhancers later in the configuration list can override decisions of earlier enhancers. For example, a rate limiting enhancer should typically come last to ensure it can block requests regardless of other authorization decisions.
Authorization Enhancers
Authorization enhancers are implemented as regular wiki-plugin-*
packages that export a securityEnhancer
function. This approach integrates seamlessly with the existing plugin system and allows plugins to provide both UI components and server-side functionality.
wiki-plugin-useraccesstokens
- Plugin that adds API token authentication and provides token management (create, list, revoke, delete) for any base authentication provider.
wiki-plugin-ratelimit
- Plugin that adds request rate limiting to prevent abuse and ensure fair usage.
Context
Security plugins in FedWiki have traditionally been monolithic, making it difficult to extend or customize security features. This composable architecture addresses that by allowing developers to create modular security components that can be combined as needed.
The need for this arose from the desire to add new security features without modifying the core wiki server or existing plugins. By separating authentication from authorization, we can create a flexible system that allows for easy extension and customization.
This composable architecture could later be adopted by the core FedWiki server, but now it serves as a proof of concept and practical implementation for developers to build upon.
Other approaches considered
In our quest to add User Access Tokens and Rate Limiting to FedWiki, we explored several approaches:
-
Stacking Wrapper Security Plugins: This approach involved creating new plugins that wrapped existing authentication plugins. As an example, we could create a
wiki-security-useraccesstokens
plugin that wraps thewiki-security-passportjs
plugin to add token support. While this would allow for adding multiple authorization features to an existing plugin, it would work by stacking wrapper plugins, which could lead to complexity and compatibility issues. We didn't want a situation where you would have to wrapwiki-security-friends
withwiki-security-useraccesstokens
and then wrap that withwiki-security-ratelimit
, etc. -
Multiple Security Plugins: This approach involved creating authorization enhancers as security plugins that could be installed alongside existing authentication plugins. For example, we could have a
wiki-security-useraccesstokens
plugin that works with any base authentication provider security plugin. This would've been implemented by making changes to the core FedWiki server to allow multiple security plugins to be loaded and composed together, making no distinction between authentication and authorization. This would have required significant changes to the core server and made it very complex to manage multiple security plugins and their interactions.
We ultimately decided against these approaches because they either added unnecessary complexity. Instead, we opted for a composable security architecture that separates authentication from authorization while maintaining full compatibility with the wiki server and existing plugins.
Usage
Basic Setup (Friends + Tokens)
Install the necessary components:
npm install wiki-security-composable wiki-plugin-useraccesstokens
Configure wiki config.json
:
{
"security_type": "wiki-security-composable",
"auth_provider": "wiki-security-friends",
"authz_enhancers": ["wiki-plugin-useraccesstokens"]
}
Run the wiki server:
wiki --security_type composable
Or configure directly from command line:
wiki --security_type composable --auth_provider wiki-security-friends --authz_enhancers wiki-plugin-useraccesstokens
Co-op Setup (OAuth + Tokens + Rate Limiting)
Install the necessary components:
npm install wiki-security-composable wiki-plugin-useraccesstokens wiki-plugin-ratelimit
Configure wiki config.json
:
{
"security_type": "wiki-security-composable",
"auth_provider": "wiki-security-passportjs",
"authz_enhancers": [
"wiki-plugin-useraccesstokens",
"wiki-plugin-ratelimit"
],
"ratelimit_config": {
"windowMs": 900000, // 15 minutes
"maxRequests": 1000,
"maxAuthRequests": 5
}
}
Migration from Existing Setups
From Friends Plugin
Current setup:
{
"security_type": "wiki-security-friends"
}
Migrated setup (no functional change):
{
"security_type": "wiki-security-composable",
"auth_provider": "wiki-security-friends"
}
Enhanced setup (adds API tokens):
{
"security_type": "wiki-security-composable",
"auth_provider": "wiki-security-friends",
"authz_enhancers": ["wiki-plugin-useraccesstokens"]
}
From PassportJS Plugin
Current setup:
{
"security_type": "wiki-security-passportjs",
"google_clientid": "...",
"google_clientsecret": "..."
}
Migrated setup:
{
"security_type": "wiki-security-composable",
"auth_provider": "wiki-security-passportjs",
"google_clientid": "...",
"google_clientsecret": "..."
}
Development
Creating New Authentication Providers
To create a new authentication provider, implement the standard security plugin interface:
module.exports = (log, loga, argv) => ({
retrieveOwner(cb),
getOwner(),
setOwner(id, cb),
getUser(req),
isAuthorized(req),
isAdmin(req),
defineRoutes(app, cors, updateOwner)
})
Creating New Authorization Enhancers
Authorization enhancers are implemented as regular plugins by exporting a securityEnhancer
function.
Create a regular wiki-plugin-*
package and export a securityEnhancer
function:
// index.js - Main plugin entry point
export const startServer = (params) => {
// Regular plugin server functionality (optional)
console.log('Plugin server started')
}
export const securityEnhancer = (log, loga, argv, baseHandler) => {
const enhancer = {}
enhancer.getUser = (req, baseGetUser) => {
// Call base handler to get user
return baseGetUser()
}
// Override authorization to check additional permissions
enhancer.isAuthorized = (req, baseIsAuthorized) => {
const baseResult = baseIsAuthorized()
if (!baseResult) return false
// Additional authorization logic here
return checkCustomPermissions(req)
}
// Override admin check
enhancer.isAdmin = (req, baseIsAdmin) => {
const baseResult = baseIsAdmin()
if (!baseResult) return false
// Additional admin checks
return checkAdminPermissions(req)
}
// Add middleware (optional)
enhancer.middleware = (req, res, next) => {
// Custom middleware logic
next()
}
// Add additional routes (optional)
enhancer.defineRoutes = (app, cors, updateOwner) => {
app.get('/auth/custom', (req, res) => {
res.json({ message: 'Custom endpoint' })
})
}
return enhancer
}
Then use it in configuration:
{
"security_type": "wiki-security-composable",
"auth_provider": "wiki-security-friends",
"authz_enhancers": ["wiki-plugin-myplugin"]
}
Testing New Components
The architecture is designed to be testable:
// Test your enhancer
const mockBaseHandler = { /* ... */ };
const enhancer = require('./my-authz-plugin')(log, loga, argv, mockBaseHandler);
// Test enhanced authorization
const req = { /* mock request */ };
const result = enhancer.isAuthorized(req, () => true);
Potential Future Enhancements
Additional authorization enhancers could include:
wiki-plugin-permissions
: Fine-grained page/action permissionswiki-plugin-audit
: Request logging and audit trailswiki-plugin-ip-filter
: IP-based access controlwiki-plugin-session-mgmt
: Advanced session managementwiki-plugin-2fa
: Two-factor authenticationwiki-plugin-delegation
: Temporary access delegationwiki-plugin-webhooks
: Webhook authentication
Miscellaneous
TODOs
- Make
wiki-security-composable
use regular plugins instead ofwiki-authz
plugins for authorization enhancers.- Created
wiki-plugin-useraccesstokens
as replacement forwiki-authz-useraccesstokens
. - Created
wiki-plugin-ratelimit
as replacement forwiki-authz-ratelimit
.
- Created
- Manually test by running the wiki server with different configurations.
- Test by itself. (Must fail if no auth provider is specified)
- Test with
wiki-security-friends
. - Test with
wiki-security-passportjs
. - Test with
wiki-security-friends
andwiki-plugin-useraccesstokens
. - Test with
wiki-security-passportjs
andwiki-plugin-useraccesstokens
. - Test with
wiki-security-passportjs
,wiki-plugin-useraccesstokens
, andwiki-plugin-ratelimit
.
- Make sure that everything in
wiki-plugin-useraccesstokens
has been implemented. - Add a README for
wiki-plugin-ratelimit
. - Make config for
wiki-plugin-ratelimit
flat like the others. - Provide explicit documentation on what the security handler interface does and how to use it, as documentation is lacking in
wiki-server
.
Security Interface Method Usage Examples
The following examples show how each security interface method is used throughout the wiki-server codebase:
Owner Management
retrieveOwner(cb)
- Server Startup
// wiki-server/lib/server.js:920
// Called during server initialization to load owner information
securityhandler.retrieveOwner(e => {
// Throw if you can't find the initial owner
if (e) throw e
owner = securityhandler.getOwner()
console.log('owner: ' + owner)
app.emit('owner-set')
})
getOwner()
- Get Current Owner
// wiki-server/lib/server.js:923
// Used immediately after retrieveOwner to get the owner value
owner = securityhandler.getOwner()
setOwner(id, cb)
- Site Claiming
// wiki-security-friends/server/friends.coffee:109
// Called when a user claims an unclaimed site
setOwner id, (err) ->
if err
console.log 'Failed to claim wiki ', req.hostname, 'error ', err
res.sendStatus(500)
updateOwner getOwner
Authentication (User Identification)
getUser(req)
- Template Data
// wiki-server/lib/server.js:329, 365
// Used in route handlers to identify current user for template rendering
user = securityhandler.getUser(req)
const info = {
title,
pages: [],
authenticated: user ? true : false,
user: user,
seedNeighbors: argv.neighbors,
owned: owner ? true : false,
isOwner: securityhandler.isAuthorized(req) ? true : false,
ownedBy: owner ? owner : '',
}
Authorization (Access Control)
isAuthorized(req)
- Write Protection Middleware
// wiki-server/lib/server.js:474-480
// Middleware function to protect write operations
const authorized = (req, res, next) => {
if (securityhandler.isAuthorized(req)) {
next()
} else {
console.log('rejecting', req.path)
res.sendStatus(403)
}
}
isAuthorized(req)
- Applied to Protected Routes
// Examples from wiki-server/lib/server.js
app.post('/favicon.png', authorized, (req, res) => { /* ... */ })
app.get('/recycler/favicon.png', authorized, (req, res) => { /* ... */ })
app.get('/recycler/system/slugs.json', authorized, (req, res) => { /* ... */ })
app.get(/^\/recycler\/([a-z0-9-]+)\.json$/, authorized, (req, res) => { /* ... */ })
app.delete(/^\/recycler\/([a-z0-9-]+)\.json$/, authorized, (req, res) => { /* ... */ })
app.get('/proxy/*', authorized, (req, res) => { /* ... */ })
app.put(/^\/page\/([a-z0-9-]+)\/action$/i, authorized, (req, res) => { /* ... */ })
app.delete(/^\/([a-z0-9-]+)\.json$/, authorized, (req, res) => { /* ... */ })
isAdmin(req)
- Admin-Only Access
// wiki-server/lib/server.js:684-690
// Middleware function for admin-only operations
const admin = (req, res, next) => {
if (securityhandler.isAdmin(req)) {
next()
} else {
console.log('rejecting', req.path)
res.sendStatus(403)
}
}
// Applied to admin routes
app.get('/system/version.json', admin, (req, res) => {
// Return system version information
})
Route Definition
defineRoutes(app, cors, updateOwner)
- Security Plugin Setup
// wiki-server/lib/server.js:268
// Called during server initialization to register security routes
securityhandler.defineRoutes(app, cors, updateOwner)
Example Route Definitions in Security Plugins:
// wiki-security-friends/server/friends.coffee:143-146
security.defineRoutes = (app, cors, updateOwner) ->
app.post '/login', cors, security.login(updateOwner)
app.get '/logout', cors, security.logout()
app.post '/auth/reclaim/', cors, security.reclaim()
Usage Pattern Analysis
- Startup Sequence:
retrieveOwner()
→getOwner()
→defineRoutes()
- Request Processing:
getUser()
for identification →isAuthorized()
/isAdmin()
for access control - Template Rendering: Both
getUser()
andisAuthorized()
used to populate template variables - Protection Pattern: Authorization methods wrapped in middleware functions for route protection
This usage pattern demonstrates clear separation between:
- Authentication:
getUser()
identifies who the user is - Authorization:
isAuthorized()
/isAdmin()
determine what they can do - Configuration: Owner management and route definition handle setup
Client Asset Management
One unique aspect of wiki-security-composable
is how it handles client-side assets. The plugin copies client assets from the base authentication provider to its own client/
directory during initialization. This might seem redundant since both packages are installed as dependencies, but it's necessary due to how the wiki server serves static assets.
Why Asset Copying is Required
The wiki server sets up a single static route for security assets:
// wiki-server/lib/server.js:284
app.use('/security', express.static(path.join(argv.packageDir, argv.security_type, 'client'), staticPathOptions))
This creates a hardcoded expectation that security assets are located at:
{packageDir}/{security_type}/client/
When security_type = "wiki-security-composable"
, the server expects assets at:
node_modules/wiki-security-composable/client/
However, the base authentication provider's assets (login forms, JavaScript, CSS) are actually located at:
node_modules/wiki-security-friends/client/
node_modules/wiki-security-passportjs/client/
The Path Resolution Problem
Unlike regular plugins, which each get their own static route (/plugins/pluginname/
), security plugins share a single /security/
route. The server cannot dynamically serve from different packages based on configuration because:
- Single Route Limitation: Only one
app.use('/security', ...)
handler can exist - Package Name Mismatch: The route path is based on
argv.security_type
, not the base provider's name - Client-Side Expectations: The base provider's JavaScript expects its assets at
/security/
Asset Copying Process
During initialization, wiki-security-composable
:
- Detects the base authentication provider's package directory
- Cleans any existing client assets to avoid conflicts
- Copies all client assets from the base provider to its own
client/
directory - Preserves the original file structure and permissions
// Simplified version of the copying logic
const baseProviderClientPath = path.join(
path.dirname(require.resolve(`${authProvider}/package.json`)),
'client'
);
const ourClientPath = path.join(__dirname, '..', 'client');
// Clean and copy
fs.rmSync(ourClientPath, { recursive: true, force: true });
copyFiles(baseProviderClientPath, ourClientPath);
Alternative Approaches Considered
This copying approach could be avoided by:
- Multiple Static Routes: Modifying wiki-server to serve from multiple security package paths
- Dynamic Route Resolution: Making
/security/
redirect based on configuration - Symlinks: Using filesystem symlinks (but cross-platform compatibility issues)
- Route Proxying: Having composable security proxy requests to appropriate packages
The copying approach was chosen because it:
- Maintains full compatibility with existing wiki-server code
- Provides asset isolation and predictable behavior
- Works reliably across different deployment scenarios
- Allows for potential asset modification if needed
Future Server Integration
If this composable architecture were adopted by the core wiki server, the asset copying would become unnecessary. The server could be modified to:
- Dynamically resolve static routes based on the actual authentication provider
- Support multiple security-related static routes
- Eliminate the assumption that
security_type
equals the package containing client assets
This represents one of the key areas where the composable architecture has to work around existing server assumptions rather than requiring server modifications.