/**
* Authentication Routes
*
* Handles:
* - POST /api/identity/generate - Generate new web identity
* - POST /api/user/link-ssh-key - Link SSH key to account
* - GET /api/user/identities - List user identities
* - PATCH /api/user/profile - Update user profile
* - POST /api/auth/challenge - Generate auth challenge
* - GET /api/user/attestation - Fetch signed permission attestation
* - POST /api/auth/restore - Restore permissions from attestation
* - POST /api/user/merge/initiate - Initiate account merge
* - GET /api/user/merge/:mergeId - Get merge request details
* - POST /api/user/merge/:mergeId/confirm - Confirm merge from secondary account
* - GET /merge/:mergeId - Merge confirmation page
*/
import { json, html, unauthorized, badRequest, notFound, forbidden, readJson, text, rateLimited } from '../response.js'
import { requireAuth } from '../middleware/require-auth.js'
import { ANProto } from '../anproto.js'
import { PermissionAttestation } from '../auth/attestation.js'
import crypto from 'hypercore-crypto'
/**
* Register auth routes
*/
export function registerIdentityRoutes(router) {
// Generate new web identity (legacy — no username)
router.post('/api/identity/generate', handleGenerateIdentity)
// Create identity with username (new flow)
router.post('/api/identity/create', handleCreateIdentity)
// Check username availability
router.get('/api/identity/username-available', handleUsernameAvailable)
// Link SSH key to account
router.post('/api/user/link-ssh-key', requireAuth, handleLinkSSHKey)
// List user identities
router.get('/api/user/identities', requireAuth, handleListIdentities)
// Update user profile
router.patch('/api/user/profile', requireAuth, handleUpdateProfile)
// Generate challenge for signature-based auth
router.post('/api/auth/challenge', handleGenerateChallenge)
// Permission attestations (self-sovereign identity recovery)
router.get('/api/user/attestation', requireAuth, handleGetAttestation)
router.post('/api/auth/restore', handleRestoreFromAttestation)
// Debug endpoints (admin only)
router.get('/api/auth/debug', requireAuth, handleAuthDebug)
router.get('/api/auth/debug/user/:pubkey', requireAuth, handleDebugUserLookup)
// Localhost-only bootstrap (no auth required)
router.post('/api/bootstrap/promote', handleBootstrapPromote)
// Account merge routes
}
/**
* Generate new web identity keypair
*/
async function handleGenerateIdentity(req, res, server) {
const { authEnabled, authManager } = server.auth
// Rate limit: 10 identities per IP per hour
if (authEnabled && authManager.rateLimiter) {
const clientIp = authManager.getClientIp(req)
const rateCheck = await authManager.rateLimiter.check(clientIp, 'identity-generate')
if (!rateCheck.allowed) {
return rateLimited(res, rateCheck.retryAfter, rateCheck.resetAt)
}
}
try {
const identity = await authManager.generateWebIdentity()
// Register in DB immediately so legacy (pre-ed25519) auth works
const registry = authManager.identityRegistry
await registry.autoRegister({
identityId: identity.publicKey,
method: 'web-identity',
credentials: { publicKey: identity.publicKey }
})
json(res, {
publicKey: identity.publicKey,
keypair: identity.keypair,
message: 'Identity created! Save your keypair to restore access.'
})
} catch (err) {
console.error('[API] Generate identity error:', err)
json(res, { error: 'Failed to generate identity' }, 500)
}
}
/**
* Create identity with username (new opt-in flow)
*/
async function handleCreateIdentity(req, res, server) {
const { authEnabled, authManager } = server.auth
// Rate limit
if (authEnabled && authManager.rateLimiter) {
const clientIp = authManager.getClientIp(req)
const rateCheck = await authManager.rateLimiter.check(clientIp, 'identity-generate')
if (!rateCheck.allowed) {
return rateLimited(res, rateCheck.retryAfter, rateCheck.resetAt)
}
}
try {
const { username, displayName } = await readJson(req)
if (!username) {
return badRequest(res, 'username is required')
}
// Validate format: lowercase, alphanumeric + hyphens, 3-30 chars
const normalized = username.toLowerCase()
if (!/^[a-z0-9][a-z0-9-]{1,28}[a-z0-9]$/.test(normalized)) {
return badRequest(res, 'Username must be 3-30 characters, lowercase letters, numbers, and hyphens only')
}
// Check availability
const registry = authManager.identityRegistry
const available = await registry.checkUsernameAvailable(normalized)
if (!available) {
return json(res, { error: 'Username is already taken' }, 409)
}
// Generate keypair
const identity = await authManager.generateWebIdentity()
// Register with username
await registry.autoRegister({
identityId: identity.publicKey,
method: 'web-identity',
credentials: { publicKey: identity.publicKey },
username: normalized,
name: displayName || normalized
})
json(res, {
publicKey: identity.publicKey,
keypair: identity.keypair,
username: normalized,
message: 'Identity created!'
})
} catch (err) {
console.error('[API] Create identity error:', err)
json(res, { error: 'Failed to create identity' }, 500)
}
}
/**
* Check username availability
*/
async function handleUsernameAvailable(req, res, server) {
const { authManager } = server.auth
try {
const url = new URL(req.url, `http://${req.headers.host}`)
const username = url.searchParams.get('username')
if (!username) {
return badRequest(res, 'username query parameter is required')
}
const normalized = username.toLowerCase()
// Validate format
if (!/^[a-z0-9][a-z0-9-]{1,28}[a-z0-9]$/.test(normalized)) {
return json(res, { available: false, reason: 'Invalid format: 3-30 chars, lowercase letters, numbers, hyphens' })
}
const registry = authManager.identityRegistry
const available = await registry.checkUsernameAvailable(normalized)
json(res, { available, username: normalized })
} catch (err) {
console.error('[API] Username check error:', err)
json(res, { error: 'Failed to check username' }, 500)
}
}
/**
* Link SSH key to current user
*/
async function handleLinkSSHKey(req, res, server) {
const { authManager } = server.auth
try {
const { sshPublicKey } = await readJson(req)
if (!sshPublicKey) {
badRequest(res, 'sshPublicKey is required')
return
}
// Check if SSH key is already linked to another user
const existingIdentity = await authManager.identityRegistry.getIdentityBySSHKey(sshPublicKey)
if (existingIdentity && existingIdentity.userId) {
const currentUser = await authManager.identityRegistry.getUserByIdentity(req.auth.identityId)
if (existingIdentity.userId !== currentUser?.userId) {
badRequest(res, 'SSH key is already linked to another account')
return
}
}
// Link SSH key to current user
const updatedUser = await authManager.linkSSHKey(
req.auth.identityId,
sshPublicKey
)
// Get all identities
const identities = await authManager.identityRegistry.listUserIdentities(updatedUser.userId)
json(res, {
success: true,
userId: updatedUser.userId,
identities: identities.map(id => ({
method: id.method,
identityId: id.identityId,
createdAt: id.createdAt
}))
})
} catch (err) {
console.error('[API] Link SSH key error:', err)
json(res, { error: err.message }, 500)
}
}
/**
* List user identities and permissions
*/
async function handleListIdentities(req, res, server) {
const { authManager } = server.auth
try {
const result = await authManager.getUserWithIdentities(req.auth.identityId)
if (!result) {
notFound(res, 'User not found')
return
}
const { user, identities } = result
json(res, {
userId: user.userId,
name: user.name,
profilePicture: user.profilePicture,
identities: identities.map(id => ({
method: id.method,
identityId: id.identityId,
createdAt: id.createdAt
})),
permissions: {
global: user.globalPermissions,
repos: user.repos
}
})
} catch (err) {
console.error('[API] List identities error:', err)
json(res, { error: err.message }, 500)
}
}
/**
* Update user profile (name, profile picture)
*/
async function handleUpdateProfile(req, res, server) {
const { authManager } = server.auth
try {
const result = await authManager.getUserWithIdentities(req.auth.identityId)
if (!result) {
notFound(res, 'User not found')
return
}
const updates = await readJson(req)
const updatedUser = await authManager.identityRegistry.updateUserProfile(result.user.userId, updates)
json(res, {
success: true,
user: {
userId: updatedUser.userId,
name: updatedUser.name,
profilePicture: updatedUser.profilePicture
}
})
} catch (err) {
console.error('[API] Update profile error:', err)
json(res, { error: err.message }, 500)
}
}
/**
* Generate challenge for signature-based authentication
*/
async function handleGenerateChallenge(req, res, server) {
const { authManager } = server.auth
try {
const { context } = await readJson(req)
const challenge = await authManager.generateChallenge({ context })
json(res, {
challengeId: challenge.challengeId,
challenge: challenge.challenge,
expiresAt: challenge.expiresAt,
ttl: 300 // 5 minutes
})
} catch (err) {
console.error('[API] Generate challenge error:', err)
json(res, { error: err.message }, 500)
}
}
// ============================================
// Permission Attestation Handlers
// ============================================
/**
* Get a signed permission attestation for the current user.
* Users store this alongside their keypair for disaster recovery.
*/
async function handleGetAttestation(req, res, server) {
const { serverIdentity } = server.p2p
const { authManager } = server.auth
try {
if (!serverIdentity) {
json(res, { error: 'P2P not initialized — attestations require server identity' }, 503)
return
}
const result = await authManager.getUserWithIdentities(req.auth.identityId)
if (!result) {
notFound(res, 'User not found')
return
}
const { user, identities } = result
// Find the identity record that matches the auth identity
const identity = identities.find(i => i.identityId === req.auth.identityId) || identities[0]
const userPublicKey = identity?.publicKey || identity?.credentials?.publicKey || req.auth.identityId
const attestation = PermissionAttestation.create({
user,
userPublicKey,
identityMethod: identity?.method || 'web-identity',
identityId: identity?.identityId || req.auth.identityId,
serverIdentity
})
json(res, { attestation })
} catch (err) {
console.error('[API] Get attestation error:', err)
json(res, { error: err.message }, 500)
}
}
/**
* Restore permissions from a previously-issued attestation.
* No auth required (the user lost access — that's the whole point).
*
* Flow:
* 1. Client presents attestation + proof of key ownership (challenge-response)
* 2. Server verifies attestation signature (proves THIS server issued it)
* 3. Server verifies key ownership (proves client holds the private key)
* 4. Server re-creates user record with attested permissions
*/
async function handleRestoreFromAttestation(req, res, server) {
const { serverIdentity } = server.p2p
const { authManager } = server.auth
try {
if (!serverIdentity) {
json(res, { error: 'P2P not initialized — restore requires server identity' }, 503)
return
}
const body = await readJson(req)
const { attestation, challengeId, publicKey, signature } = body
if (!attestation || !challengeId || !publicKey || !signature) {
badRequest(res, 'attestation, challengeId, publicKey, and signature are required')
return
}
// Step 1: Verify the attestation was signed by THIS server
const verification = PermissionAttestation.verify(
attestation,
serverIdentity.getPublicKeyHex()
)
if (!verification.valid) {
forbidden(res, 'Invalid attestation — not signed by this server or tampered')
return
}
// Step 2: Check publicKey matches attestation
if (attestation.userPublicKey !== publicKey) {
forbidden(res, 'Public key does not match attestation')
return
}
// Step 3: Verify key ownership via challenge-response
const challengeRecord = await authManager.challengeManager.consume(challengeId)
if (!challengeRecord) {
forbidden(res, 'Invalid or expired challenge')
return
}
const isOwner = ANProto.verify(challengeRecord.challenge, signature, publicKey)
if (!isOwner) {
forbidden(res, 'Invalid signature — cannot prove key ownership')
return
}
// Step 4: Re-create user record with attested permissions
const grant = verification.grant
const registry = authManager.identityRegistry
// Check if user already exists (might have been partially recreated)
let existingUser = null
try {
const existingIdentity = await registry.getIdentityByPublicKey(publicKey)
if (existingIdentity && existingIdentity.userId) {
existingUser = await registry.getUser(existingIdentity.userId)
}
} catch {}
if (existingUser) {
// Update existing user's permissions to match attestation via Autobase
existingUser = await registry.updateUserProfile(existingUser.userId, {
globalPermissions: grant.permissions,
repos: grant.repos.map(name => ({
name,
permissions: grant.permissions
})),
restoredFromAttestation: true
})
console.log('[Restore] Updated existing user:', existingUser.userId, 'permissions:', grant.permissions)
} else {
// Create new user + identity records
const result = await registry.autoRegister({
identityId: grant.identityId,
method: grant.identityMethod,
credentials: { publicKey }
})
// Set permissions on the newly created user via Autobase
existingUser = await registry.updateUserProfile(result.user.userId, {
globalPermissions: grant.permissions,
repos: grant.repos.map(name => ({
name,
permissions: grant.permissions
})),
restoredFromAttestation: true
})
console.log('[Restore] Created user from attestation:', existingUser.userId, 'permissions:', grant.permissions)
}
// Issue a fresh attestation for the restored user
const freshAttestation = PermissionAttestation.create({
user: existingUser,
userPublicKey: publicKey,
identityMethod: grant.identityMethod,
identityId: grant.identityId,
serverIdentity
})
json(res, {
success: true,
userId: existingUser.userId,
permissions: grant.permissions,
attestation: freshAttestation,
message: 'Permissions restored from attestation'
})
} catch (err) {
console.error('[API] Restore from attestation error:', err)
json(res, { error: err.message }, 500)
}
}
/**
* Helper: create an attestation for a user if server identity is available.
* Returns the attestation object or null.
*/
async function createAttestationForUser (server, user, identityId) {
const { serverIdentity } = server.p2p
const { authManager } = server.auth
if (!serverIdentity) return null
try {
const result = await authManager.getUserWithIdentities(identityId)
if (!result) return null
const identity = result.identities.find(i => i.identityId === identityId) || result.identities[0]
const userPublicKey = identity?.publicKey || identity?.credentials?.publicKey || identityId
return PermissionAttestation.create({
user: result.user,
userPublicKey,
identityMethod: identity?.method || 'web-identity',
identityId: identity?.identityId || identityId,
serverIdentity
})
} catch {
return null
}
}
/**
* Debug endpoint - shows auth system state (no auth required)
*/
async function handleAuthDebug(req, res, server) {
const { authManager } = server.auth
// Require admin permission
const isAdmin = req.auth?.permissions?.admin ||
req.auth?.user?.globalPermissions?.includes('admin')
if (!isAdmin) {
forbidden(res, 'Admin permission required')
return
}
try {
// Get challenge stats
const challengeStats = await authManager.challengeManager.stats()
// Get recent challenges from cache
const recentChallenges = []
for (const [id, record] of authManager.challengeManager.cache.entries()) {
recentChallenges.push({
id: id.substring(0, 16) + '...',
expiresAt: new Date(record.expiresAt).toISOString(),
used: record.used,
context: record.context,
ageMs: Date.now() - record.createdAt
})
}
// Count users and identities
let userCount = 0
let identityCount = 0
const registry = authManager.identityRegistry
for await (const entry of registry.db.createReadStream({ gte: 'user:', lt: 'user:\xff' })) {
userCount++
}
for await (const entry of registry.db.createReadStream({ gte: 'identity:', lt: 'identity:\xff' })) {
identityCount++
}
// Check request headers
const headers = {
publicKey: req.headers['x-wirecore-publickey']?.substring(0, 20) + '...' || null,
hasSignature: !!req.headers['x-wirecore-signature'],
challengeId: req.headers['x-wirecore-challenge']?.substring(0, 16) + '...' || null
}
json(res, {
status: 'ok',
timestamp: new Date().toISOString(),
headers,
challenges: challengeStats,
recentChallenges: recentChallenges.slice(0, 10),
registry: {
users: userCount,
identities: identityCount
}
})
} catch (err) {
console.error('[API] Debug error:', err)
json(res, { error: err.message, stack: err.stack }, 500)
}
}
/**
* Debug user lookup by public key
*/
async function handleDebugUserLookup(req, res, server) {
const { authManager } = server.auth
// Require admin permission
const isAdmin = req.auth?.permissions?.admin ||
req.auth?.user?.globalPermissions?.includes('admin')
if (!isAdmin) {
forbidden(res, 'Admin permission required')
return
}
try {
const pubkey = decodeURIComponent(req.params.pubkey)
console.log('[Debug] Looking up pubkey:', pubkey)
const registry = authManager.identityRegistry
// Get identity by public key
const identity = await registry.getIdentityByPublicKey(pubkey)
if (!identity) {
json(res, { error: 'Identity not found', pubkey })
return
}
// Get user
const user = await registry.getUser(identity.userId)
// Get all identities for this user
let allIdentities = []
try {
allIdentities = await registry.listUserIdentities(identity.userId)
} catch (err) {
console.error('[Debug] listUserIdentities error:', err)
}
// Also scan for any identities pointing to this userId
const foundIdentities = []
for await (const entry of registry.db.createReadStream({ gte: 'identity:', lt: 'identity:\xff' })) {
if (entry.value.userId === identity.userId) {
foundIdentities.push({
key: entry.key,
...entry.value
})
}
}
// Check pubkey index
const pubkeyEntries = []
for await (const entry of registry.db.createReadStream({ gte: 'pubkey:', lt: 'pubkey:\xff' })) {
if (entry.value.userId === identity.userId) {
pubkeyEntries.push({
key: entry.key,
...entry.value
})
}
}
json(res, {
pubkey,
identity,
user,
userIdentitiesArray: user?.identities || [],
listUserIdentitiesResult: allIdentities,
foundIdentitiesInDb: foundIdentities,
pubkeyEntries
})
} catch (err) {
console.error('[API] Debug user lookup error:', err)
json(res, { error: err.message, stack: err.stack }, 500)
}
}
// ============================================
// Bootstrap Handlers (localhost-only, no auth)
// ============================================
/**
* Promote a browser identity to admin — localhost only, no token required
* Solves the chicken-and-egg problem on fresh servers
*/
async function handleBootstrapPromote(req, res, server) {
const { bindAddresses } = server.config
const { authManager } = server.auth
// Localhost check: bare-http1 doesn't expose req.socket.remoteAddress,
// so also check if the server is bound to localhost (only local connections possible)
const socketIp = req.socket?.remoteAddress || ''
const localhostIps = ['127.0.0.1', '::1', '::ffff:127.0.0.1', '']
const serverIsLocalOnly = bindAddresses.every(a => a === 'localhost' || a === '127.0.0.1' || a === '::1')
const isLocal = localhostIps.includes(socketIp) || serverIsLocalOnly
if (!isLocal) {
console.log('[Bootstrap] Rejected non-local promote from:', socketIp || 'unknown')
forbidden(res, 'Bootstrap promote is localhost-only')
return
}
try {
const body = await readJson(req)
const { publicKey, userId } = body
if (!publicKey && !userId) {
badRequest(res, 'publicKey or userId is required')
return
}
const registry = authManager.identityRegistry
let user
if (userId) {
// Direct user lookup by userId
user = await registry.getUser(userId)
if (!user) {
notFound(res, 'User not found for userId: ' + userId.substring(0, 8) + '...')
return
}
console.log('[Bootstrap] Promoting by userId:', userId.substring(0, 8) + '...')
} else {
// Lookup by publicKey (original flow)
let identity = await registry.getIdentityByPublicKey(publicKey)
if (!identity) {
// Identity not in DB yet — register it now (handles pre-fix generate endpoints)
const result = await registry.autoRegister({
identityId: publicKey,
method: 'web-identity',
credentials: { publicKey }
})
identity = result.identity
user = result.user
console.log('[Bootstrap] Auto-registered identity:', publicKey.substring(0, 8) + '...')
} else {
if (!identity.userId) {
badRequest(res, 'Identity has no associated user')
return
}
user = await registry.getUser(identity.userId)
if (!user) {
notFound(res, 'User not found')
return
}
}
}
// Promote to admin via Autobase
const adminPerms = ['admin', 'read', 'write']
const updatedRepos = [...(user.repos || [])]
const wildcardRepo = updatedRepos.find(r => r.name === '*')
if (wildcardRepo) {
wildcardRepo.permissions = adminPerms
} else {
updatedRepos.push({ name: '*', permissions: adminPerms })
}
user = await registry.updateUserProfile(user.userId, {
globalPermissions: adminPerms,
repos: updatedRepos
})
console.log('[Bootstrap] Promoted user to admin:', user.userId)
// Create bearer token for the new admin (localhost-only, so trusted)
let token = null
let tokenHash = null
if (authManager.bearerProvider) {
try {
const tokenResult = await authManager.bearerProvider.createToken({
username: user.name || 'admin',
description: 'Bootstrap admin token',
permissions: adminPerms,
repos: ['*']
})
token = tokenResult.token
tokenHash = tokenResult.tokenHash
console.log('[Bootstrap] Created admin token:', tokenHash.slice(0, 8) + '...')
} catch (err) {
console.error('[Bootstrap] Token creation failed:', err.message)
}
}
// Include attestation if server identity is available
const identityId = publicKey || (user.identities && user.identities[0])
const attestation = identityId
? await createAttestationForUser(server, user, identityId)
: null
json(res, {
success: true,
userId: user.userId,
globalPermissions: user.globalPermissions,
token,
tokenHash,
attestation
})
} catch (err) {
console.error('[Bootstrap] Promote error:', err)
json(res, { error: err.message }, 500)
}
}
// ============================================
// Account Merge Handlers
// ============================================
/**
* Initiate account merge - creates merge request
* Called by primary account (the one to keep)
*/
export {
handleGenerateIdentity,
handleCreateIdentity,
handleUsernameAvailable,
handleLinkSSHKey,
handleListIdentities,
handleUpdateProfile,
handleGenerateChallenge,
handleGetAttestation,
handleRestoreFromAttestation,
createAttestationForUser,
}