267 lines
7.9 KiB
TypeScript
267 lines
7.9 KiB
TypeScript
/**
|
|
* FederationController — admin REST API for federation management (FED-M2-08).
|
|
*
|
|
* Routes (all under /api/admin/federation, all require AdminGuard):
|
|
*
|
|
* Grant management:
|
|
* POST /api/admin/federation/grants
|
|
* GET /api/admin/federation/grants
|
|
* GET /api/admin/federation/grants/:id
|
|
* PATCH /api/admin/federation/grants/:id/revoke
|
|
* POST /api/admin/federation/grants/:id/tokens
|
|
*
|
|
* Peer management:
|
|
* GET /api/admin/federation/peers
|
|
* POST /api/admin/federation/peers/keypair
|
|
* PATCH /api/admin/federation/peers/:id/cert
|
|
*
|
|
* NOTE: The enrollment REDEMPTION endpoint (POST /api/federation/enrollment/:token)
|
|
* is handled by EnrollmentController — not duplicated here.
|
|
*/
|
|
|
|
import {
|
|
Body,
|
|
Controller,
|
|
Get,
|
|
HttpCode,
|
|
HttpStatus,
|
|
Inject,
|
|
NotFoundException,
|
|
Param,
|
|
Patch,
|
|
Post,
|
|
Query,
|
|
UseGuards,
|
|
} from '@nestjs/common';
|
|
import { webcrypto } from 'node:crypto';
|
|
import { X509Certificate } from 'node:crypto';
|
|
import { Pkcs10CertificateRequestGenerator } from '@peculiar/x509';
|
|
import { type Db, eq, federationPeers } from '@mosaicstack/db';
|
|
import { DB } from '../database/database.module.js';
|
|
import { AdminGuard } from '../admin/admin.guard.js';
|
|
import { GrantsService } from './grants.service.js';
|
|
import { EnrollmentService } from './enrollment.service.js';
|
|
import { sealClientKey } from './peer-key.util.js';
|
|
import { CreateGrantDto, ListGrantsDto } from './grants.dto.js';
|
|
import {
|
|
CreatePeerKeypairDto,
|
|
GenerateEnrollmentTokenDto,
|
|
RevokeGrantBodyDto,
|
|
StorePeerCertDto,
|
|
} from './federation-admin.dto.js';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Convert an ArrayBuffer to a Base64 string (for PEM encoding).
|
|
*/
|
|
function arrayBufferToBase64(buf: ArrayBuffer): string {
|
|
const bytes = new Uint8Array(buf);
|
|
let binary = '';
|
|
for (const b of bytes) {
|
|
binary += String.fromCharCode(b);
|
|
}
|
|
return Buffer.from(binary, 'binary').toString('base64');
|
|
}
|
|
|
|
/**
|
|
* Wrap a Base64 string in PEM armour.
|
|
*/
|
|
function toPem(label: string, b64: string): string {
|
|
const lines = b64.match(/.{1,64}/g) ?? [];
|
|
return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Controller
|
|
// ---------------------------------------------------------------------------
|
|
|
|
@Controller('api/admin/federation')
|
|
@UseGuards(AdminGuard)
|
|
export class FederationController {
|
|
constructor(
|
|
@Inject(DB) private readonly db: Db,
|
|
@Inject(GrantsService) private readonly grantsService: GrantsService,
|
|
@Inject(EnrollmentService) private readonly enrollmentService: EnrollmentService,
|
|
) {}
|
|
|
|
// ─── Grant management ────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* POST /api/admin/federation/grants
|
|
* Create a new grant in pending state.
|
|
*/
|
|
@Post('grants')
|
|
@HttpCode(HttpStatus.CREATED)
|
|
async createGrant(@Body() body: CreateGrantDto) {
|
|
return this.grantsService.createGrant(body);
|
|
}
|
|
|
|
/**
|
|
* GET /api/admin/federation/grants
|
|
* List grants with optional filters.
|
|
*/
|
|
@Get('grants')
|
|
async listGrants(@Query() query: ListGrantsDto) {
|
|
return this.grantsService.listGrants(query);
|
|
}
|
|
|
|
/**
|
|
* GET /api/admin/federation/grants/:id
|
|
* Get a single grant by ID.
|
|
*/
|
|
@Get('grants/:id')
|
|
async getGrant(@Param('id') id: string) {
|
|
return this.grantsService.getGrant(id);
|
|
}
|
|
|
|
/**
|
|
* PATCH /api/admin/federation/grants/:id/revoke
|
|
* Revoke an active grant.
|
|
*/
|
|
@Patch('grants/:id/revoke')
|
|
async revokeGrant(@Param('id') id: string, @Body() body: RevokeGrantBodyDto) {
|
|
return this.grantsService.revokeGrant(id, body.reason);
|
|
}
|
|
|
|
/**
|
|
* POST /api/admin/federation/grants/:id/tokens
|
|
* Generate a single-use enrollment token for a pending grant.
|
|
* Returns the token plus an enrollmentUrl the operator shares out-of-band.
|
|
*/
|
|
@Post('grants/:id/tokens')
|
|
@HttpCode(HttpStatus.CREATED)
|
|
async generateToken(@Param('id') id: string, @Body() body: GenerateEnrollmentTokenDto) {
|
|
const grant = await this.grantsService.getGrant(id);
|
|
|
|
const result = await this.enrollmentService.createToken({
|
|
grantId: id,
|
|
peerId: grant.peerId,
|
|
ttlSeconds: body.ttlSeconds ?? 900,
|
|
});
|
|
|
|
const baseUrl = process.env['BETTER_AUTH_URL'] ?? 'http://localhost:14242';
|
|
const enrollmentUrl = `${baseUrl}/api/federation/enrollment/${result.token}`;
|
|
|
|
return {
|
|
token: result.token,
|
|
expiresAt: result.expiresAt,
|
|
enrollmentUrl,
|
|
};
|
|
}
|
|
|
|
// ─── Peer management ─────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* GET /api/admin/federation/peers
|
|
* List all federation peer rows.
|
|
*/
|
|
@Get('peers')
|
|
async listPeers() {
|
|
return this.db.select().from(federationPeers).orderBy(federationPeers.commonName);
|
|
}
|
|
|
|
/**
|
|
* POST /api/admin/federation/peers/keypair
|
|
* Generate a new peer entry with EC P-256 key pair and a PKCS#10 CSR.
|
|
*
|
|
* Flow:
|
|
* 1. Generate EC P-256 key pair via webcrypto
|
|
* 2. Generate a self-signed CSR via @peculiar/x509
|
|
* 3. Export private key as PEM
|
|
* 4. sealClientKey(privatePem) → sealed blob
|
|
* 5. Insert pending peer row
|
|
* 6. Return { peerId, csrPem }
|
|
*/
|
|
@Post('peers/keypair')
|
|
@HttpCode(HttpStatus.CREATED)
|
|
async createPeerKeypair(@Body() body: CreatePeerKeypairDto) {
|
|
// 1. Generate EC P-256 key pair via Web Crypto
|
|
const keyPair = await webcrypto.subtle.generateKey(
|
|
{ name: 'ECDSA', namedCurve: 'P-256' },
|
|
true, // extractable
|
|
['sign', 'verify'],
|
|
);
|
|
|
|
// 2. Generate PKCS#10 CSR
|
|
const csr = await Pkcs10CertificateRequestGenerator.create({
|
|
name: `CN=${body.commonName}`,
|
|
keys: keyPair,
|
|
signingAlgorithm: { name: 'ECDSA', hash: 'SHA-256' },
|
|
});
|
|
|
|
const csrPem = csr.toString('pem');
|
|
|
|
// 3. Export private key as PKCS#8 PEM
|
|
const pkcs8Der = await webcrypto.subtle.exportKey('pkcs8', keyPair.privateKey);
|
|
const privatePem = toPem('PRIVATE KEY', arrayBufferToBase64(pkcs8Der));
|
|
|
|
// 4. Seal the private key
|
|
const sealed = sealClientKey(privatePem);
|
|
|
|
// 5. Insert pending peer row
|
|
const [peer] = await this.db
|
|
.insert(federationPeers)
|
|
.values({
|
|
commonName: body.commonName,
|
|
displayName: body.displayName,
|
|
certPem: '',
|
|
certSerial: 'pending',
|
|
certNotAfter: new Date(0),
|
|
clientKeyPem: sealed,
|
|
state: 'pending',
|
|
endpointUrl: body.endpointUrl,
|
|
})
|
|
.returning();
|
|
|
|
return {
|
|
peerId: peer!.id,
|
|
csrPem,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* PATCH /api/admin/federation/peers/:id/cert
|
|
* Store a signed certificate after enrollment completes.
|
|
*
|
|
* Flow:
|
|
* 1. Parse the cert to extract serial and notAfter
|
|
* 2. Update the peer row with cert data + state='active'
|
|
* 3. Return the updated peer row
|
|
*/
|
|
@Patch('peers/:id/cert')
|
|
async storePeerCert(@Param('id') id: string, @Body() body: StorePeerCertDto) {
|
|
// Ensure peer exists
|
|
const [existing] = await this.db
|
|
.select({ id: federationPeers.id })
|
|
.from(federationPeers)
|
|
.where(eq(federationPeers.id, id))
|
|
.limit(1);
|
|
|
|
if (!existing) {
|
|
throw new NotFoundException(`Peer ${id} not found`);
|
|
}
|
|
|
|
// 1. Parse cert
|
|
const x509 = new X509Certificate(body.certPem);
|
|
const certSerial = x509.serialNumber;
|
|
const certNotAfter = new Date(x509.validTo);
|
|
|
|
// 2. Update peer
|
|
const [updated] = await this.db
|
|
.update(federationPeers)
|
|
.set({
|
|
certPem: body.certPem,
|
|
certSerial,
|
|
certNotAfter,
|
|
state: 'active',
|
|
})
|
|
.where(eq(federationPeers.id, id))
|
|
.returning();
|
|
|
|
return updated;
|
|
}
|
|
}
|