2025-08-02 02:48:51 -05:00
2025-08-02 02:48:51 -05:00
2025-08-02 02:48:51 -05:00
2025-08-02 02:48:51 -05:00
2025-08-02 02:48:51 -05:00
2025-08-02 02:48:51 -05:00
2025-08-02 02:48:51 -05:00

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:

  1. 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 the wiki-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 wrap wiki-security-friends with wiki-security-useraccesstokens and then wrap that with wiki-security-ratelimit, etc.

  2. 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 permissions
  • wiki-plugin-audit: Request logging and audit trails
  • wiki-plugin-ip-filter: IP-based access control
  • wiki-plugin-session-mgmt: Advanced session management
  • wiki-plugin-2fa: Two-factor authentication
  • wiki-plugin-delegation: Temporary access delegation
  • wiki-plugin-webhooks: Webhook authentication

Miscellaneous

TODOs

  • Make wiki-security-composable use regular plugins instead of wiki-authz plugins for authorization enhancers.
    • Created wiki-plugin-useraccesstokens as replacement for wiki-authz-useraccesstokens.
    • Created wiki-plugin-ratelimit as replacement for wiki-authz-ratelimit.
  • 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 and wiki-plugin-useraccesstokens.
    • Test with wiki-security-passportjs and wiki-plugin-useraccesstokens.
    • Test with wiki-security-passportjs, wiki-plugin-useraccesstokens, and wiki-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

  1. Startup Sequence: retrieveOwner()getOwner()defineRoutes()
  2. Request Processing: getUser() for identification → isAuthorized()/isAdmin() for access control
  3. Template Rendering: Both getUser() and isAuthorized() used to populate template variables
  4. 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:

  1. Single Route Limitation: Only one app.use('/security', ...) handler can exist
  2. Package Name Mismatch: The route path is based on argv.security_type, not the base provider's name
  3. Client-Side Expectations: The base provider's JavaScript expects its assets at /security/

Asset Copying Process

During initialization, wiki-security-composable:

  1. Detects the base authentication provider's package directory
  2. Cleans any existing client assets to avoid conflicts
  3. Copies all client assets from the base provider to its own client/ directory
  4. 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:

  1. Multiple Static Routes: Modifying wiki-server to serve from multiple security package paths
  2. Dynamic Route Resolution: Making /security/ redirect based on configuration
  3. Symlinks: Using filesystem symlinks (but cross-platform compatibility issues)
  4. 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:

  1. Dynamically resolve static routes based on the actual authentication provider
  2. Support multiple security-related static routes
  3. 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.

Description
No description provided
Readme 92 KiB
Languages
JavaScript 50.3%
CSS 49.5%
HTML 0.2%