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'
/**
* 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
* - SSH Keys: Public key authentication
*
* 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']
// Identity registry (provided by server, Autobase-backed)
this.identityRegistry = options.identityRegistry
this.db = options.registry // Raw Hyperbee for token storage (not identity data)
// 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 },
'git': { tokens: 5000, refillRate: 500, refillInterval: 60 * 1000 },
'identity-generate': { tokens: 5, refillRate: 1, refillInterval: 60 * 60 * 1000 }
},
cacheTimeout: options.rateLimitCacheTimeout || 10 * 1000 // 10 seconds
})
// Register all auth providers
this.providers = [
new WebIdentityProvider(this.identityRegistry, this.challengeManager),
new KeetIdentityProvider(this.identityRegistry),
new SSHKeyProvider(this.identityRegistry),
new BearerTokenProvider(this.identityRegistry, this.db)
]
// 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
const errors = []
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: authResult.credentials.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
const 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) {
// Deactivated users cannot authenticate
if (newUser.deactivatedAt) {
errors.push({ provider: provider.name, error: 'User account is deactivated' })
continue
}
return {
userId: newUser.userId,
identityId: authResult.identityId,
method: authResult.method,
permissions: this.formatUserPermissions(newUser, null),
user: newUser,
verified: true
}
}
}
if (!user) {
console.warn(`[Auth] ${provider.name}: authenticated but user not found for identity ${authResult.identityId.substring(0, 12)}...`)
errors.push({ provider: provider.name, error: 'User not found after authenticate' })
continue
}
// Deactivated users cannot authenticate
if (user.deactivatedAt) {
console.warn(`[Auth] ${provider.name}: user ${user.userId.substring(0, 12)}... is deactivated`)
errors.push({ provider: provider.name, error: 'User account is deactivated' })
continue
}
return {
userId: user.userId,
identityId: authResult.identityId,
method: authResult.method,
permissions: this.formatUserPermissions(user, null),
user,
verified: true
}
} catch (err) {
console.warn(`[Auth] ${provider.name} error: ${err.message}`)
errors.push({ provider: provider.name, error: err.message })
continue
}
}
}
if (errors.length > 0) {
console.warn('[Auth] All providers failed:', errors.map(e => `${e.provider}: ${e.error}`).join('; '))
}
return null
}
/**
* Get permissions for a user
* @param {Object} user - User object
* @param {string} repoName - Repository name
* @returns {Object} Formatted permissions
*/
formatUserPermissions(user, repoName) {
// Check repo-specific permissions
const repoPerms = user.repos.find(r => r.name === repoName || r.name === '*')
if (repoPerms) {
return {
read: repoPerms.permissions.includes('read') || repoPerms.permissions.includes('admin'),
write: repoPerms.permissions.includes('write') || repoPerms.permissions.includes('admin'),
admin: repoPerms.permissions.includes('admin'),
repos: [repoPerms.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'),
repos: ['*']
}
}
/**
* 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'),
repos: authResult.repos
}
}
/**
* Check if auth has permission for action on repo
* @param {Object} auth - Auth result from authenticate()
* @param {string} action - 'read', 'write', or 'admin'
* @param {string} repoName - Repository name
* @returns {boolean}
*/
hasPermission(auth, action, repoName = null) {
if (!auth || !auth.verified) return false
const perms = auth.permissions
// Admin can do anything
if (perms.admin) return true
// Check action
if (!perms[action]) return false
// Check repo access
if (repoName) {
return perms.repos.includes('*') || perms.repos.includes(repoName)
}
return true
}
/**
* Middleware to authenticate and authorize requests
* @param {Object} req - HTTP request
* @param {string} action - Required action ('read', 'write', 'admin')
* @param {string} repoName - Repository name
* @returns {Promise<Object>} { authorized, auth, reason }
*/
async authorizeRequest(req, action, repoName = null) {
const auth = await this.authenticate(req)
if (!auth) {
return { authorized: false, auth: null, reason: 'Not authenticated' }
}
if (!this.hasPermission(auth, action, repoName)) {
return { authorized: false, auth, reason: 'Insufficient permissions' }
}
return { authorized: true, auth, reason: null }
}
// ==================== BACKWARD COMPATIBILITY ====================
// These methods delegate to BearerTokenProvider for backward compat
/**
* Create a new bearer token (backward compat)
*/
async createToken(options) {
return this.bearerProvider.createToken(options)
}
/**
* Validate a bearer token (backward compat)
*/
async validateToken(token) {
return this.bearerProvider.validateToken(token)
}
/**
* Revoke a bearer token (backward compat)
*/
async revokeToken(tokenHash) {
return this.bearerProvider.revokeToken(tokenHash)
}
/**
* List all bearer tokens (backward compat)
*/
async listAllTokens() {
return this.bearerProvider.listAllTokens()
}
/**
* Get user tokens (backward compat)
*/
async getUserTokens(username) {
return this.bearerProvider.getUserTokens(username)
}
/**
* Create default admin token (backward compat)
*/
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'],
repos: ['*']
})
}
/**
* Get auth status (backward compat)
*/
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
}
}
/**
* Clean up expired tokens (backward compat)
*/
async cleanupExpiredTokens() {
const tokens = await this.listAllTokens()
let removed = 0
for (const tokenData of tokens) {
if (tokenData.expiresAt && Date.now() > tokenData.expiresAt) {
await this.revokeToken(tokenData.tokenHash)
removed++
}
}
return { removed }
}
// ==================== NEW IDENTITY LINKING METHODS ====================
/**
* Generate new web identity
* @returns {Promise<Object>} { userId, publicKey, keypair, permissions }
*/
async generateWebIdentity() {
const webProvider = this.providers.find(p => p.name === 'web-identity')
return webProvider.generateIdentity()
}
/**
* Link SSH key to current user
* @param {string} currentIdentityId - Current identity ID (from web/keet)
* @param {string} sshPublicKey - SSH public key to link
* @returns {Promise<Object>} Updated user with all identities
*/
async linkSSHKey(currentIdentityId, sshPublicKey) {
// Get current user
const user = await this.identityRegistry.getUserByIdentity(currentIdentityId)
if (!user) {
throw new Error('Current identity not found')
}
// Calculate SSH fingerprint
const fingerprint = this.identityRegistry.calculateSSHFingerprint(sshPublicKey)
// Create SSH identity
const sshIdentity = await this.identityRegistry.createIdentity({
identityId: `ssh:${fingerprint}`,
method: 'ssh-key',
credentials: { publicKey: sshPublicKey, fingerprint }
})
// Link to user
await this.identityRegistry.linkIdentity(sshIdentity.identityId, user.userId)
return this.identityRegistry.getUser(user.userId)
}
/**
* Get user with all linked identities
* @param {string} identityId - Any linked identity ID
* @returns {Promise<Object>} { user, identities }
*/
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 }
}
/**
* Upgrade user permissions (admin only)
* @param {string} userId - User ID
* @param {string} repoName - Repository name (or '*')
* @param {Array<string>} permissions - Permissions to grant
* @returns {Promise<Object>} Updated user
*/
async upgradeUserPermissions(userId, repoName, permissions) {
return this.identityRegistry.upgradePermissions(userId, repoName, permissions)
}
/**
* Generate authentication challenge
* @param {Object} options - { context }
* @returns {Promise<Object>} { challengeId, challenge, expiresAt }
*/
async generateChallenge(options = {}) {
return await this.challengeManager.generate(options)
}
/**
* Verify challenge
* @param {string} challengeId - Challenge ID
* @param {string} challenge - Challenge string
* @returns {Promise<boolean>} True if valid
*/
async verifyChallenge(challengeId, challenge) {
return await this.challengeManager.verify(challengeId, challenge)
}
/**
* Revoke an identity (admin only)
* @param {string} identityId - Identity ID
* @param {Object} options - { reason, revokedBy }
* @returns {Promise<Object>} Revoked identity
*/
async revokeIdentity(identityId, options = {}) {
return this.identityRegistry.revokeIdentity(identityId, options)
}
/**
* Restore a revoked identity (admin only)
* @param {string} identityId - Identity ID
* @returns {Promise<Object>} Restored identity
*/
async restoreIdentity(identityId) {
return this.identityRegistry.restoreIdentity(identityId)
}
/**
* List all revoked identities (admin only)
* @returns {Promise<Array>} Array of revoked identities
*/
async listRevokedIdentities() {
return this.identityRegistry.listRevokedIdentities()
}
/**
* Get client IP from request
* @param {Object} req - HTTP request
* @returns {string} Client IP
*/
getClientIp(req) {
const socketIp = req.socket?.remoteAddress || 'unknown'
// Only trust proxy headers from configured trusted proxies (loopback by default)
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
}
/**
* Get rate limiter stats
* @returns {Promise<Object>} Stats
*/
async getRateLimitStats() {
return await this.rateLimiter.stats()
}
/**
* Get challenge manager stats
* @returns {Promise<Object>} Stats
*/
async getChallengeStats() {
return await this.challengeManager.stats()
}
/**
* Check if user has access to a repository.
*
* Resolution order:
* 1. Read action → ALLOW (federation = full read access)
* 2. User is global admin → ALLOW
* 3. User is repo owner → ALLOW
* 4. Direct repo grant via NamespaceRegistry → ALLOW
* 5. Group grant (groups containing repo) via NamespaceRegistry → ALLOW
* 6. Server grant via NamespaceRegistry → ALLOW
* 7. Legacy repo.collaborators[] (backward compat) → ALLOW
* 8. DENY
*
* @param {Object|null} auth - Authentication result (null for anonymous)
* @param {Object} repo - Repository object with visibility, owner, collaborators
* @param {string} action - Action to perform ('read', 'write', 'admin')
* @param {Object} [options] - Additional context
* @param {Object} [options.namespaceRegistry] - NamespaceRegistry for grant checks
* @param {string} [options.serverKey] - Server key for server-level grant checks
* @returns {boolean|Promise<boolean>} Whether user has access
*/
hasRepoAccess(auth, repo, action = 'read', options = {}) {
if (!repo) return false
// 1. Read action → ALLOW (federation = full read access)
if (action === 'read') return true
// 2. No auth → DENY for write actions
if (!auth) return false
// 3. Global admin → ALLOW
if (auth.permissions && auth.permissions.admin) return true
// 4. Repo owner → ALLOW
if (repo.owner && repo.owner === auth.userId) return true
// 5-7. Check grants (async) — if registry available
if (options.namespaceRegistry && auth.userId) {
return this._checkGrantAccess(auth, repo, action, options)
}
// 8. Legacy collaborators check (backward compat)
if (repo.collaborators && auth.userId) {
const collab = repo.collaborators.find(c => c.userId === auth.userId)
if (collab) {
if (action === 'write' && (collab.permissions.includes('write') || collab.permissions.includes('admin'))) {
return true
}
if (action === 'admin' && collab.permissions.includes('admin')) {
return true
}
}
}
// Fall back to global permissions
return !!(auth.permissions && auth.permissions[action])
}
/**
* Check grant-based access via NamespaceRegistry
* @private
*/
async _checkGrantAccess(auth, repo, action, options) {
const registry = options.namespaceRegistry
const permMap = { write: 'write', admin: 'owner' }
const requiredPerm = permMap[action] || 'write'
const hasGrant = await registry.checkAccess({
userId: auth.userId,
serverKey: options.serverKey || null,
repoName: repo.name,
requiredPermission: requiredPerm
})
if (hasGrant) return true
// Legacy collaborators check (backward compat)
if (repo.collaborators && auth.userId) {
const collab = repo.collaborators.find(c => c.userId === auth.userId)
if (collab) {
if (action === 'write' && (collab.permissions.includes('write') || collab.permissions.includes('admin'))) {
return true
}
if (action === 'admin' && collab.permissions.includes('admin')) {
return true
}
}
}
return !!(auth.permissions && auth.permissions[action])
}
/**
* Cleanup resources
*/
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="Pear Git"')
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({
error: message,
hint: 'Use Authorization: Bearer <token>, X-Wirecore-PublicKey header, or ?token=<token>'
}))
}