187 lines
6.1 KiB
TypeScript
187 lines
6.1 KiB
TypeScript
/**
|
|
* Passkey REST API Server — Spec Section 3.7, 16.3
|
|
*
|
|
* Proxies browser FIDO2 requests to PasskeyPlatformService (→ amiPro FIDO2 server).
|
|
*
|
|
* Endpoints:
|
|
* POST /api/passkey/attestation/options — Get registration challenge
|
|
* POST /api/passkey/attestation/result — Submit registration result
|
|
* POST /api/passkey/assertion/options — Get authentication challenge
|
|
* POST /api/passkey/assertion/result — Submit authentication result
|
|
* POST /api/passkey/session/validate — Validate a session
|
|
* POST /api/passkey/session/delete — Delete/logout a session
|
|
*/
|
|
|
|
import http from 'node:http';
|
|
import type { PasskeyPlatformService } from './platform-service.js';
|
|
|
|
export class PasskeyAPIServer {
|
|
private server: http.Server | null = null;
|
|
|
|
constructor(
|
|
private readonly platformService: PasskeyPlatformService,
|
|
private readonly rpId: string,
|
|
private readonly origin: string,
|
|
) {}
|
|
|
|
start(port: number): void {
|
|
this.server = http.createServer((req, res) => {
|
|
void this.handleRequest(req, res);
|
|
});
|
|
|
|
this.server.listen(port, '127.0.0.1', () => {
|
|
console.log(`[passkey-api] Server listening on http://127.0.0.1:${port}`);
|
|
});
|
|
}
|
|
|
|
stop(): void {
|
|
this.server?.close();
|
|
}
|
|
|
|
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
|
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
|
|
// CORS headers
|
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
|
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
|
|
if (req.method === 'OPTIONS') {
|
|
res.writeHead(200);
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
if (req.method !== 'POST') {
|
|
sendJson(res, 405, { error: 'Method Not Allowed' });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const body = await parseJsonBody(req) as Record<string, unknown>;
|
|
|
|
switch (url.pathname) {
|
|
case '/api/passkey/attestation/options':
|
|
await this.handleAttestationOptions(res, body);
|
|
break;
|
|
case '/api/passkey/attestation/result':
|
|
await this.handleAttestationResult(res, body);
|
|
break;
|
|
case '/api/passkey/assertion/options':
|
|
await this.handleAssertionOptions(res, body);
|
|
break;
|
|
case '/api/passkey/assertion/result':
|
|
await this.handleAssertionResult(res, body);
|
|
break;
|
|
case '/api/passkey/session/validate':
|
|
await this.handleSessionValidate(res, body);
|
|
break;
|
|
case '/api/passkey/session/delete':
|
|
await this.handleSessionDelete(res, body);
|
|
break;
|
|
default:
|
|
sendJson(res, 404, { error: 'Not Found' });
|
|
}
|
|
} catch (err) {
|
|
sendJson(res, 400, { error: (err as Error).message });
|
|
}
|
|
}
|
|
|
|
// ─── Registration ────────────────────────────────────────────────────
|
|
|
|
private async handleAttestationOptions(
|
|
res: http.ServerResponse,
|
|
body: Record<string, unknown>,
|
|
): Promise<void> {
|
|
const username = body.username as string;
|
|
const displayName = (body.displayName as string) || username;
|
|
|
|
if (!username) {
|
|
sendJson(res, 400, { error: 'username is required' });
|
|
return;
|
|
}
|
|
|
|
const result = await this.platformService.getAttestationOptions(username, displayName);
|
|
sendJson(res, result.success ? 200 : 400, result.success ? result.options : { error: result.error });
|
|
}
|
|
|
|
private async handleAttestationResult(
|
|
res: http.ServerResponse,
|
|
body: Record<string, unknown>,
|
|
): Promise<void> {
|
|
const result = await this.platformService.submitAttestationResult(body as any);
|
|
sendJson(res, result.success ? 200 : 400, result);
|
|
}
|
|
|
|
// ─── Authentication ──────────────────────────────────────────────────
|
|
|
|
private async handleAssertionOptions(
|
|
res: http.ServerResponse,
|
|
body: Record<string, unknown>,
|
|
): Promise<void> {
|
|
const username = body.username as string | undefined;
|
|
|
|
const result = await this.platformService.getAssertionOptions(username);
|
|
sendJson(res, result.success ? 200 : 400, result.success ? result.options : { error: result.error });
|
|
}
|
|
|
|
private async handleAssertionResult(
|
|
res: http.ServerResponse,
|
|
body: Record<string, unknown>,
|
|
): Promise<void> {
|
|
const result = await this.platformService.submitAssertionResult(body as any);
|
|
sendJson(res, result.success ? 200 : 400, result);
|
|
}
|
|
|
|
// ─── Session ─────────────────────────────────────────────────────────
|
|
|
|
private async handleSessionValidate(
|
|
res: http.ServerResponse,
|
|
body: Record<string, unknown>,
|
|
): Promise<void> {
|
|
const session = body.session as string;
|
|
if (!session) {
|
|
sendJson(res, 400, { error: 'session is required' });
|
|
return;
|
|
}
|
|
|
|
const valid = await this.platformService.validateSession(session);
|
|
sendJson(res, 200, { valid });
|
|
}
|
|
|
|
private async handleSessionDelete(
|
|
res: http.ServerResponse,
|
|
body: Record<string, unknown>,
|
|
): Promise<void> {
|
|
const session = body.session as string;
|
|
const username = body.username as string;
|
|
if (!session || !username) {
|
|
sendJson(res, 400, { error: 'session and username are required' });
|
|
return;
|
|
}
|
|
|
|
await this.platformService.deleteSession(session, username);
|
|
sendJson(res, 200, { ok: true });
|
|
}
|
|
}
|
|
|
|
function sendJson(res: http.ServerResponse, status: number, body: unknown): void {
|
|
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify(body));
|
|
}
|
|
|
|
function parseJsonBody(req: http.IncomingMessage): Promise<unknown> {
|
|
return new Promise((resolve, reject) => {
|
|
let data = '';
|
|
req.on('data', (chunk: Buffer) => { data += chunk.toString(); });
|
|
req.on('end', () => {
|
|
try {
|
|
resolve(JSON.parse(data));
|
|
} catch {
|
|
reject(new Error('Invalid JSON'));
|
|
}
|
|
});
|
|
req.on('error', reject);
|
|
});
|
|
}
|