import { IdentityRegistry } from './auth/identity-registry.js'
import { WebIdentityProvider } from './auth/providers/web-identity.js'
import { BearerTokenProvider } from './auth/providers/bearer-token.js'
import { KeetIdentityProvider } from './auth/providers/keet-identity.js'
import { ChallengeManager } from './auth/challenge-manager.js'
import { RateLimiter } from './auth/rate-limiter.js'
/**
* Authentication and Authorization System
*
* Multi-method authentication with identity linking:
* - Web Identity: Browser-based ANProto keypairs (auto-generated)
* - Bearer Tokens: API tokens with embedded permissions
* - Keet Identity: BIP39 mnemonic-based identities
*
* Key Concepts:
* - User: A person with permissions (can have multiple identities)
* - Identity: An authentication method
* - Linking: Multiple identities can link to the same user
*/
export class AuthManager {
constructor(options = {}) {
this.hyperbee = options.registry
this.trustedProxies = options.trustedProxies || ['127.0.0.1', '::1', '::ffff:127.0.0.1']
// Initialize identity registry
this.identityRegistry = new IdentityRegistry(this.hyperbee)
// Shared registry reference (set externally for mirror auth fallback)
this.sharedRegistry = null
// Initialize challenge manager (with persistent storage)
this.challengeManager = new ChallengeManager({
db: this.hyperbee,
ttl: options.challengeTTL || 5 * 60 * 1000, // 5 minutes
cacheTimeout: options.challengeCacheTimeout || 30 * 1000 // 30 seconds
})
// Initialize rate limiter (with persistent storage)
this.rateLimiter = new RateLimiter({
db: this.hyperbee,
limits: options.rateLimits || {
'auth': { tokens: 1000, refillRate: 100, refillInterval: 60 * 1000 },
'api': { tokens: 10000, refillRate: 1000, refillInterval: 60 * 1000 },
'notes': { tokens: 5000, refillRate: 500, refillInterval: 60 * 1000 },
'identity-generate': { tokens: 5, refillRate: 1, refillInterval: 60 * 60 * 1000 },
'media-upload': { tokens: 50, refillRate: 10, refillInterval: 60 * 1000 }
},
cacheTimeout: options.rateLimitCacheTimeout || 10 * 1000 // 10 seconds
})
// Register auth providers (no SSH for notes)
this.providers = [
new WebIdentityProvider(this.identityRegistry, this.challengeManager),
new KeetIdentityProvider(this.identityRegistry),
new BearerTokenProvider(this.identityRegistry)
]
// Get bearer token provider for backward compat
this.bearerProvider = this.providers.find(p => p.name === 'bearer-token')
}
/**
* Authenticate request using all available providers
* @param {Object} req - HTTP request
* @param {Object} options - { skipRateLimit, operation }
* @returns {Promise<Object|null>} Auth result or null
*/
async authenticate(req, options = {}) {
// Apply rate limiting (unless explicitly skipped)
if (!options.skipRateLimit) {
const clientIp = this.getClientIp(req)
const operation = options.operation || 'auth'
const rateLimit = await this.rateLimiter.check(clientIp, operation)
if (!rateLimit.allowed) {
const error = new Error('Rate limit exceeded')
error.code = 'RATE_LIMIT'
error.retryAfter = rateLimit.retryAfter
error.resetAt = rateLimit.resetAt
throw error
}
}
// Try each provider in order
for (const provider of this.providers) {
const credentials = await provider.extractCredentials(req)
if (credentials) {
try {
const authResult = await provider.authenticate(credentials)
// Bearer tokens have permissions embedded, don't look up User
if (authResult.method === 'bearer-token') {
return {
userId: null,
identityId: authResult.identityId,
method: authResult.method,
username: authResult.credentials.username,
permissions: this.formatTokenPermissions(authResult),
verified: true
}
}
// For other methods, look up User via Identity
let user = await this.identityRegistry.getUserByIdentity(authResult.identityId)
if (!user && authResult.method === 'web-identity') {
// Web identity was auto-registered by provider, retry lookup
const newUser = await this.identityRegistry.getUserByIdentity(authResult.identityId)
if (newUser) {
return {
userId: newUser.userId,
identityId: authResult.identityId,
method: authResult.method,
permissions: this.formatUserPermissions(newUser, null),
user: newUser,
verified: true
}
}
}
if (!user && this.sharedRegistry) {
// Mirror fallback: check shared registry for synced users
try {
const publicKey = credentials.publicKey || (authResult.credentials && authResult.credentials.publicKey)
if (publicKey) {
// Check if this pubkey already has a local identity with a valid user
const existingIdentity = await this.identityRegistry.getIdentityByPublicKey(publicKey)
if (existingIdentity && existingIdentity.userId) {
const existingUser = await this.identityRegistry.getUser(existingIdentity.userId)
if (existingUser) {
// Pubkey already maps to a valid local user — use it, don't overwrite
user = existingUser
}
}
if (!user) {
const sharedUser = await this.sharedRegistry.getSharedUserByPublicKey(publicKey)
if (sharedUser) {
// Create local user with the SAME userId from shared registry
const userObj = {
userId: sharedUser.userId,
username: sharedUser.username || null,
name: sharedUser.name || 'Anonymous',
email: null,
profilePicture: null,
globalPermissions: sharedUser.globalPermissions || ['read'],
notebooks: sharedUser.notebooks || [{ name: '*', permissions: ['read'] }],
identities: [authResult.identityId],
createdAt: Date.now(),
updatedAt: Date.now()
}
await this.identityRegistry.db.put(`user:${sharedUser.userId}`, userObj)
// Link identity to this user
const identity = await this.identityRegistry.getIdentity(authResult.identityId)
if (identity) {
identity.userId = sharedUser.userId
await this.identityRegistry.db.put(`identity:${authResult.identityId}`, identity)
}
user = userObj
}
}
}
} catch (err) {
console.error('[Auth] Shared registry fallback error:', err.message)
}
}
if (!user) {
console.log(`[Auth] No user found for identity ${authResult.identityId}, skipping provider`)
continue
}
// Auto-promote to admin if no admin exists (bootstrap case)
const hasAdmin = await this.identityRegistry._hasAnyAdmin()
if (!hasAdmin && user.globalPermissions && !user.globalPermissions.includes('admin')) {
await this.identityRegistry.upgradePermissions(user.userId, '*', ['read', 'write', 'admin'])
// Re-fetch upgraded user
const upgraded = await this.identityRegistry.getUser(user.userId)
if (upgraded) user = upgraded
}
return {
userId: user.userId,
identityId: authResult.identityId,
method: authResult.method,
permissions: this.formatUserPermissions(user, null),
user,
verified: authResult.verified !== false
}
} catch (err) {
console.error(`[Auth] Provider ${provider.name} threw:`, err.message)
continue
}
}
}
return null
}
/**
* Get permissions for a user
* @param {Object} user - User object
* @param {string} notebookName - Notebook name
* @returns {Object} Formatted permissions
*/
formatUserPermissions(user, notebookName) {
// Check notebook-specific permissions
const notebookPerms = user.notebooks.find(r => r.name === notebookName || r.name === '*')
if (notebookPerms) {
return {
read: notebookPerms.permissions.includes('read') || notebookPerms.permissions.includes('admin'),
write: notebookPerms.permissions.includes('write') || notebookPerms.permissions.includes('admin'),
admin: notebookPerms.permissions.includes('admin'),
notebooks: [notebookPerms.name]
}
}
// Fall back to global permissions
return {
read: user.globalPermissions.includes('read') || user.globalPermissions.includes('admin'),
write: user.globalPermissions.includes('write') || user.globalPermissions.includes('admin'),
admin: user.globalPermissions.includes('admin'),
notebooks: ['*']
}
}
/**
* Format bearer token permissions
* @param {Object} authResult - Bearer token auth result
* @returns {Object} Formatted permissions
*/
formatTokenPermissions(authResult) {
return {
read: authResult.permissions.includes('read') || authResult.permissions.includes('admin'),
write: authResult.permissions.includes('write') || authResult.permissions.includes('admin'),
admin: authResult.permissions.includes('admin'),
notebooks: authResult.notebooks
}
}
/**
* Check if auth has permission for action on notebook
* @param {Object} auth - Auth result from authenticate()
* @param {string} notebookName - Notebook name
* @param {string} action - 'read', 'write', or 'admin'
* @returns {boolean}
*/
hasNotebookAccess(auth, notebookName, action = 'read') {
if (!auth) return false
// Unverified (legacy) auth can only read — block write/admin
if (!auth.verified && action !== 'read') return false
const perms = auth.permissions
// Admin can do anything
if (perms.admin) return true
// Check action
if (!perms[action]) return false
// Check notebook access
if (notebookName) {
return perms.notebooks.includes('*') || perms.notebooks.includes(notebookName)
}
return true
}
/**
* Middleware to authenticate and authorize requests
* @param {Object} req - HTTP request
* @param {string} action - Required action ('read', 'write', 'admin')
* @param {string} notebookName - Notebook name
* @returns {Promise<Object>} { authorized, auth, reason }
*/
async authorizeRequest(req, action, notebookName = null) {
const auth = await this.authenticate(req)
if (!auth) {
return { authorized: false, auth: null, reason: 'Not authenticated' }
}
if (!this.hasNotebookAccess(auth, notebookName, action)) {
return { authorized: false, auth, reason: 'Insufficient permissions' }
}
return { authorized: true, auth, reason: null }
}
// ==================== TOKEN MANAGEMENT ====================
async createToken(options) {
return this.bearerProvider.createToken(options)
}
async validateToken(token) {
return this.bearerProvider.validateToken(token)
}
async revokeToken(tokenHash) {
return this.bearerProvider.revokeToken(tokenHash)
}
async listAllTokens() {
return this.bearerProvider.listAllTokens()
}
async getUserTokens(username) {
return this.bearerProvider.getUserTokens(username)
}
async createDefaultAdmin() {
const tokens = await this.listAllTokens()
if (tokens.length > 0) return null
return this.createToken({
username: 'admin',
description: 'Default admin token (generated on first start)',
permissions: ['read', 'write', 'admin'],
notebooks: ['*']
})
}
async getStatus() {
const tokens = await this.listAllTokens()
const activeTokens = tokens.filter(t => !t.expiresAt || t.expiresAt > Date.now())
return {
enabled: tokens.length > 0,
totalTokens: tokens.length,
activeTokens: activeTokens.length,
users: [...new Set(tokens.map(t => t.username))].length
}
}
// ==================== IDENTITY METHODS ====================
async getUserWithIdentities(identityId) {
const user = await this.identityRegistry.getUserByIdentity(identityId)
if (!user) return null
const identities = await this.identityRegistry.listUserIdentities(user.userId)
return { user, identities }
}
async upgradeUserPermissions(userId, notebookName, permissions) {
return this.identityRegistry.upgradePermissions(userId, notebookName, permissions)
}
async generateChallenge(options = {}) {
return await this.challengeManager.generate(options)
}
async verifyChallenge(challengeId, challenge) {
return await this.challengeManager.verify(challengeId, challenge)
}
async revokeIdentity(identityId, options = {}) {
return this.identityRegistry.revokeIdentity(identityId, options)
}
async restoreIdentity(identityId) {
return this.identityRegistry.restoreIdentity(identityId)
}
async listRevokedIdentities() {
return this.identityRegistry.listRevokedIdentities()
}
getClientIp(req) {
const socketIp = req.socket?.remoteAddress || 'unknown'
const isTrustedProxy = this.trustedProxies.some(ip => socketIp === ip || socketIp === '::ffff:' + ip)
if (isTrustedProxy) {
const forwarded = req.headers['x-forwarded-for']
if (forwarded) {
return forwarded.split(',')[0].trim()
}
const realIp = req.headers['x-real-ip']
if (realIp) {
return realIp
}
}
return socketIp
}
async getRateLimitStats() {
return await this.rateLimiter.stats()
}
async getChallengeStats() {
return await this.challengeManager.stats()
}
destroy() {
this.challengeManager.destroy()
this.rateLimiter.destroy()
}
}
/**
* Authorization levels
*/
export const Permissions = {
READ: 'read',
WRITE: 'write',
ADMIN: 'admin'
}
/**
* Helper to send auth error response
*/
export function sendAuthError(res, message = 'Unauthorized', statusCode = 401) {
res.statusCode = statusCode
res.setHeader('WWW-Authenticate', 'Bearer realm="Hypernote"')
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({
error: message,
hint: 'Use Authorization: Bearer <token> or X-Wirecore-PublicKey header'
}))
}