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 { SSHKeyProvider } from './auth/providers/ssh-key.js'
import { ChallengeManager } from './auth/challenge-manager.js'
import { RateLimiter } from './auth/rate-limiter.js'
/**
* AuthManager — Generic multi-method authentication and authorization
*
* Supports:
* - Web Identity (ANProto Ed25519 challenge-response)
* - Keet Identity (BIP39 mnemonic proof chains)
* - Bearer Tokens (API tokens with embedded permissions)
* - SSH Keys (Ed25519 + RSA signature verification)
*
* Permission model is injectable via checkPermission callback.
* Uses 'resources' as the generic term (not repos, notebooks, etc).
*/
export class AuthManager {
constructor (options = {}) {
this.db = options.db || null // Direct-write Hyperbee for identity/token storage
this.trustedProxies = options.trustedProxies || ['127.0.0.1', '::1', '::ffff:127.0.0.1']
// Injectable permission check: (auth, action, resourceName) => boolean
// Default: check auth.permissions object
this.checkPermission = options.checkPermission || null
// Identity registry (Autobase-backed)
this.identityRegistry = options.identityRegistry || null
// Challenge manager
this.challengeManager = new ChallengeManager({
db: this.db,
ttl: options.challengeTTL || 5 * 60 * 1000,
cacheTimeout: options.challengeCacheTimeout || 30 * 1000
})
// Rate limiter
this.rateLimiter = new RateLimiter({
db: this.db,
limits: options.rateLimits || {
auth: { tokens: 1000, refillRate: 100, refillInterval: 60 * 1000 },
api: { tokens: 10000, refillRate: 1000, refillInterval: 60 * 1000 },
'identity-generate': { tokens: 5, refillRate: 1, refillInterval: 60 * 60 * 1000 }
},
cacheTimeout: options.rateLimitCacheTimeout || 10 * 1000
})
// Auth providers
this.providers = []
// Register default providers based on what's available
if (this.identityRegistry) {
this.providers.push(
new WebIdentityProvider(this.identityRegistry, this.challengeManager, {
autoRegister: options.autoRegister !== false // default: true
})
)
if (options.IdentityKey) {
this.providers.push(
new KeetIdentityProvider(this.identityRegistry, { IdentityKey: options.IdentityKey })
)
}
this.providers.push(
new BearerTokenProvider(this.identityRegistry, { db: this.db })
)
if (options.enableSSH !== false) {
this.providers.push(
new SSHKeyProvider(this.identityRegistry, this.challengeManager)
)
}
}
// Get bearer token provider reference
this.bearerProvider = this.providers.find(p => p.name === 'bearer-token') || null
}
/**
* Add a custom auth provider
* @param {BaseProvider} provider
*/
addProvider (provider) {
this.providers.push(provider)
}
/**
* 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 = {}) {
// Rate limiting
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
for (const provider of this.providers) {
const credentials = await provider.extractCredentials(req)
if (!credentials) continue
try {
const authResult = await provider.authenticate(credentials)
// Bearer tokens have permissions embedded
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') {
// Retry — web identity auto-registers
user = await this.identityRegistry.getUserByIdentity(authResult.identityId)
}
if (!user) continue
// Auto-promote to admin if no admin exists (bootstrap)
const hasAdmin = await this.identityRegistry._hasAnyAdmin()
if (!hasAdmin && user.globalPermissions && !user.globalPermissions.includes('admin')) {
await this.identityRegistry.upgradePermissions(user.userId, '*', ['read', 'write', 'admin'])
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: true
}
} catch (err) {
console.error(`[AuthManager] Provider ${provider.name} error:`, err.message)
continue
}
}
return null
}
/**
* Generate a challenge for challenge-response auth
* @param {Object} opts - { publicKey?, metadata? }
* @returns {Promise<Object>} { challengeId, challenge, expiresAt }
*/
async generateChallenge (opts = {}) {
return this.challengeManager.generate(opts)
}
/**
* Format user permissions
* Uses user.resources (generic) instead of application-specific terms
* @param {Object} user - User object
* @param {string} resourceName - Resource name (or null for global)
* @returns {Object} { read, write, admin, resources }
*/
formatUserPermissions (user, resourceName) {
// Resource-specific permissions, SPECIFIC BEFORE WILDCARD.
//
// This used to be a single `.find(r => r.name === resourceName || r.name
// === '*')`, which returns the FIRST match — so for a user holding
// [{'*': read}, {'secret': admin}] a lookup of 'secret' matched '*' and
// returned read-only. Named grants were silently shadowed by the wildcard
// depending on array ORDER, which made per-resource permissions unreliable
// for any purpose, and made "readable by everyone except these named
// people" inexpressible.
//
// Precedence is now explicit: an exact-name entry wins; '*' applies only
// when the resource has no entry of its own. That is what lets '*' mean
// "whatever is public" while a named grant still opens one private
// resource to one user.
const resources = user.resources || []
const exact = resourceName ? resources.find(r => r.name === resourceName) : null
const wildcard = resources.find(r => r.name === '*')
const resourcePerms = exact || wildcard
if (resourcePerms) {
return {
read: resourcePerms.permissions.includes('read') || resourcePerms.permissions.includes('admin'),
write: resourcePerms.permissions.includes('write') || resourcePerms.permissions.includes('admin'),
admin: resourcePerms.permissions.includes('admin'),
resources: [resourcePerms.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'),
resources: ['*']
}
}
/**
* Format bearer token 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'),
resources: authResult.resources || ['*']
}
}
/**
* Check if auth has permission for action on resource
* Uses injectable checkPermission callback if provided.
* @param {Object} auth - Auth result from authenticate()
* @param {string} resourceName - Resource name
* @param {string} action - 'read', 'write', or 'admin'
* @returns {boolean}
*/
hasResourceAccess (auth, resourceName, action = 'read') {
if (!auth || !auth.verified) return false
// Use custom permission check if provided
if (this.checkPermission) {
return this.checkPermission(auth, action, resourceName)
}
// Default permission check
const perms = auth.permissions
if (perms.admin) return true
if (!perms[action]) return false
if (resourceName) {
return perms.resources.includes('*') || perms.resources.includes(resourceName)
}
return true
}
/**
* Middleware: authenticate and authorize request
* @param {Object} req - HTTP request
* @param {string} action - Required action
* @param {string} resourceName - Resource name
* @returns {Promise<Object>} { authorized, auth, reason }
*/
async authorizeRequest (req, action, resourceName = null) {
const auth = await this.authenticate(req)
if (!auth) {
return { authorized: false, auth: null, reason: 'Not authenticated' }
}
if (!this.hasResourceAccess(auth, resourceName, action)) {
return { authorized: false, auth, reason: 'Insufficient permissions' }
}
return { authorized: true, auth, reason: null }
}
// ==================== TOKEN MANAGEMENT ====================
async createToken (options) {
if (!this.bearerProvider) throw new Error('Bearer token provider not configured')
return this.bearerProvider.createToken(options)
}
async validateToken (token) {
if (!this.bearerProvider) return null
return this.bearerProvider.validateToken(token)
}
async revokeToken (tokenHash) {
if (!this.bearerProvider) return
return this.bearerProvider.revokeToken(tokenHash)
}
async listAllTokens () {
if (!this.bearerProvider) return []
return this.bearerProvider.listAllTokens()
}
async getUserTokens (username) {
if (!this.bearerProvider) return []
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'],
resources: ['*']
})
}
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
}
}
// ==================== ACCOUNT MERGE PROTOCOL ====================
/**
* Initiate an account merge. The primary user (caller) wants to merge
* the target user (identified by publicKey) into their account.
*
* Returns a merge request with a challenge that the target must sign.
*
* @param {string} primaryUserId - The user initiating the merge
* @param {string} targetPublicKey - Public key of the account to merge in
* @returns {Promise<Object>} { mergeId, challenge, expiresAt, primaryUserId, targetPublicKey }
*/
async initiateMerge (primaryUserId, targetPublicKey) {
// Verify primary user exists
const primaryUser = await this.identityRegistry.getUser(primaryUserId)
if (!primaryUser) throw new Error('Primary user not found')
// Verify target identity exists and has a user
const targetIdentity = await this.identityRegistry.getIdentityByPublicKey(targetPublicKey)
if (!targetIdentity || !targetIdentity.userId) {
throw new Error('Target identity not found or not linked to a user')
}
if (targetIdentity.userId === primaryUserId) {
throw new Error('Cannot merge account with itself')
}
// Generate a merge challenge
const mergeChallenge = await this.challengeManager.generate({
metadata: {
type: 'account-merge',
primaryUserId,
targetPublicKey,
targetUserId: targetIdentity.userId
}
})
// Store merge request in db
if (this.db) {
await this.db.put(`merge:${mergeChallenge.challengeId}`, {
mergeId: mergeChallenge.challengeId,
primaryUserId,
targetPublicKey,
targetUserId: targetIdentity.userId,
challenge: mergeChallenge.challenge,
expiresAt: mergeChallenge.expiresAt,
status: 'pending'
})
}
return {
mergeId: mergeChallenge.challengeId,
challenge: mergeChallenge.challenge,
expiresAt: mergeChallenge.expiresAt,
primaryUserId,
targetPublicKey
}
}
/**
* Confirm a merge by providing the target user's signature.
* The target must sign the merge challenge with their private key.
*
* @param {string} mergeId - Merge request ID
* @param {string} signature - Target's signature of the challenge
* @returns {Promise<Object>} Merge result
*/
async confirmMerge (mergeId, signature) {
if (!this.db) throw new Error('No db configured for merge')
const entry = await this.db.get(`merge:${mergeId}`)
if (!entry) throw new Error('Merge request not found')
const merge = entry.value
if (merge.status !== 'pending') throw new Error('Merge already completed or expired')
if (merge.expiresAt < Date.now()) throw new Error('Merge request expired')
// Verify the signature is from the target (canonical ANProto format)
const { ANProto } = await import('./anproto.js')
const result = ANProto.verify(merge.challenge, signature)
if (!result.valid) throw new Error('Invalid signature — ' + (result.error || 'verification failed'))
if (result.publicKey !== merge.targetPublicKey) throw new Error('Signature is not from the target account')
// Execute the merge
await this.identityRegistry.mergeAccounts(merge.primaryUserId, merge.targetUserId)
// Mark merge as complete
merge.status = 'completed'
merge.completedAt = Date.now()
await this.db.put(`merge:${mergeId}`, merge)
return {
merged: true,
primaryUserId: merge.primaryUserId,
secondaryUserId: merge.targetUserId
}
}
/**
* Get merge request status
* @param {string} mergeId
* @returns {Promise<Object|null>}
*/
async getMerge (mergeId) {
if (!this.db) return null
const entry = await this.db.get(`merge:${mergeId}`)
if (!entry) return null
// Don't expose the challenge value
const { challenge, ...safe } = entry.value
return safe
}
// ==================== 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, resourceName, permissions) {
return this.identityRegistry.upgradePermissions(userId, resourceName, permissions)
}
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()
}
}
/**
* Permission constants
*/
export const Permissions = {
READ: 'read',
WRITE: 'write',
ADMIN: 'admin'
}
/**
* Send auth error response
*/
export function sendAuthError (res, message = 'Unauthorized', statusCode = 401) {
res.statusCode = statusCode
res.setHeader('WWW-Authenticate', 'Bearer realm="hyper-identity"')
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({
error: message,
hint: 'Use Authorization: Bearer <token> or X-Identity-PublicKey header'
}))
}