diff --git a/factory.json b/factory.json new file mode 100644 index 0000000..d89f6c3 --- /dev/null +++ b/factory.json @@ -0,0 +1,5 @@ +{ + "name": "User Access Tokens", + "category": "security", + "description": "Manage API tokens for programmatic authentication" +} diff --git a/index.js b/index.js new file mode 100644 index 0000000..0cb76c2 --- /dev/null +++ b/index.js @@ -0,0 +1,195 @@ +/** + * Wiki Plugin: User Access Tokens + * + * This plugin provides User Access Token functionality as a security enhancer + * for the wiki-security-composable system. It is designed to work only with + * the composable security architecture, not as a standalone plugin. + */ + +import { TokenManager } from './server/server.js' + +/** + * Security enhancer functionality + * Called by wiki-security-composable when this plugin is used as an auth enhancer + */ +export const securityEnhancer = (log, loga, argv, baseHandler) => { + const enhancer = {} + + // Initialize token manager + const tokenManager = new TokenManager(argv.status) + + // Helper function to get current user from enhanced authentication + const getCurrentUser = (req) => { + return enhancer.getUser(req, () => baseHandler.getUser(req)) + } + + // Enhanced authorization check that includes token-based auth + enhancer.isAuthorized = (req, baseIsAuthorized) => { + // First check if base authentication already authorizes + if (baseIsAuthorized()) { + return true + } + + // Check for token-based authorization + if (req.tokenAuth && req.tokenAuth.user) { + return true + } + + return false + } + + // Enhanced user identification that includes token users + enhancer.getUser = (req, baseGetUser) => { + // If we have token auth, return the token user + if (req.tokenAuth && req.tokenAuth.user) { + return req.tokenAuth.user + } + + // Otherwise, use base authentication + return baseGetUser() + } + + // Helper function to extract Bearer token from Authorization header + // Implements robust parsing according to HTTP specification: + // - Case-insensitive header name and scheme + // - Tolerates whitespace after colon and between scheme and token + const extractBearerToken = (req) => { + // Get authorization header (case-insensitive) + const authHeader = Object.keys(req.headers) + .find(key => key.toLowerCase() === 'authorization') + + if (!authHeader || !req.headers[authHeader]) { + return null + } + + const headerValue = req.headers[authHeader] + + // Parse Authorization header with regex for robustness + // Pattern: optional whitespace, "bearer" (case-insensitive), required whitespace, token + const bearerMatch = headerValue.match(/^\s*bearer\s+(.+)$/i) + + if (bearerMatch) { + return bearerMatch[1].trim() // Return the token part, trimmed + } + + return null + } + + // Middleware for token validation - this runs on all requests + enhancer.middleware = async (req, res, next) => { + const token = extractBearerToken(req) + + if (token) { + try { + const tokenRecord = await tokenManager.validateToken(token) + if (tokenRecord) { + // Set token auth context for use by enhanced methods + req.tokenAuth = { + user: tokenRecord.user, + scopes: tokenRecord.scopes, + tokenName: tokenRecord.name + } + } + } catch (error) { + console.error('Token validation error:', error) + } + } + + next() + } + + // Additional routes for token management + enhancer.defineRoutes = (app, cors, updateOwner) => { + // Middleware to check if user is authenticated via enhanced handler + const authenticated = function (req, res, next) { + // Use the enhanced authorization that includes token auth + const isAuthorized = enhancer.isAuthorized(req, () => { + return baseHandler.isAuthorized(req) + }) + + if (!isAuthorized) { + return res.status(401).json({ error: 'Must be authenticated' }) + } + + return next() + } + + // POST /plugin/useraccesstokens/create - Create a new token + app.post('/plugin/useraccesstokens/create', authenticated, async (req, res) => { + try { + const user = getCurrentUser(req) + const { name, expiresInDays } = req.body + + if (!name || typeof name !== 'string' || name.trim() === '') { + return res.status(400).json({ error: 'Token name is required' }) + } + + const result = await tokenManager.createToken(user, name.trim(), expiresInDays) + + res.json({ + token: result.token, + name: result.record.name, + displayHint: result.record.displayHint, + created: result.record.created, + expires: result.record.expires, + scopes: result.record.scopes + }) + } catch (error) { + res.status(400).json({ error: error.message }) + } + }) + + // GET /plugin/useraccesstokens/list - List user's tokens + app.get('/plugin/useraccesstokens/list', authenticated, async (req, res) => { + try { + const user = getCurrentUser(req) + const tokens = await tokenManager.listTokens(user) + res.json(tokens) + } catch (error) { + res.status(500).json({ error: error.message }) + } + }) + + // POST /plugin/useraccesstokens/revoke - Revoke a token + app.post('/plugin/useraccesstokens/revoke', authenticated, async (req, res) => { + try { + const user = getCurrentUser(req) + const { name } = req.body + + if (!name) { + return res.status(400).json({ error: 'Token name is required' }) + } + + await tokenManager.revokeToken(user, name) + res.json({ message: `Token "${name}" revoked successfully` }) + } catch (error) { + res.status(400).json({ error: error.message }) + } + }) + + // DELETE /plugin/useraccesstokens/delete - Delete a token + app.delete('/plugin/useraccesstokens/delete', authenticated, async (req, res) => { + try { + const user = getCurrentUser(req) + const { name } = req.body + + if (!name) { + return res.status(400).json({ error: 'Token name is required' }) + } + + await tokenManager.deleteToken(user, name) + res.json({ message: `Token "${name}" deleted successfully` }) + } catch (error) { + res.status(400).json({ error: error.message }) + } + }) + + log('UserAccessTokens security enhancer routes registered') + } + + log('UserAccessTokens security enhancer initialized') + return enhancer +} + +// Export TokenManager for testing and direct use +export { TokenManager } diff --git a/package.json b/package.json index 3264f11..653f1d2 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "wiki-plugin-useraccesstokens", "version": "0.1.0", "description": "Federated Wiki plugin for managing User Access Tokens (API tokens) for programmatic authentication", + "main": "index.js", "keywords": [ "wiki", "federated wiki", diff --git a/server/server.js b/server/server.js index 23c7c35..b2b51e3 100644 --- a/server/server.js +++ b/server/server.js @@ -1,5 +1,5 @@ -// useraccesstokens plugin, server-side component -// These handlers are launched with the wiki server. +// useraccesstokens plugin, TokenManager class +// Provides token management functionality for the security enhancer import fs from 'node:fs' import fsp from 'node:fs/promises' @@ -57,7 +57,7 @@ class TokenManager { const tokens = await this.loadTokens() // Check if token name already exists for this user - const existingToken = tokens.find(t => t.user === user && t.name === name && !t.revoked) + const existingToken = tokens.find(t => JSON.stringify(t.user) === JSON.stringify(user) && t.name === name && !t.revoked) if (existingToken) { throw new Error(`Token with name "${name}" already exists`) } @@ -87,17 +87,20 @@ class TokenManager { async listTokens(user) { const tokens = await this.loadTokens() - return tokens - .filter(t => t.user === user) - .map(t => { - const { tokenHash, ...safeToken } = t - return safeToken - }) + + const filteredTokens = tokens.filter(t => { + return JSON.stringify(t.user) === JSON.stringify(user) + }) + + return filteredTokens.map(t => { + const { tokenHash, ...safeToken } = t + return safeToken + }) } async revokeToken(user, name) { const tokens = await this.loadTokens() - const tokenIndex = tokens.findIndex(t => t.user === user && t.name === name) + const tokenIndex = tokens.findIndex(t => JSON.stringify(t.user) === JSON.stringify(user) && t.name === name) if (tokenIndex === -1) { throw new Error(`Token "${name}" not found`) @@ -111,7 +114,7 @@ class TokenManager { async deleteToken(user, name) { const tokens = await this.loadTokens() - const tokenIndex = tokens.findIndex(t => t.user === user && t.name === name) + const tokenIndex = tokens.findIndex(t => JSON.stringify(t.user) === JSON.stringify(user) && t.name === name) if (tokenIndex === -1) { throw new Error(`Token "${name}" not found`) @@ -148,124 +151,4 @@ class TokenManager { } } -const startServer = async function (params) { - const { app, argv } = params - - // Initialize token manager - const tokenManager = new TokenManager(argv.status) - - // Middleware to check if user is authenticated - const authenticated = function (req, res, next) { - if (!app.securityhandler.isAuthorized(req)) { - return res.status(401).json({ error: 'Must be authenticated' }) - } - return next() - } - - // Middleware to get current user - const getCurrentUser = function (req) { - return app.securityhandler.getUser(req) - } - - // API Routes - - // POST /plugin/useraccesstokens/create - Create a new token - app.post('/plugin/useraccesstokens/create', authenticated, async (req, res) => { - try { - const user = getCurrentUser(req) - const { name, expiresInDays } = req.body - - if (!name || typeof name !== 'string' || name.trim() === '') { - return res.status(400).json({ error: 'Token name is required' }) - } - - const result = await tokenManager.createToken(user, name.trim(), expiresInDays) - - res.json({ - token: result.token, - name: result.record.name, - displayHint: result.record.displayHint, - created: result.record.created, - expires: result.record.expires, - scopes: result.record.scopes - }) - } catch (error) { - res.status(400).json({ error: error.message }) - } - }) - - // GET /plugin/useraccesstokens/list - List user's tokens - app.get('/plugin/useraccesstokens/list', authenticated, async (req, res) => { - try { - const user = getCurrentUser(req) - const tokens = await tokenManager.listTokens(user) - res.json(tokens) - } catch (error) { - res.status(500).json({ error: error.message }) - } - }) - - // POST /plugin/useraccesstokens/revoke - Revoke a token - app.post('/plugin/useraccesstokens/revoke', authenticated, async (req, res) => { - try { - const user = getCurrentUser(req) - const { name } = req.body - - if (!name) { - return res.status(400).json({ error: 'Token name is required' }) - } - - await tokenManager.revokeToken(user, name) - res.json({ message: `Token "${name}" revoked successfully` }) - } catch (error) { - res.status(400).json({ error: error.message }) - } - }) - - // DELETE /plugin/useraccesstokens/delete - Delete a token - app.delete('/plugin/useraccesstokens/delete', authenticated, async (req, res) => { - try { - const user = getCurrentUser(req) - const { name } = req.body - - if (!name) { - return res.status(400).json({ error: 'Token name is required' }) - } - - await tokenManager.deleteToken(user, name) - res.json({ message: `Token "${name}" deleted successfully` }) - } catch (error) { - res.status(400).json({ error: error.message }) - } - }) - - // Middleware for token-based authentication - app.use('/api', async (req, res, next) => { - const authHeader = req.headers.authorization - - if (authHeader && authHeader.startsWith('Bearer ')) { - const token = authHeader.substring(7) - - try { - const tokenRecord = await tokenManager.validateToken(token) - if (tokenRecord) { - // Set user context for token-based requests - req.tokenAuth = { - user: tokenRecord.user, - scopes: tokenRecord.scopes, - tokenName: tokenRecord.name - } - return next() - } - } catch (error) { - console.error('Token validation error:', error) - } - } - - next() - }) - - console.log('UserAccessTokens plugin server started') -} - -export { startServer, TokenManager } +export { TokenManager } diff --git a/test/middleware-integration.test.js b/test/middleware-integration.test.js new file mode 100644 index 0000000..970f937 --- /dev/null +++ b/test/middleware-integration.test.js @@ -0,0 +1,205 @@ +import { suite, test } from 'node:test' +import assert from 'node:assert' +import { TokenManager } from '../server/server.js' +import fs from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +// Import the securityEnhancer to test middleware +import { securityEnhancer } from '../index.js' + +suite('Bearer token middleware integration', () => { + let tempDir + let tokenManager + let middleware + let testToken + let testUser + + const setup = async () => { + tempDir = path.join(__dirname, 'temp-integration-' + Date.now()) + await fs.mkdir(tempDir, { recursive: true }) + + tokenManager = new TokenManager(tempDir) + testUser = { + displayName: 'Test User', + email: 'test@example.com', + provider: 'github', + id: '12345' + } + + // Create a test token + const result = await tokenManager.createToken(testUser, 'test-token') + testToken = result.token + + // Create the enhancer and get middleware + const mockLog = console.log + const mockLoga = console.log + const mockArgv = { status: tempDir } + const mockBaseHandler = { + getUser: () => null, + isAuthorized: () => false + } + + const enhancer = securityEnhancer(mockLog, mockLoga, mockArgv, mockBaseHandler) + middleware = enhancer.middleware + } + + const cleanup = async () => { + if (tempDir) { + await fs.rm(tempDir, { recursive: true, force: true }) + } + } + + test('middleware works with standard Bearer header', async () => { + await setup() + try { + const req = { + headers: { + authorization: `Bearer ${testToken}` + } + } + const res = {} + const next = () => {} + + await middleware(req, res, next) + + assert(req.tokenAuth) + assert.deepEqual(req.tokenAuth.user, testUser) + assert.equal(req.tokenAuth.tokenName, 'test-token') + } finally { + await cleanup() + } + }) + + test('middleware works with case-insensitive headers', async () => { + await setup() + try { + const testCases = [ + { authorization: `Bearer ${testToken}` }, + { Authorization: `Bearer ${testToken}` }, + { AUTHORIZATION: `Bearer ${testToken}` }, + { AuThOrIzAtIoN: `Bearer ${testToken}` } + ] + + for (const headers of testCases) { + const req = { headers } + const res = {} + const next = () => {} + + await middleware(req, res, next) + + assert(req.tokenAuth, `Failed with headers: ${JSON.stringify(headers)}`) + assert.deepEqual(req.tokenAuth.user, testUser) + } + } finally { + await cleanup() + } + }) + + test('middleware works with case-insensitive Bearer scheme', async () => { + await setup() + try { + const testCases = [ + `Bearer ${testToken}`, + `bearer ${testToken}`, + `BEARER ${testToken}`, + `BeArEr ${testToken}` + ] + + for (const authValue of testCases) { + const req = { + headers: { authorization: authValue } + } + const res = {} + const next = () => {} + + await middleware(req, res, next) + + assert(req.tokenAuth, `Failed with auth: ${authValue}`) + assert.deepEqual(req.tokenAuth.user, testUser) + } + } finally { + await cleanup() + } + }) + + test('middleware works with various whitespace patterns', async () => { + await setup() + try { + const testCases = [ + `Bearer ${testToken}`, + ` Bearer ${testToken}`, + ` Bearer ${testToken}`, + `\tBearer ${testToken}`, + `Bearer ${testToken}`, + `Bearer ${testToken}`, + `Bearer\t${testToken}`, + ` Bearer ${testToken} `, + `\tBEARER\t\t${testToken}\t` + ] + + for (const authValue of testCases) { + const req = { + headers: { authorization: authValue } + } + const res = {} + const next = () => {} + + await middleware(req, res, next) + + assert(req.tokenAuth, `Failed with auth: "${authValue}"`) + assert.deepEqual(req.tokenAuth.user, testUser) + } + } finally { + await cleanup() + } + }) + + test('middleware rejects invalid schemes and formats', async () => { + await setup() + try { + const testCases = [ + `Basic ${testToken}`, + `Digest ${testToken}`, + `OAuth ${testToken}`, + `Bearer`, + `Bearerabc123`, + ``, + `NotBearer ${testToken}`, + `Bearer${testToken}` // No space + ] + + for (const authValue of testCases) { + const req = { + headers: { authorization: authValue } + } + const res = {} + const next = () => {} + + await middleware(req, res, next) + + assert(!req.tokenAuth, `Should have failed with auth: "${authValue}"`) + } + } finally { + await cleanup() + } + }) + + test('middleware handles missing authorization header gracefully', async () => { + await setup() + try { + const req = { headers: {} } + const res = {} + const next = () => {} + + await middleware(req, res, next) + + assert(!req.tokenAuth) + } finally { + await cleanup() + } + }) +}) diff --git a/test/test.js b/test/test.js index 155e28b..1450f82 100644 --- a/test/test.js +++ b/test/test.js @@ -26,6 +26,29 @@ suite('useraccesstokens plugin', () => { let tempDir let tokenManager + // Realistic user objects that match what OAuth2/Passport provide + const testUser1 = { + displayName: 'John Doe', + email: 'john@example.com', + provider: 'github', + id: '12345' + } + + const testUser2 = { + displayName: 'Jane Smith', + email: 'jane@example.com', + provider: 'google', + id: '67890' + } + + // Same user object but different reference (tests object equality) + const testUser1Copy = { + displayName: 'John Doe', + email: 'john@example.com', + provider: 'github', + id: '12345' + } + // Setup before each test const setup = async () => { tempDir = path.join(__dirname, 'temp-' + Date.now()) @@ -58,18 +81,18 @@ suite('useraccesstokens plugin', () => { test('can create and validate tokens', async () => { await setup() try { - const result = await tokenManager.createToken('testuser', 'test-token') + const result = await tokenManager.createToken(testUser1, 'test-token') assert(result.token.startsWith('fwuat-')) assert.equal(result.record.name, 'test-token') - assert.equal(result.record.user, 'testuser') + assert.deepEqual(result.record.user, testUser1) assert.equal(result.record.revoked, false) assert(result.record.created) assert(result.record.displayHint) const validation = await tokenManager.validateToken(result.token) assert(validation) - assert.equal(validation.user, 'testuser') + assert.deepEqual(validation.user, testUser1) assert.equal(validation.name, 'test-token') } finally { await cleanup() @@ -79,21 +102,21 @@ suite('useraccesstokens plugin', () => { test('can list user tokens safely', async () => { await setup() try { - await tokenManager.createToken('user1', 'token1') - await tokenManager.createToken('user1', 'token2') - await tokenManager.createToken('user2', 'token3') + await tokenManager.createToken(testUser1, 'token1') + await tokenManager.createToken(testUser1, 'token2') + await tokenManager.createToken(testUser2, 'token3') - const user1Tokens = await tokenManager.listTokens('user1') + const user1Tokens = await tokenManager.listTokens(testUser1) assert.equal(user1Tokens.length, 2) // Verify tokenHash is not included in the response user1Tokens.forEach(token => { assert(!token.tokenHash) assert(token.displayHint) - assert.equal(token.user, 'user1') + assert.deepEqual(token.user, testUser1) }) - const user2Tokens = await tokenManager.listTokens('user2') + const user2Tokens = await tokenManager.listTokens(testUser2) assert.equal(user2Tokens.length, 1) } finally { await cleanup() @@ -103,14 +126,14 @@ suite('useraccesstokens plugin', () => { test('can revoke tokens', async () => { await setup() try { - const result = await tokenManager.createToken('testuser', 'test-token') + const result = await tokenManager.createToken(testUser1, 'test-token') // Token should work before revocation let validation = await tokenManager.validateToken(result.token) assert(validation) // Revoke the token - await tokenManager.revokeToken('testuser', 'test-token') + await tokenManager.revokeToken(testUser1, 'test-token') // Token should not work after revocation validation = await tokenManager.validateToken(result.token) @@ -123,10 +146,10 @@ suite('useraccesstokens plugin', () => { test('rejects duplicate token names for same user', async () => { await setup() try { - await tokenManager.createToken('testuser', 'duplicate-name') + await tokenManager.createToken(testUser1, 'duplicate-name') try { - await tokenManager.createToken('testuser', 'duplicate-name') + await tokenManager.createToken(testUser1, 'duplicate-name') assert.fail('Should have thrown an error for duplicate name') } catch (error) { assert(error.message.includes('already exists')) @@ -140,7 +163,7 @@ suite('useraccesstokens plugin', () => { await setup() try { // Create token that expires immediately (for testing) - const result = await tokenManager.createToken('testuser', 'expired-token', -1) + const result = await tokenManager.createToken(testUser1, 'expired-token', -1) // Token should be expired and not validate const validation = await tokenManager.validateToken(result.token) @@ -149,5 +172,47 @@ suite('useraccesstokens plugin', () => { await cleanup() } }) + + test('correctly handles object equality for user comparisons', async () => { + await setup() + try { + // Create token with testUser1 + const result = await tokenManager.createToken(testUser1, 'test-token') + + // List tokens using testUser1Copy (same content, different object reference) + const tokens = await tokenManager.listTokens(testUser1Copy) + assert.equal(tokens.length, 1) + assert.equal(tokens[0].name, 'test-token') + + // Revoke token using testUser1Copy + await tokenManager.revokeToken(testUser1Copy, 'test-token') + + // Verify token is revoked + const validation = await tokenManager.validateToken(result.token) + assert.equal(validation, null) + } finally { + await cleanup() + } + }) + + test('can delete tokens with object user equality', async () => { + await setup() + try { + await tokenManager.createToken(testUser1, 'token-to-delete') + + // Verify token exists + let tokens = await tokenManager.listTokens(testUser1) + assert.equal(tokens.length, 1) + + // Delete using testUser1Copy (same content, different reference) + await tokenManager.deleteToken(testUser1Copy, 'token-to-delete') + + // Verify token is gone + tokens = await tokenManager.listTokens(testUser1) + assert.equal(tokens.length, 0) + } finally { + await cleanup() + } + }) }) })