Make it work.
This commit is contained in:
5
factory.json
Normal file
5
factory.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"name": "User Access Tokens",
|
||||||
|
"category": "security",
|
||||||
|
"description": "Manage API tokens for programmatic authentication"
|
||||||
|
}
|
195
index.js
Normal file
195
index.js
Normal file
@ -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 }
|
@ -2,6 +2,7 @@
|
|||||||
"name": "wiki-plugin-useraccesstokens",
|
"name": "wiki-plugin-useraccesstokens",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Federated Wiki plugin for managing User Access Tokens (API tokens) for programmatic authentication",
|
"description": "Federated Wiki plugin for managing User Access Tokens (API tokens) for programmatic authentication",
|
||||||
|
"main": "index.js",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"wiki",
|
"wiki",
|
||||||
"federated wiki",
|
"federated wiki",
|
||||||
|
147
server/server.js
147
server/server.js
@ -1,5 +1,5 @@
|
|||||||
// useraccesstokens plugin, server-side component
|
// useraccesstokens plugin, TokenManager class
|
||||||
// These handlers are launched with the wiki server.
|
// Provides token management functionality for the security enhancer
|
||||||
|
|
||||||
import fs from 'node:fs'
|
import fs from 'node:fs'
|
||||||
import fsp from 'node:fs/promises'
|
import fsp from 'node:fs/promises'
|
||||||
@ -57,7 +57,7 @@ class TokenManager {
|
|||||||
const tokens = await this.loadTokens()
|
const tokens = await this.loadTokens()
|
||||||
|
|
||||||
// Check if token name already exists for this user
|
// 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) {
|
if (existingToken) {
|
||||||
throw new Error(`Token with name "${name}" already exists`)
|
throw new Error(`Token with name "${name}" already exists`)
|
||||||
}
|
}
|
||||||
@ -87,17 +87,20 @@ class TokenManager {
|
|||||||
|
|
||||||
async listTokens(user) {
|
async listTokens(user) {
|
||||||
const tokens = await this.loadTokens()
|
const tokens = await this.loadTokens()
|
||||||
return tokens
|
|
||||||
.filter(t => t.user === user)
|
const filteredTokens = tokens.filter(t => {
|
||||||
.map(t => {
|
return JSON.stringify(t.user) === JSON.stringify(user)
|
||||||
const { tokenHash, ...safeToken } = t
|
})
|
||||||
return safeToken
|
|
||||||
})
|
return filteredTokens.map(t => {
|
||||||
|
const { tokenHash, ...safeToken } = t
|
||||||
|
return safeToken
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async revokeToken(user, name) {
|
async revokeToken(user, name) {
|
||||||
const tokens = await this.loadTokens()
|
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) {
|
if (tokenIndex === -1) {
|
||||||
throw new Error(`Token "${name}" not found`)
|
throw new Error(`Token "${name}" not found`)
|
||||||
@ -111,7 +114,7 @@ class TokenManager {
|
|||||||
|
|
||||||
async deleteToken(user, name) {
|
async deleteToken(user, name) {
|
||||||
const tokens = await this.loadTokens()
|
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) {
|
if (tokenIndex === -1) {
|
||||||
throw new Error(`Token "${name}" not found`)
|
throw new Error(`Token "${name}" not found`)
|
||||||
@ -148,124 +151,4 @@ class TokenManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const startServer = async function (params) {
|
export { TokenManager }
|
||||||
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 }
|
|
||||||
|
205
test/middleware-integration.test.js
Normal file
205
test/middleware-integration.test.js
Normal file
@ -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()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
93
test/test.js
93
test/test.js
@ -26,6 +26,29 @@ suite('useraccesstokens plugin', () => {
|
|||||||
let tempDir
|
let tempDir
|
||||||
let tokenManager
|
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
|
// Setup before each test
|
||||||
const setup = async () => {
|
const setup = async () => {
|
||||||
tempDir = path.join(__dirname, 'temp-' + Date.now())
|
tempDir = path.join(__dirname, 'temp-' + Date.now())
|
||||||
@ -58,18 +81,18 @@ suite('useraccesstokens plugin', () => {
|
|||||||
test('can create and validate tokens', async () => {
|
test('can create and validate tokens', async () => {
|
||||||
await setup()
|
await setup()
|
||||||
try {
|
try {
|
||||||
const result = await tokenManager.createToken('testuser', 'test-token')
|
const result = await tokenManager.createToken(testUser1, 'test-token')
|
||||||
|
|
||||||
assert(result.token.startsWith('fwuat-'))
|
assert(result.token.startsWith('fwuat-'))
|
||||||
assert.equal(result.record.name, 'test-token')
|
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.equal(result.record.revoked, false)
|
||||||
assert(result.record.created)
|
assert(result.record.created)
|
||||||
assert(result.record.displayHint)
|
assert(result.record.displayHint)
|
||||||
|
|
||||||
const validation = await tokenManager.validateToken(result.token)
|
const validation = await tokenManager.validateToken(result.token)
|
||||||
assert(validation)
|
assert(validation)
|
||||||
assert.equal(validation.user, 'testuser')
|
assert.deepEqual(validation.user, testUser1)
|
||||||
assert.equal(validation.name, 'test-token')
|
assert.equal(validation.name, 'test-token')
|
||||||
} finally {
|
} finally {
|
||||||
await cleanup()
|
await cleanup()
|
||||||
@ -79,21 +102,21 @@ suite('useraccesstokens plugin', () => {
|
|||||||
test('can list user tokens safely', async () => {
|
test('can list user tokens safely', async () => {
|
||||||
await setup()
|
await setup()
|
||||||
try {
|
try {
|
||||||
await tokenManager.createToken('user1', 'token1')
|
await tokenManager.createToken(testUser1, 'token1')
|
||||||
await tokenManager.createToken('user1', 'token2')
|
await tokenManager.createToken(testUser1, 'token2')
|
||||||
await tokenManager.createToken('user2', 'token3')
|
await tokenManager.createToken(testUser2, 'token3')
|
||||||
|
|
||||||
const user1Tokens = await tokenManager.listTokens('user1')
|
const user1Tokens = await tokenManager.listTokens(testUser1)
|
||||||
assert.equal(user1Tokens.length, 2)
|
assert.equal(user1Tokens.length, 2)
|
||||||
|
|
||||||
// Verify tokenHash is not included in the response
|
// Verify tokenHash is not included in the response
|
||||||
user1Tokens.forEach(token => {
|
user1Tokens.forEach(token => {
|
||||||
assert(!token.tokenHash)
|
assert(!token.tokenHash)
|
||||||
assert(token.displayHint)
|
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)
|
assert.equal(user2Tokens.length, 1)
|
||||||
} finally {
|
} finally {
|
||||||
await cleanup()
|
await cleanup()
|
||||||
@ -103,14 +126,14 @@ suite('useraccesstokens plugin', () => {
|
|||||||
test('can revoke tokens', async () => {
|
test('can revoke tokens', async () => {
|
||||||
await setup()
|
await setup()
|
||||||
try {
|
try {
|
||||||
const result = await tokenManager.createToken('testuser', 'test-token')
|
const result = await tokenManager.createToken(testUser1, 'test-token')
|
||||||
|
|
||||||
// Token should work before revocation
|
// Token should work before revocation
|
||||||
let validation = await tokenManager.validateToken(result.token)
|
let validation = await tokenManager.validateToken(result.token)
|
||||||
assert(validation)
|
assert(validation)
|
||||||
|
|
||||||
// Revoke the token
|
// Revoke the token
|
||||||
await tokenManager.revokeToken('testuser', 'test-token')
|
await tokenManager.revokeToken(testUser1, 'test-token')
|
||||||
|
|
||||||
// Token should not work after revocation
|
// Token should not work after revocation
|
||||||
validation = await tokenManager.validateToken(result.token)
|
validation = await tokenManager.validateToken(result.token)
|
||||||
@ -123,10 +146,10 @@ suite('useraccesstokens plugin', () => {
|
|||||||
test('rejects duplicate token names for same user', async () => {
|
test('rejects duplicate token names for same user', async () => {
|
||||||
await setup()
|
await setup()
|
||||||
try {
|
try {
|
||||||
await tokenManager.createToken('testuser', 'duplicate-name')
|
await tokenManager.createToken(testUser1, 'duplicate-name')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await tokenManager.createToken('testuser', 'duplicate-name')
|
await tokenManager.createToken(testUser1, 'duplicate-name')
|
||||||
assert.fail('Should have thrown an error for duplicate name')
|
assert.fail('Should have thrown an error for duplicate name')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
assert(error.message.includes('already exists'))
|
assert(error.message.includes('already exists'))
|
||||||
@ -140,7 +163,7 @@ suite('useraccesstokens plugin', () => {
|
|||||||
await setup()
|
await setup()
|
||||||
try {
|
try {
|
||||||
// Create token that expires immediately (for testing)
|
// 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
|
// Token should be expired and not validate
|
||||||
const validation = await tokenManager.validateToken(result.token)
|
const validation = await tokenManager.validateToken(result.token)
|
||||||
@ -149,5 +172,47 @@ suite('useraccesstokens plugin', () => {
|
|||||||
await cleanup()
|
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()
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
Reference in New Issue
Block a user