Compare commits
1 Commits
feat/feder
...
e5a2ebcf48
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5a2ebcf48 |
@@ -56,7 +56,6 @@
|
|||||||
"@opentelemetry/sdk-metrics": "^2.6.0",
|
"@opentelemetry/sdk-metrics": "^2.6.0",
|
||||||
"@opentelemetry/sdk-node": "^0.213.0",
|
"@opentelemetry/sdk-node": "^0.213.0",
|
||||||
"@opentelemetry/semantic-conventions": "^1.40.0",
|
"@opentelemetry/semantic-conventions": "^1.40.0",
|
||||||
"@peculiar/x509": "^2.0.0",
|
|
||||||
"@sinclair/typebox": "^0.34.48",
|
"@sinclair/typebox": "^0.34.48",
|
||||||
"better-auth": "^1.5.5",
|
"better-auth": "^1.5.5",
|
||||||
"bullmq": "^5.71.0",
|
"bullmq": "^5.71.0",
|
||||||
@@ -65,7 +64,6 @@
|
|||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"fastify": "^5.0.0",
|
"fastify": "^5.0.0",
|
||||||
"ioredis": "^5.10.0",
|
"ioredis": "^5.10.0",
|
||||||
"jose": "^6.2.2",
|
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"openai": "^6.32.0",
|
"openai": "^6.32.0",
|
||||||
"postgres": "^3.4.8",
|
"postgres": "^3.4.8",
|
||||||
|
|||||||
@@ -1,243 +0,0 @@
|
|||||||
/**
|
|
||||||
* Federation M2 E2E test — peer-add enrollment flow (FED-M2-10).
|
|
||||||
*
|
|
||||||
* Covers MILESTONES.md acceptance test #6:
|
|
||||||
* "`peer add <url>` on Server A yields an `active` peer record with a valid cert + key"
|
|
||||||
*
|
|
||||||
* This test simulates two gateways using a single bootstrapped NestJS app:
|
|
||||||
* - "Server A": the admin API that generates a keypair and stores the cert
|
|
||||||
* - "Server B": the enrollment endpoint that signs the CSR
|
|
||||||
* Both share the same DB + Step-CA in the test environment.
|
|
||||||
*
|
|
||||||
* Prerequisites:
|
|
||||||
* docker compose -f docker-compose.federated.yml --profile federated up -d
|
|
||||||
*
|
|
||||||
* Run:
|
|
||||||
* FEDERATED_INTEGRATION=1 STEP_CA_AVAILABLE=1 \
|
|
||||||
* STEP_CA_URL=https://localhost:9000 \
|
|
||||||
* STEP_CA_PROVISIONER_KEY_JSON="$(docker exec $(docker ps -qf name=step-ca) cat /home/step/secrets/mosaic-fed.json)" \
|
|
||||||
* STEP_CA_ROOT_CERT_PATH=/tmp/step-ca-root.crt \
|
|
||||||
* pnpm --filter @mosaicstack/gateway test \
|
|
||||||
* src/__tests__/integration/federation-m2-e2e.integration.test.ts
|
|
||||||
*
|
|
||||||
* Obtaining Step-CA credentials:
|
|
||||||
* # Extract provisioner key from running container:
|
|
||||||
* # docker exec $(docker ps -qf name=step-ca) cat /home/step/secrets/mosaic-fed.json
|
|
||||||
* # Copy root cert from container:
|
|
||||||
* # docker cp $(docker ps -qf name=step-ca):/home/step/certs/root_ca.crt /tmp/step-ca-root.crt
|
|
||||||
* # Then: export STEP_CA_ROOT_CERT_PATH=/tmp/step-ca-root.crt
|
|
||||||
*
|
|
||||||
* Skipped unless both FEDERATED_INTEGRATION=1 and STEP_CA_AVAILABLE=1 are set.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import * as crypto from 'node:crypto';
|
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
||||||
import { Test } from '@nestjs/testing';
|
|
||||||
import { ValidationPipe } from '@nestjs/common';
|
|
||||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
|
||||||
import supertest from 'supertest';
|
|
||||||
import {
|
|
||||||
createDb,
|
|
||||||
type Db,
|
|
||||||
type DbHandle,
|
|
||||||
federationPeers,
|
|
||||||
federationGrants,
|
|
||||||
federationEnrollmentTokens,
|
|
||||||
inArray,
|
|
||||||
eq,
|
|
||||||
} from '@mosaicstack/db';
|
|
||||||
import * as schema from '@mosaicstack/db';
|
|
||||||
import { DB } from '../../database/database.module.js';
|
|
||||||
import { AdminGuard } from '../../admin/admin.guard.js';
|
|
||||||
import { FederationModule } from '../../federation/federation.module.js';
|
|
||||||
import { GrantsService } from '../../federation/grants.service.js';
|
|
||||||
import { EnrollmentService } from '../../federation/enrollment.service.js';
|
|
||||||
|
|
||||||
const run = process.env['FEDERATED_INTEGRATION'] === '1';
|
|
||||||
const stepCaRun =
|
|
||||||
run &&
|
|
||||||
process.env['STEP_CA_AVAILABLE'] === '1' &&
|
|
||||||
!!process.env['STEP_CA_URL'] &&
|
|
||||||
!!process.env['STEP_CA_PROVISIONER_KEY_JSON'] &&
|
|
||||||
!!process.env['STEP_CA_ROOT_CERT_PATH'];
|
|
||||||
|
|
||||||
const PG_URL = 'postgresql://mosaic:mosaic@localhost:5433/mosaic';
|
|
||||||
|
|
||||||
const RUN_ID = crypto.randomUUID();
|
|
||||||
|
|
||||||
describe.skipIf(!stepCaRun)('federation M2 E2E — peer add enrollment flow', () => {
|
|
||||||
let handle: DbHandle;
|
|
||||||
let db: Db;
|
|
||||||
let app: NestFastifyApplication;
|
|
||||||
let agent: ReturnType<typeof supertest>;
|
|
||||||
let grantsService: GrantsService;
|
|
||||||
let enrollmentService: EnrollmentService;
|
|
||||||
|
|
||||||
const createdTokenGrantIds: string[] = [];
|
|
||||||
const createdGrantIds: string[] = [];
|
|
||||||
const createdPeerIds: string[] = [];
|
|
||||||
const createdUserIds: string[] = [];
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
process.env['BETTER_AUTH_SECRET'] ??= 'test-e2e-sealing-key';
|
|
||||||
|
|
||||||
handle = createDb(PG_URL);
|
|
||||||
db = handle.db;
|
|
||||||
|
|
||||||
const moduleRef = await Test.createTestingModule({
|
|
||||||
imports: [FederationModule],
|
|
||||||
providers: [{ provide: DB, useValue: db }],
|
|
||||||
})
|
|
||||||
.overrideGuard(AdminGuard)
|
|
||||||
.useValue({ canActivate: () => true })
|
|
||||||
.compile();
|
|
||||||
|
|
||||||
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
|
||||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
|
|
||||||
await app.init();
|
|
||||||
await app.getHttpAdapter().getInstance().ready();
|
|
||||||
|
|
||||||
agent = supertest(app.getHttpServer());
|
|
||||||
|
|
||||||
grantsService = moduleRef.get(GrantsService);
|
|
||||||
enrollmentService = moduleRef.get(EnrollmentService);
|
|
||||||
}, 30_000);
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
if (db && createdTokenGrantIds.length > 0) {
|
|
||||||
await db
|
|
||||||
.delete(federationEnrollmentTokens)
|
|
||||||
.where(inArray(federationEnrollmentTokens.grantId, createdTokenGrantIds))
|
|
||||||
.catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e));
|
|
||||||
}
|
|
||||||
if (db && createdGrantIds.length > 0) {
|
|
||||||
await db
|
|
||||||
.delete(federationGrants)
|
|
||||||
.where(inArray(federationGrants.id, createdGrantIds))
|
|
||||||
.catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e));
|
|
||||||
}
|
|
||||||
if (db && createdPeerIds.length > 0) {
|
|
||||||
await db
|
|
||||||
.delete(federationPeers)
|
|
||||||
.where(inArray(federationPeers.id, createdPeerIds))
|
|
||||||
.catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e));
|
|
||||||
}
|
|
||||||
if (db && createdUserIds.length > 0) {
|
|
||||||
await db
|
|
||||||
.delete(schema.users)
|
|
||||||
.where(inArray(schema.users.id, createdUserIds))
|
|
||||||
.catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e));
|
|
||||||
}
|
|
||||||
if (app)
|
|
||||||
await app.close().catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e));
|
|
||||||
if (handle)
|
|
||||||
await handle.close().catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e));
|
|
||||||
});
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// #6 — peer add: keypair → enrollment → cert storage → active peer record
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
it('#6 — peer add flow: keypair → enrollment → cert storage → active peer record', async () => {
|
|
||||||
// Create a subject user to satisfy FK on federation_grants.subject_user_id
|
|
||||||
const userId = crypto.randomUUID();
|
|
||||||
await db
|
|
||||||
.insert(schema.users)
|
|
||||||
.values({
|
|
||||||
id: userId,
|
|
||||||
name: `e2e-user-${RUN_ID}`,
|
|
||||||
email: `e2e-${RUN_ID}@federation-test.invalid`,
|
|
||||||
emailVerified: false,
|
|
||||||
})
|
|
||||||
.onConflictDoNothing();
|
|
||||||
createdUserIds.push(userId);
|
|
||||||
|
|
||||||
// ── Step A: "Server B" setup ─────────────────────────────────────────
|
|
||||||
// Server B admin creates a grant and generates an enrollment token to
|
|
||||||
// share out-of-band with Server A's operator.
|
|
||||||
|
|
||||||
// Insert a placeholder peer on "Server B" to satisfy the grant FK
|
|
||||||
const serverBPeerId = crypto.randomUUID();
|
|
||||||
await db
|
|
||||||
.insert(federationPeers)
|
|
||||||
.values({
|
|
||||||
id: serverBPeerId,
|
|
||||||
commonName: `server-b-peer-${RUN_ID}`,
|
|
||||||
displayName: 'Server B Placeholder',
|
|
||||||
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
|
|
||||||
certSerial: `serial-b-${serverBPeerId}`,
|
|
||||||
certNotAfter: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000),
|
|
||||||
state: 'pending',
|
|
||||||
})
|
|
||||||
.onConflictDoNothing();
|
|
||||||
createdPeerIds.push(serverBPeerId);
|
|
||||||
|
|
||||||
const grant = await grantsService.createGrant({
|
|
||||||
subjectUserId: userId,
|
|
||||||
scope: { resources: ['tasks'], excluded_resources: [], max_rows_per_query: 100 },
|
|
||||||
peerId: serverBPeerId,
|
|
||||||
});
|
|
||||||
createdGrantIds.push(grant.id);
|
|
||||||
createdTokenGrantIds.push(grant.id);
|
|
||||||
|
|
||||||
const { token } = await enrollmentService.createToken({
|
|
||||||
grantId: grant.id,
|
|
||||||
peerId: serverBPeerId,
|
|
||||||
ttlSeconds: 900,
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Step B: "Server A" generates keypair ─────────────────────────────
|
|
||||||
const keypairRes = await agent
|
|
||||||
.post('/api/admin/federation/peers/keypair')
|
|
||||||
.send({
|
|
||||||
commonName: `e2e-peer-${RUN_ID.slice(0, 8)}`,
|
|
||||||
displayName: 'E2E Test Peer',
|
|
||||||
endpointUrl: 'https://test.invalid',
|
|
||||||
})
|
|
||||||
.set('Content-Type', 'application/json');
|
|
||||||
|
|
||||||
expect(keypairRes.status).toBe(201);
|
|
||||||
const { peerId, csrPem } = keypairRes.body as { peerId: string; csrPem: string };
|
|
||||||
expect(typeof peerId).toBe('string');
|
|
||||||
expect(csrPem).toContain('-----BEGIN CERTIFICATE REQUEST-----');
|
|
||||||
createdPeerIds.push(peerId);
|
|
||||||
|
|
||||||
// ── Step C: Enrollment (simulates Server A sending CSR to Server B) ──
|
|
||||||
const enrollRes = await agent
|
|
||||||
.post(`/api/federation/enrollment/${token}`)
|
|
||||||
.send({ csrPem })
|
|
||||||
.set('Content-Type', 'application/json');
|
|
||||||
|
|
||||||
expect(enrollRes.status).toBe(200);
|
|
||||||
const { certPem, certChainPem } = enrollRes.body as {
|
|
||||||
certPem: string;
|
|
||||||
certChainPem: string;
|
|
||||||
};
|
|
||||||
expect(certPem).toContain('-----BEGIN CERTIFICATE-----');
|
|
||||||
expect(certChainPem).toContain('-----BEGIN CERTIFICATE-----');
|
|
||||||
|
|
||||||
// ── Step D: "Server A" stores the cert ───────────────────────────────
|
|
||||||
const storeRes = await agent
|
|
||||||
.patch(`/api/admin/federation/peers/${peerId}/cert`)
|
|
||||||
.send({ certPem })
|
|
||||||
.set('Content-Type', 'application/json');
|
|
||||||
|
|
||||||
expect(storeRes.status).toBe(200);
|
|
||||||
|
|
||||||
// ── Step E: Verify peer record in DB ─────────────────────────────────
|
|
||||||
const [peer] = await db
|
|
||||||
.select()
|
|
||||||
.from(federationPeers)
|
|
||||||
.where(eq(federationPeers.id, peerId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
expect(peer).toBeDefined();
|
|
||||||
expect(peer?.state).toBe('active');
|
|
||||||
expect(peer?.certPem).toContain('-----BEGIN CERTIFICATE-----');
|
|
||||||
expect(typeof peer?.certSerial).toBe('string');
|
|
||||||
expect((peer?.certSerial ?? '').length).toBeGreaterThan(0);
|
|
||||||
// clientKeyPem is a sealed ciphertext — must not be a raw PEM
|
|
||||||
expect(peer?.clientKeyPem?.startsWith('-----BEGIN')).toBe(false);
|
|
||||||
// certNotAfter must be in the future
|
|
||||||
expect(peer?.certNotAfter?.getTime()).toBeGreaterThan(Date.now());
|
|
||||||
}, 60_000);
|
|
||||||
});
|
|
||||||
@@ -1,483 +0,0 @@
|
|||||||
/**
|
|
||||||
* Federation M2 integration tests (FED-M2-09).
|
|
||||||
*
|
|
||||||
* Covers MILESTONES.md acceptance tests #1, #2, #3, #5, #7, #8.
|
|
||||||
*
|
|
||||||
* Prerequisites:
|
|
||||||
* docker compose -f docker-compose.federated.yml --profile federated up -d
|
|
||||||
*
|
|
||||||
* Run DB-only tests (no Step-CA):
|
|
||||||
* FEDERATED_INTEGRATION=1 BETTER_AUTH_SECRET=test-secret pnpm --filter @mosaicstack/gateway test \
|
|
||||||
* src/__tests__/integration/federation-m2.integration.test.ts
|
|
||||||
*
|
|
||||||
* Run all tests including Step-CA-dependent ones:
|
|
||||||
* FEDERATED_INTEGRATION=1 STEP_CA_AVAILABLE=1 \
|
|
||||||
* STEP_CA_URL=https://localhost:9000 \
|
|
||||||
* STEP_CA_PROVISIONER_KEY_JSON="$(docker exec $(docker ps -qf name=step-ca) cat /home/step/secrets/mosaic-fed.json)" \
|
|
||||||
* STEP_CA_ROOT_CERT_PATH=/tmp/step-ca-root.crt \
|
|
||||||
* pnpm --filter @mosaicstack/gateway test \
|
|
||||||
* src/__tests__/integration/federation-m2.integration.test.ts
|
|
||||||
*
|
|
||||||
* Obtaining Step-CA credentials:
|
|
||||||
* # Extract provisioner key from running container:
|
|
||||||
* # docker exec $(docker ps -qf name=step-ca) cat /home/step/secrets/mosaic-fed.json
|
|
||||||
* # Copy root cert from container:
|
|
||||||
* # docker cp $(docker ps -qf name=step-ca):/home/step/certs/root_ca.crt /tmp/step-ca-root.crt
|
|
||||||
* # Then: export STEP_CA_ROOT_CERT_PATH=/tmp/step-ca-root.crt
|
|
||||||
*/
|
|
||||||
|
|
||||||
import * as crypto from 'node:crypto';
|
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
||||||
import { Test } from '@nestjs/testing';
|
|
||||||
import { GoneException } from '@nestjs/common';
|
|
||||||
import { Pkcs10CertificateRequestGenerator, X509Certificate as PeculiarX509 } from '@peculiar/x509';
|
|
||||||
import {
|
|
||||||
createDb,
|
|
||||||
type Db,
|
|
||||||
type DbHandle,
|
|
||||||
federationPeers,
|
|
||||||
federationGrants,
|
|
||||||
federationEnrollmentTokens,
|
|
||||||
inArray,
|
|
||||||
eq,
|
|
||||||
} from '@mosaicstack/db';
|
|
||||||
import * as schema from '@mosaicstack/db';
|
|
||||||
import { seal } from '@mosaicstack/auth';
|
|
||||||
import { DB } from '../../database/database.module.js';
|
|
||||||
import { GrantsService } from '../../federation/grants.service.js';
|
|
||||||
import { EnrollmentService } from '../../federation/enrollment.service.js';
|
|
||||||
import { CaService } from '../../federation/ca.service.js';
|
|
||||||
import { FederationScopeError } from '../../federation/scope-schema.js';
|
|
||||||
|
|
||||||
const run = process.env['FEDERATED_INTEGRATION'] === '1';
|
|
||||||
const stepCaRun = run && process.env['STEP_CA_AVAILABLE'] === '1';
|
|
||||||
|
|
||||||
const PG_URL = 'postgresql://mosaic:mosaic@localhost:5433/mosaic';
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers for test data isolation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Unique run prefix to identify rows created by this test run. */
|
|
||||||
const RUN_ID = crypto.randomUUID();
|
|
||||||
|
|
||||||
/** Insert a minimal user row to satisfy the FK on federation_grants.subject_user_id. */
|
|
||||||
async function insertTestUser(db: Db, id: string): Promise<void> {
|
|
||||||
await db
|
|
||||||
.insert(schema.users)
|
|
||||||
.values({
|
|
||||||
id,
|
|
||||||
name: `test-user-${id}`,
|
|
||||||
email: `test-${id}@federation-test.invalid`,
|
|
||||||
emailVerified: false,
|
|
||||||
})
|
|
||||||
.onConflictDoNothing();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Insert a minimal peer row to satisfy the FK on federation_grants.peer_id. */
|
|
||||||
async function insertTestPeer(db: Db, id: string, suffix: string = ''): Promise<void> {
|
|
||||||
await db
|
|
||||||
.insert(federationPeers)
|
|
||||||
.values({
|
|
||||||
id,
|
|
||||||
commonName: `test-peer-${RUN_ID}-${suffix}`,
|
|
||||||
displayName: `Test Peer ${suffix}`,
|
|
||||||
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
|
|
||||||
certSerial: `test-serial-${id}`,
|
|
||||||
certNotAfter: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000),
|
|
||||||
state: 'pending',
|
|
||||||
})
|
|
||||||
.onConflictDoNothing();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// DB-only test module (CaService mocked so env vars not required)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function buildDbModule(db: Db) {
|
|
||||||
return Test.createTestingModule({
|
|
||||||
providers: [
|
|
||||||
{ provide: DB, useValue: db },
|
|
||||||
GrantsService,
|
|
||||||
{
|
|
||||||
provide: CaService,
|
|
||||||
useValue: {
|
|
||||||
issueCert: async () => {
|
|
||||||
throw new Error('CaService.issueCert should not be called in DB-only tests');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
EnrollmentService,
|
|
||||||
],
|
|
||||||
}).compile();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Test suite — DB-only (no Step-CA)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
describe.skipIf(!run)('federation M2 — DB-only tests', () => {
|
|
||||||
let handle: DbHandle;
|
|
||||||
let db: Db;
|
|
||||||
let grantsService: GrantsService;
|
|
||||||
|
|
||||||
/** IDs created during this run — cleaned up in afterAll. */
|
|
||||||
const createdGrantIds: string[] = [];
|
|
||||||
const createdPeerIds: string[] = [];
|
|
||||||
const createdUserIds: string[] = [];
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
process.env['BETTER_AUTH_SECRET'] ??= 'test-integration-sealing-key-not-for-prod';
|
|
||||||
|
|
||||||
handle = createDb(PG_URL);
|
|
||||||
db = handle.db;
|
|
||||||
|
|
||||||
const moduleRef = await buildDbModule(db);
|
|
||||||
grantsService = moduleRef.get(GrantsService);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
// Clean up in FK-safe order: tokens → grants → peers → users
|
|
||||||
if (db && createdGrantIds.length > 0) {
|
|
||||||
await db
|
|
||||||
.delete(federationEnrollmentTokens)
|
|
||||||
.where(inArray(federationEnrollmentTokens.grantId, createdGrantIds))
|
|
||||||
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
|
||||||
await db
|
|
||||||
.delete(federationGrants)
|
|
||||||
.where(inArray(federationGrants.id, createdGrantIds))
|
|
||||||
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
|
||||||
}
|
|
||||||
if (db && createdPeerIds.length > 0) {
|
|
||||||
await db
|
|
||||||
.delete(federationPeers)
|
|
||||||
.where(inArray(federationPeers.id, createdPeerIds))
|
|
||||||
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
|
||||||
}
|
|
||||||
if (db && createdUserIds.length > 0) {
|
|
||||||
await db
|
|
||||||
.delete(schema.users)
|
|
||||||
.where(inArray(schema.users.id, createdUserIds))
|
|
||||||
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
|
||||||
}
|
|
||||||
if (handle)
|
|
||||||
await handle.close().catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
|
||||||
});
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// #1 — grant create writes a pending row
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
it('#1 — createGrant writes a pending row to DB', async () => {
|
|
||||||
const userId = crypto.randomUUID();
|
|
||||||
const peerId = crypto.randomUUID();
|
|
||||||
const validScope = {
|
|
||||||
resources: ['tasks'],
|
|
||||||
excluded_resources: [],
|
|
||||||
max_rows_per_query: 100,
|
|
||||||
};
|
|
||||||
|
|
||||||
await insertTestUser(db, userId);
|
|
||||||
await insertTestPeer(db, peerId, 'test1');
|
|
||||||
createdUserIds.push(userId);
|
|
||||||
createdPeerIds.push(peerId);
|
|
||||||
|
|
||||||
const grant = await grantsService.createGrant({
|
|
||||||
subjectUserId: userId,
|
|
||||||
scope: validScope,
|
|
||||||
peerId,
|
|
||||||
});
|
|
||||||
|
|
||||||
createdGrantIds.push(grant.id);
|
|
||||||
|
|
||||||
// Verify the row exists in DB with correct shape
|
|
||||||
const [row] = await db
|
|
||||||
.select()
|
|
||||||
.from(federationGrants)
|
|
||||||
.where(eq(federationGrants.id, grant.id))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
expect(row).toBeDefined();
|
|
||||||
expect(row?.status).toBe('pending');
|
|
||||||
expect(row?.peerId).toBe(peerId);
|
|
||||||
expect(row?.subjectUserId).toBe(userId);
|
|
||||||
const storedScope = row?.scope as Record<string, unknown>;
|
|
||||||
expect(storedScope['resources']).toEqual(['tasks']);
|
|
||||||
expect(storedScope['max_rows_per_query']).toBe(100);
|
|
||||||
}, 15_000);
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// #7 — scope with unknown resource type rejected
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
it('#7 — createGrant rejects scope with unknown resource type', async () => {
|
|
||||||
const userId = crypto.randomUUID();
|
|
||||||
const peerId = crypto.randomUUID();
|
|
||||||
const invalidScope = {
|
|
||||||
resources: ['totally_unknown_resource'],
|
|
||||||
excluded_resources: [],
|
|
||||||
max_rows_per_query: 100,
|
|
||||||
};
|
|
||||||
|
|
||||||
await insertTestUser(db, userId);
|
|
||||||
await insertTestPeer(db, peerId, 'test7');
|
|
||||||
createdUserIds.push(userId);
|
|
||||||
createdPeerIds.push(peerId);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
grantsService.createGrant({
|
|
||||||
subjectUserId: userId,
|
|
||||||
scope: invalidScope,
|
|
||||||
peerId,
|
|
||||||
}),
|
|
||||||
).rejects.toThrow(FederationScopeError);
|
|
||||||
}, 15_000);
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// #8 — listGrants returns accurate status for grants in various states
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
it('#8 — listGrants returns accurate status for grants in various states', async () => {
|
|
||||||
const userId = crypto.randomUUID();
|
|
||||||
const peerId = crypto.randomUUID();
|
|
||||||
const validScope = {
|
|
||||||
resources: ['notes'],
|
|
||||||
excluded_resources: [],
|
|
||||||
max_rows_per_query: 50,
|
|
||||||
};
|
|
||||||
|
|
||||||
await insertTestUser(db, userId);
|
|
||||||
await insertTestPeer(db, peerId, 'test8');
|
|
||||||
createdUserIds.push(userId);
|
|
||||||
createdPeerIds.push(peerId);
|
|
||||||
|
|
||||||
// Create two pending grants via GrantsService
|
|
||||||
const grantA = await grantsService.createGrant({
|
|
||||||
subjectUserId: userId,
|
|
||||||
scope: validScope,
|
|
||||||
peerId,
|
|
||||||
});
|
|
||||||
const grantB = await grantsService.createGrant({
|
|
||||||
subjectUserId: userId,
|
|
||||||
scope: { resources: ['tasks'], excluded_resources: [], max_rows_per_query: 50 },
|
|
||||||
peerId,
|
|
||||||
});
|
|
||||||
createdGrantIds.push(grantA.id, grantB.id);
|
|
||||||
|
|
||||||
// Insert a third grant directly in 'revoked' state to test status variety
|
|
||||||
const [grantC] = await db
|
|
||||||
.insert(federationGrants)
|
|
||||||
.values({
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
subjectUserId: userId,
|
|
||||||
peerId,
|
|
||||||
scope: validScope,
|
|
||||||
status: 'revoked',
|
|
||||||
revokedAt: new Date(),
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
createdGrantIds.push(grantC!.id);
|
|
||||||
|
|
||||||
// List all grants for this peer
|
|
||||||
const allForPeer = await grantsService.listGrants({ peerId });
|
|
||||||
|
|
||||||
const ourGrantIds = new Set([grantA.id, grantB.id, grantC!.id]);
|
|
||||||
const ourGrants = allForPeer.filter((g) => ourGrantIds.has(g.id));
|
|
||||||
expect(ourGrants).toHaveLength(3);
|
|
||||||
|
|
||||||
const pendingGrants = ourGrants.filter((g) => g.status === 'pending');
|
|
||||||
const revokedGrants = ourGrants.filter((g) => g.status === 'revoked');
|
|
||||||
expect(pendingGrants).toHaveLength(2);
|
|
||||||
expect(revokedGrants).toHaveLength(1);
|
|
||||||
|
|
||||||
// Status-filtered query
|
|
||||||
const pendingOnly = await grantsService.listGrants({ peerId, status: 'pending' });
|
|
||||||
const ourPending = pendingOnly.filter((g) => ourGrantIds.has(g.id));
|
|
||||||
expect(ourPending.every((g) => g.status === 'pending')).toBe(true);
|
|
||||||
|
|
||||||
// Verify peer list from DB also shows the peer rows with correct state
|
|
||||||
const peers = await db.select().from(federationPeers).where(eq(federationPeers.id, peerId));
|
|
||||||
expect(peers).toHaveLength(1);
|
|
||||||
expect(peers[0]?.state).toBe('pending');
|
|
||||||
}, 15_000);
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// #5 — client_key_pem encrypted at rest
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
it('#5 — clientKeyPem stored in DB is a sealed ciphertext (not a valid PEM)', async () => {
|
|
||||||
const peerId = crypto.randomUUID();
|
|
||||||
const rawPem = '-----BEGIN PRIVATE KEY-----\nMOCK\n-----END PRIVATE KEY-----\n';
|
|
||||||
const sealed = seal(rawPem);
|
|
||||||
|
|
||||||
await db.insert(federationPeers).values({
|
|
||||||
id: peerId,
|
|
||||||
commonName: `test-peer-${RUN_ID}-sealed`,
|
|
||||||
displayName: 'Sealed Key Test Peer',
|
|
||||||
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
|
|
||||||
certSerial: `test-serial-sealed-${peerId}`,
|
|
||||||
certNotAfter: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000),
|
|
||||||
state: 'pending',
|
|
||||||
clientKeyPem: sealed,
|
|
||||||
});
|
|
||||||
createdPeerIds.push(peerId);
|
|
||||||
|
|
||||||
const [row] = await db
|
|
||||||
.select()
|
|
||||||
.from(federationPeers)
|
|
||||||
.where(eq(federationPeers.id, peerId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
expect(row).toBeDefined();
|
|
||||||
// The stored value must NOT be a valid PEM — it's a sealed ciphertext blob
|
|
||||||
expect(row?.clientKeyPem).toBeDefined();
|
|
||||||
expect(row?.clientKeyPem?.startsWith('-----BEGIN')).toBe(false);
|
|
||||||
// The sealed value should be non-trivial (at least 20 chars)
|
|
||||||
expect((row?.clientKeyPem ?? '').length).toBeGreaterThan(20);
|
|
||||||
}, 15_000);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Test suite — Step-CA gated
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
describe.skipIf(!stepCaRun)('federation M2 — Step-CA tests', () => {
|
|
||||||
let handle: DbHandle;
|
|
||||||
let db: Db;
|
|
||||||
let grantsService: GrantsService;
|
|
||||||
let enrollmentService: EnrollmentService;
|
|
||||||
|
|
||||||
const createdGrantIds: string[] = [];
|
|
||||||
const createdPeerIds: string[] = [];
|
|
||||||
const createdUserIds: string[] = [];
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
handle = createDb(PG_URL);
|
|
||||||
db = handle.db;
|
|
||||||
|
|
||||||
// Use real CaService — env vars (STEP_CA_URL, STEP_CA_PROVISIONER_KEY_JSON,
|
|
||||||
// STEP_CA_ROOT_CERT_PATH) must be set when STEP_CA_AVAILABLE=1
|
|
||||||
const moduleRef = await Test.createTestingModule({
|
|
||||||
providers: [{ provide: DB, useValue: db }, CaService, GrantsService, EnrollmentService],
|
|
||||||
}).compile();
|
|
||||||
|
|
||||||
grantsService = moduleRef.get(GrantsService);
|
|
||||||
enrollmentService = moduleRef.get(EnrollmentService);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
if (db && createdGrantIds.length > 0) {
|
|
||||||
await db
|
|
||||||
.delete(federationEnrollmentTokens)
|
|
||||||
.where(inArray(federationEnrollmentTokens.grantId, createdGrantIds))
|
|
||||||
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
|
||||||
await db
|
|
||||||
.delete(federationGrants)
|
|
||||||
.where(inArray(federationGrants.id, createdGrantIds))
|
|
||||||
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
|
||||||
}
|
|
||||||
if (db && createdPeerIds.length > 0) {
|
|
||||||
await db
|
|
||||||
.delete(federationPeers)
|
|
||||||
.where(inArray(federationPeers.id, createdPeerIds))
|
|
||||||
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
|
||||||
}
|
|
||||||
if (db && createdUserIds.length > 0) {
|
|
||||||
await db
|
|
||||||
.delete(schema.users)
|
|
||||||
.where(inArray(schema.users.id, createdUserIds))
|
|
||||||
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
|
||||||
}
|
|
||||||
if (handle)
|
|
||||||
await handle.close().catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Generate a P-256 key pair and PKCS#10 CSR, returning the CSR as PEM. */
|
|
||||||
async function generateCsrPem(cn: string): Promise<string> {
|
|
||||||
const alg = { name: 'ECDSA', namedCurve: 'P-256', hash: 'SHA-256' };
|
|
||||||
const keyPair = await crypto.subtle.generateKey(alg, true, ['sign', 'verify']);
|
|
||||||
const csr = await Pkcs10CertificateRequestGenerator.create({
|
|
||||||
name: `CN=${cn}`,
|
|
||||||
keys: keyPair,
|
|
||||||
signingAlgorithm: alg,
|
|
||||||
});
|
|
||||||
return csr.toString('pem');
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// #2 — enrollment signs CSR and returns cert
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
it('#2 — redeem returns a certPem containing a valid PEM certificate', async () => {
|
|
||||||
const userId = crypto.randomUUID();
|
|
||||||
const peerId = crypto.randomUUID();
|
|
||||||
const validScope = {
|
|
||||||
resources: ['tasks'],
|
|
||||||
excluded_resources: [],
|
|
||||||
max_rows_per_query: 100,
|
|
||||||
};
|
|
||||||
|
|
||||||
await insertTestUser(db, userId);
|
|
||||||
await insertTestPeer(db, peerId, 'ca-test2');
|
|
||||||
createdUserIds.push(userId);
|
|
||||||
createdPeerIds.push(peerId);
|
|
||||||
|
|
||||||
const grant = await grantsService.createGrant({
|
|
||||||
subjectUserId: userId,
|
|
||||||
scope: validScope,
|
|
||||||
peerId,
|
|
||||||
});
|
|
||||||
createdGrantIds.push(grant.id);
|
|
||||||
|
|
||||||
const { token } = await enrollmentService.createToken({
|
|
||||||
grantId: grant.id,
|
|
||||||
peerId,
|
|
||||||
ttlSeconds: 900,
|
|
||||||
});
|
|
||||||
|
|
||||||
const csrPem = await generateCsrPem(`gateway-test-${RUN_ID.slice(0, 8)}`);
|
|
||||||
const result = await enrollmentService.redeem(token, csrPem);
|
|
||||||
|
|
||||||
expect(result.certPem).toContain('-----BEGIN CERTIFICATE-----');
|
|
||||||
expect(result.certChainPem).toContain('-----BEGIN CERTIFICATE-----');
|
|
||||||
|
|
||||||
// Verify the issued cert parses cleanly
|
|
||||||
const cert = new PeculiarX509(result.certPem);
|
|
||||||
expect(cert.serialNumber).toBeTruthy();
|
|
||||||
}, 30_000);
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// #3 — token single-use; second attempt returns GoneException
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
it('#3 — second redeem of the same token throws GoneException', async () => {
|
|
||||||
const userId = crypto.randomUUID();
|
|
||||||
const peerId = crypto.randomUUID();
|
|
||||||
const validScope = {
|
|
||||||
resources: ['notes'],
|
|
||||||
excluded_resources: [],
|
|
||||||
max_rows_per_query: 50,
|
|
||||||
};
|
|
||||||
|
|
||||||
await insertTestUser(db, userId);
|
|
||||||
await insertTestPeer(db, peerId, 'ca-test3');
|
|
||||||
createdUserIds.push(userId);
|
|
||||||
createdPeerIds.push(peerId);
|
|
||||||
|
|
||||||
const grant = await grantsService.createGrant({
|
|
||||||
subjectUserId: userId,
|
|
||||||
scope: validScope,
|
|
||||||
peerId,
|
|
||||||
});
|
|
||||||
createdGrantIds.push(grant.id);
|
|
||||||
|
|
||||||
const { token } = await enrollmentService.createToken({
|
|
||||||
grantId: grant.id,
|
|
||||||
peerId,
|
|
||||||
ttlSeconds: 900,
|
|
||||||
});
|
|
||||||
|
|
||||||
const csrPem = await generateCsrPem(`gateway-test-replay-${RUN_ID.slice(0, 8)}`);
|
|
||||||
|
|
||||||
// First redeem must succeed
|
|
||||||
const result = await enrollmentService.redeem(token, csrPem);
|
|
||||||
expect(result.certPem).toContain('-----BEGIN CERTIFICATE-----');
|
|
||||||
|
|
||||||
// Second redeem with the same token must be rejected
|
|
||||||
await expect(enrollmentService.redeem(token, csrPem)).rejects.toThrow(GoneException);
|
|
||||||
}, 30_000);
|
|
||||||
});
|
|
||||||
@@ -1,10 +1,62 @@
|
|||||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
import { seal, unseal } from '@mosaicstack/auth';
|
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
|
||||||
import type { Db } from '@mosaicstack/db';
|
import type { Db } from '@mosaicstack/db';
|
||||||
import { providerCredentials, eq, and } from '@mosaicstack/db';
|
import { providerCredentials, eq, and } from '@mosaicstack/db';
|
||||||
import { DB } from '../database/database.module.js';
|
import { DB } from '../database/database.module.js';
|
||||||
import type { ProviderCredentialSummaryDto } from './provider-credentials.dto.js';
|
import type { ProviderCredentialSummaryDto } from './provider-credentials.dto.js';
|
||||||
|
|
||||||
|
const ALGORITHM = 'aes-256-gcm';
|
||||||
|
const IV_LENGTH = 12; // 96-bit IV for GCM
|
||||||
|
const TAG_LENGTH = 16; // 128-bit auth tag
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive a 32-byte AES-256 key from BETTER_AUTH_SECRET using SHA-256.
|
||||||
|
* The secret is assumed to be set in the environment.
|
||||||
|
*/
|
||||||
|
function deriveEncryptionKey(): Buffer {
|
||||||
|
const secret = process.env['BETTER_AUTH_SECRET'];
|
||||||
|
if (!secret) {
|
||||||
|
throw new Error('BETTER_AUTH_SECRET is not set — cannot derive encryption key');
|
||||||
|
}
|
||||||
|
return createHash('sha256').update(secret).digest();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt a plain-text value using AES-256-GCM.
|
||||||
|
* Output format: base64(iv + authTag + ciphertext)
|
||||||
|
*/
|
||||||
|
function encrypt(plaintext: string): string {
|
||||||
|
const key = deriveEncryptionKey();
|
||||||
|
const iv = randomBytes(IV_LENGTH);
|
||||||
|
const cipher = createCipheriv(ALGORITHM, key, iv);
|
||||||
|
|
||||||
|
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
||||||
|
const authTag = cipher.getAuthTag();
|
||||||
|
|
||||||
|
// Combine iv (12) + authTag (16) + ciphertext and base64-encode
|
||||||
|
const combined = Buffer.concat([iv, authTag, encrypted]);
|
||||||
|
return combined.toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt a value encrypted by `encrypt()`.
|
||||||
|
* Throws on authentication failure (tampered data).
|
||||||
|
*/
|
||||||
|
function decrypt(encoded: string): string {
|
||||||
|
const key = deriveEncryptionKey();
|
||||||
|
const combined = Buffer.from(encoded, 'base64');
|
||||||
|
|
||||||
|
const iv = combined.subarray(0, IV_LENGTH);
|
||||||
|
const authTag = combined.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
|
||||||
|
const ciphertext = combined.subarray(IV_LENGTH + TAG_LENGTH);
|
||||||
|
|
||||||
|
const decipher = createDecipheriv(ALGORITHM, key, iv);
|
||||||
|
decipher.setAuthTag(authTag);
|
||||||
|
|
||||||
|
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||||
|
return decrypted.toString('utf8');
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ProviderCredentialsService {
|
export class ProviderCredentialsService {
|
||||||
private readonly logger = new Logger(ProviderCredentialsService.name);
|
private readonly logger = new Logger(ProviderCredentialsService.name);
|
||||||
@@ -22,7 +74,7 @@ export class ProviderCredentialsService {
|
|||||||
value: string,
|
value: string,
|
||||||
metadata?: Record<string, unknown>,
|
metadata?: Record<string, unknown>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const encryptedValue = seal(value);
|
const encryptedValue = encrypt(value);
|
||||||
|
|
||||||
await this.db
|
await this.db
|
||||||
.insert(providerCredentials)
|
.insert(providerCredentials)
|
||||||
@@ -70,7 +122,7 @@ export class ProviderCredentialsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return unseal(row.encryptedValue);
|
return decrypt(row.encryptedValue);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Failed to decrypt credential for user=${userId} provider=${provider}`,
|
`Failed to decrypt credential for user=${userId} provider=${provider}`,
|
||||||
|
|||||||
@@ -1,373 +0,0 @@
|
|||||||
/**
|
|
||||||
* Unit tests for EnrollmentService — federation enrollment token flow (FED-M2-07).
|
|
||||||
*
|
|
||||||
* Coverage:
|
|
||||||
* createToken:
|
|
||||||
* - inserts token row with correct grantId, peerId, and future expiresAt
|
|
||||||
* - returns { token, expiresAt } with a 64-char hex token
|
|
||||||
* - clamps ttlSeconds to 900
|
|
||||||
*
|
|
||||||
* redeem — error paths:
|
|
||||||
* - NotFoundException when token row not found
|
|
||||||
* - GoneException when token already used (usedAt set)
|
|
||||||
* - GoneException when token expired (expiresAt < now)
|
|
||||||
* - GoneException when grant status is not pending
|
|
||||||
*
|
|
||||||
* redeem — success path:
|
|
||||||
* - atomically claims token BEFORE cert issuance (claim → issueCert → tx)
|
|
||||||
* - calls CaService.issueCert with correct args
|
|
||||||
* - activates grant + updates peer + writes audit log inside a transaction
|
|
||||||
* - returns { certPem, certChainPem }
|
|
||||||
*
|
|
||||||
* redeem — replay protection:
|
|
||||||
* - GoneException when claim UPDATE returns empty array (concurrent request won)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import 'reflect-metadata';
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import { GoneException, NotFoundException } from '@nestjs/common';
|
|
||||||
import type { Db } from '@mosaicstack/db';
|
|
||||||
import { EnrollmentService } from '../enrollment.service.js';
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Test constants
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const GRANT_ID = 'g1111111-1111-1111-1111-111111111111';
|
|
||||||
const PEER_ID = 'p2222222-2222-2222-2222-222222222222';
|
|
||||||
const USER_ID = 'u3333333-3333-3333-3333-333333333333';
|
|
||||||
const TOKEN = 'a'.repeat(64); // 64-char hex
|
|
||||||
|
|
||||||
const MOCK_CERT_PEM = '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n';
|
|
||||||
const MOCK_CHAIN_PEM = MOCK_CERT_PEM + MOCK_CERT_PEM;
|
|
||||||
const MOCK_SERIAL = 'ABCD1234';
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Factory helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function makeTokenRow(overrides: Partial<Record<string, unknown>> = {}) {
|
|
||||||
return {
|
|
||||||
token: TOKEN,
|
|
||||||
grantId: GRANT_ID,
|
|
||||||
peerId: PEER_ID,
|
|
||||||
expiresAt: new Date(Date.now() + 60_000), // 1 min from now
|
|
||||||
usedAt: null,
|
|
||||||
createdAt: new Date(),
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeGrant(overrides: Partial<Record<string, unknown>> = {}) {
|
|
||||||
return {
|
|
||||||
id: GRANT_ID,
|
|
||||||
peerId: PEER_ID,
|
|
||||||
subjectUserId: USER_ID,
|
|
||||||
scope: { resources: ['tasks'], excluded_resources: [], max_rows_per_query: 100 },
|
|
||||||
status: 'pending',
|
|
||||||
expiresAt: null,
|
|
||||||
createdAt: new Date(),
|
|
||||||
revokedAt: null,
|
|
||||||
revokedReason: null,
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Mock DB builder
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function makeDb({
|
|
||||||
tokenRows = [makeTokenRow()],
|
|
||||||
// claimedRows is returned by the .returning() on the token-claim UPDATE.
|
|
||||||
// Empty array = concurrent request won the race (GoneException).
|
|
||||||
claimedRows = [{ token: TOKEN }],
|
|
||||||
}: {
|
|
||||||
tokenRows?: unknown[];
|
|
||||||
claimedRows?: unknown[];
|
|
||||||
} = {}) {
|
|
||||||
// insert().values() — for createToken (outer db, not tx)
|
|
||||||
const insertValues = vi.fn().mockResolvedValue(undefined);
|
|
||||||
const insertMock = vi.fn().mockReturnValue({ values: insertValues });
|
|
||||||
|
|
||||||
// select().from().where().limit() — for fetching the token row
|
|
||||||
const limitSelect = vi.fn().mockResolvedValue(tokenRows);
|
|
||||||
const whereSelect = vi.fn().mockReturnValue({ limit: limitSelect });
|
|
||||||
const fromSelect = vi.fn().mockReturnValue({ where: whereSelect });
|
|
||||||
const selectMock = vi.fn().mockReturnValue({ from: fromSelect });
|
|
||||||
|
|
||||||
// update().set().where().returning() — for the atomic token claim (outer db)
|
|
||||||
const returningMock = vi.fn().mockResolvedValue(claimedRows);
|
|
||||||
const whereClaimUpdate = vi.fn().mockReturnValue({ returning: returningMock });
|
|
||||||
const setClaimMock = vi.fn().mockReturnValue({ where: whereClaimUpdate });
|
|
||||||
const claimUpdateMock = vi.fn().mockReturnValue({ set: setClaimMock });
|
|
||||||
|
|
||||||
// transaction(cb) — cb receives txMock; txMock has update + insert
|
|
||||||
const txInsertValues = vi.fn().mockResolvedValue(undefined);
|
|
||||||
const txInsertMock = vi.fn().mockReturnValue({ values: txInsertValues });
|
|
||||||
const txWhereUpdate = vi.fn().mockResolvedValue(undefined);
|
|
||||||
const txSetMock = vi.fn().mockReturnValue({ where: txWhereUpdate });
|
|
||||||
const txUpdateMock = vi.fn().mockReturnValue({ set: txSetMock });
|
|
||||||
const txMock = { update: txUpdateMock, insert: txInsertMock };
|
|
||||||
const transactionMock = vi
|
|
||||||
.fn()
|
|
||||||
.mockImplementation(async (cb: (tx: typeof txMock) => Promise<void>) => cb(txMock));
|
|
||||||
|
|
||||||
return {
|
|
||||||
insert: insertMock,
|
|
||||||
select: selectMock,
|
|
||||||
update: claimUpdateMock,
|
|
||||||
transaction: transactionMock,
|
|
||||||
_mocks: {
|
|
||||||
insertValues,
|
|
||||||
insertMock,
|
|
||||||
limitSelect,
|
|
||||||
whereSelect,
|
|
||||||
fromSelect,
|
|
||||||
selectMock,
|
|
||||||
returningMock,
|
|
||||||
whereClaimUpdate,
|
|
||||||
setClaimMock,
|
|
||||||
claimUpdateMock,
|
|
||||||
txInsertValues,
|
|
||||||
txInsertMock,
|
|
||||||
txWhereUpdate,
|
|
||||||
txSetMock,
|
|
||||||
txUpdateMock,
|
|
||||||
txMock,
|
|
||||||
transactionMock,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Mock CaService
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function makeCaService() {
|
|
||||||
return {
|
|
||||||
issueCert: vi.fn().mockResolvedValue({
|
|
||||||
certPem: MOCK_CERT_PEM,
|
|
||||||
certChainPem: MOCK_CHAIN_PEM,
|
|
||||||
serialNumber: MOCK_SERIAL,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Mock GrantsService
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function makeGrantsService(grantOverrides: Partial<Record<string, unknown>> = {}) {
|
|
||||||
return {
|
|
||||||
getGrant: vi.fn().mockResolvedValue(makeGrant(grantOverrides)),
|
|
||||||
activateGrant: vi.fn().mockResolvedValue(makeGrant({ status: 'active' })),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helper: build service under test
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function buildService({
|
|
||||||
db = makeDb(),
|
|
||||||
caService = makeCaService(),
|
|
||||||
grantsService = makeGrantsService(),
|
|
||||||
}: {
|
|
||||||
db?: ReturnType<typeof makeDb>;
|
|
||||||
caService?: ReturnType<typeof makeCaService>;
|
|
||||||
grantsService?: ReturnType<typeof makeGrantsService>;
|
|
||||||
} = {}) {
|
|
||||||
return new EnrollmentService(db as unknown as Db, caService as never, grantsService as never);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Tests: createToken
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
describe('EnrollmentService.createToken', () => {
|
|
||||||
it('inserts a token row and returns { token, expiresAt }', async () => {
|
|
||||||
const db = makeDb();
|
|
||||||
const service = buildService({ db });
|
|
||||||
|
|
||||||
const result = await service.createToken({
|
|
||||||
grantId: GRANT_ID,
|
|
||||||
peerId: PEER_ID,
|
|
||||||
ttlSeconds: 900,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.token).toHaveLength(64); // 32 bytes hex
|
|
||||||
expect(result.expiresAt).toBeDefined();
|
|
||||||
expect(new Date(result.expiresAt).getTime()).toBeGreaterThan(Date.now());
|
|
||||||
expect(db._mocks.insertValues).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ grantId: GRANT_ID, peerId: PEER_ID }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('clamps ttlSeconds to 900', async () => {
|
|
||||||
const db = makeDb();
|
|
||||||
const service = buildService({ db });
|
|
||||||
|
|
||||||
const before = Date.now();
|
|
||||||
const result = await service.createToken({
|
|
||||||
grantId: GRANT_ID,
|
|
||||||
peerId: PEER_ID,
|
|
||||||
ttlSeconds: 9999,
|
|
||||||
});
|
|
||||||
const after = Date.now();
|
|
||||||
|
|
||||||
const expiresMs = new Date(result.expiresAt).getTime();
|
|
||||||
// Should be at most 900s from now
|
|
||||||
expect(expiresMs - before).toBeLessThanOrEqual(900_000 + 100);
|
|
||||||
expect(expiresMs - after).toBeGreaterThanOrEqual(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Tests: redeem — error paths
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
describe('EnrollmentService.redeem — error paths', () => {
|
|
||||||
it('throws NotFoundException when token row not found', async () => {
|
|
||||||
const db = makeDb({ tokenRows: [] });
|
|
||||||
const service = buildService({ db });
|
|
||||||
|
|
||||||
await expect(service.redeem(TOKEN, '---CSR---')).rejects.toBeInstanceOf(NotFoundException);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws GoneException when usedAt is set (already redeemed)', async () => {
|
|
||||||
const db = makeDb({ tokenRows: [makeTokenRow({ usedAt: new Date(Date.now() - 1000) })] });
|
|
||||||
const service = buildService({ db });
|
|
||||||
|
|
||||||
await expect(service.redeem(TOKEN, '---CSR---')).rejects.toBeInstanceOf(GoneException);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws GoneException when token has expired', async () => {
|
|
||||||
const db = makeDb({ tokenRows: [makeTokenRow({ expiresAt: new Date(Date.now() - 1000) })] });
|
|
||||||
const service = buildService({ db });
|
|
||||||
|
|
||||||
await expect(service.redeem(TOKEN, '---CSR---')).rejects.toBeInstanceOf(GoneException);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws GoneException when grant status is not pending', async () => {
|
|
||||||
const db = makeDb();
|
|
||||||
const grantsService = makeGrantsService({ status: 'active' });
|
|
||||||
const service = buildService({ db, grantsService });
|
|
||||||
|
|
||||||
await expect(service.redeem(TOKEN, '---CSR---')).rejects.toBeInstanceOf(GoneException);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws GoneException when token claim UPDATE returns empty array (concurrent replay)', async () => {
|
|
||||||
const db = makeDb({ claimedRows: [] });
|
|
||||||
const caService = makeCaService();
|
|
||||||
const grantsService = makeGrantsService();
|
|
||||||
const service = buildService({ db, caService, grantsService });
|
|
||||||
|
|
||||||
await expect(service.redeem(TOKEN, '---CSR---')).rejects.toBeInstanceOf(GoneException);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does NOT call issueCert when token claim fails (no double minting)', async () => {
|
|
||||||
const db = makeDb({ claimedRows: [] });
|
|
||||||
const caService = makeCaService();
|
|
||||||
const service = buildService({ db, caService });
|
|
||||||
|
|
||||||
await expect(service.redeem(TOKEN, '---CSR---')).rejects.toBeInstanceOf(GoneException);
|
|
||||||
expect(caService.issueCert).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Tests: redeem — success path
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
describe('EnrollmentService.redeem — success path', () => {
|
|
||||||
let db: ReturnType<typeof makeDb>;
|
|
||||||
let caService: ReturnType<typeof makeCaService>;
|
|
||||||
let grantsService: ReturnType<typeof makeGrantsService>;
|
|
||||||
let service: EnrollmentService;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
db = makeDb();
|
|
||||||
caService = makeCaService();
|
|
||||||
grantsService = makeGrantsService();
|
|
||||||
service = buildService({ db, caService, grantsService });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('claims token BEFORE calling issueCert (prevents double minting)', async () => {
|
|
||||||
const callOrder: string[] = [];
|
|
||||||
db._mocks.returningMock.mockImplementation(async () => {
|
|
||||||
callOrder.push('claim');
|
|
||||||
return [{ token: TOKEN }];
|
|
||||||
});
|
|
||||||
caService.issueCert.mockImplementation(async () => {
|
|
||||||
callOrder.push('issueCert');
|
|
||||||
return { certPem: MOCK_CERT_PEM, certChainPem: MOCK_CHAIN_PEM, serialNumber: MOCK_SERIAL };
|
|
||||||
});
|
|
||||||
|
|
||||||
await service.redeem(TOKEN, MOCK_CERT_PEM);
|
|
||||||
|
|
||||||
expect(callOrder).toEqual(['claim', 'issueCert']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls CaService.issueCert with grantId, subjectUserId, csrPem, ttlSeconds=300', async () => {
|
|
||||||
await service.redeem(TOKEN, MOCK_CERT_PEM);
|
|
||||||
|
|
||||||
expect(caService.issueCert).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
grantId: GRANT_ID,
|
|
||||||
subjectUserId: USER_ID,
|
|
||||||
csrPem: MOCK_CERT_PEM,
|
|
||||||
ttlSeconds: 300,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('runs activate grant + peer update + audit inside a transaction', async () => {
|
|
||||||
await service.redeem(TOKEN, MOCK_CERT_PEM);
|
|
||||||
|
|
||||||
expect(db._mocks.transactionMock).toHaveBeenCalledOnce();
|
|
||||||
// tx.update called twice: activate grant + update peer
|
|
||||||
expect(db._mocks.txUpdateMock).toHaveBeenCalledTimes(2);
|
|
||||||
// tx.insert called once: audit log
|
|
||||||
expect(db._mocks.txInsertMock).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('activates grant (sets status=active) inside the transaction', async () => {
|
|
||||||
await service.redeem(TOKEN, MOCK_CERT_PEM);
|
|
||||||
|
|
||||||
expect(db._mocks.txSetMock).toHaveBeenCalledWith(expect.objectContaining({ status: 'active' }));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('updates the federationPeers row with certPem, certSerial, state=active inside the transaction', async () => {
|
|
||||||
await service.redeem(TOKEN, MOCK_CERT_PEM);
|
|
||||||
|
|
||||||
expect(db._mocks.txSetMock).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
certPem: MOCK_CERT_PEM,
|
|
||||||
certSerial: MOCK_SERIAL,
|
|
||||||
state: 'active',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('inserts an audit log row inside the transaction', async () => {
|
|
||||||
await service.redeem(TOKEN, MOCK_CERT_PEM);
|
|
||||||
|
|
||||||
expect(db._mocks.txInsertValues).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
peerId: PEER_ID,
|
|
||||||
grantId: GRANT_ID,
|
|
||||||
verb: 'enrollment',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns { certPem, certChainPem } from CaService', async () => {
|
|
||||||
const result = await service.redeem(TOKEN, MOCK_CERT_PEM);
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
certPem: MOCK_CERT_PEM,
|
|
||||||
certChainPem: MOCK_CHAIN_PEM,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,212 +0,0 @@
|
|||||||
/**
|
|
||||||
* Unit tests for FederationController (FED-M2-08).
|
|
||||||
*
|
|
||||||
* Coverage:
|
|
||||||
* - listGrants: delegates to GrantsService with query params
|
|
||||||
* - createGrant: delegates to GrantsService, validates body
|
|
||||||
* - generateToken: returns enrollmentUrl containing the token
|
|
||||||
* - listPeers: returns DB rows
|
|
||||||
*/
|
|
||||||
|
|
||||||
import 'reflect-metadata';
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import { NotFoundException } from '@nestjs/common';
|
|
||||||
import type { Db } from '@mosaicstack/db';
|
|
||||||
import { FederationController } from '../federation.controller.js';
|
|
||||||
import type { GrantsService } from '../grants.service.js';
|
|
||||||
import type { EnrollmentService } from '../enrollment.service.js';
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Constants
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const GRANT_ID = 'g1111111-1111-1111-1111-111111111111';
|
|
||||||
const PEER_ID = 'p2222222-2222-2222-2222-222222222222';
|
|
||||||
const USER_ID = 'u3333333-3333-3333-3333-333333333333';
|
|
||||||
|
|
||||||
const MOCK_GRANT = {
|
|
||||||
id: GRANT_ID,
|
|
||||||
peerId: PEER_ID,
|
|
||||||
subjectUserId: USER_ID,
|
|
||||||
scope: { resources: ['tasks'], operations: ['list'] },
|
|
||||||
status: 'pending' as const,
|
|
||||||
expiresAt: null,
|
|
||||||
createdAt: new Date('2026-01-01T00:00:00Z'),
|
|
||||||
revokedAt: null,
|
|
||||||
revokedReason: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const MOCK_PEER = {
|
|
||||||
id: PEER_ID,
|
|
||||||
commonName: 'test-peer',
|
|
||||||
displayName: 'Test Peer',
|
|
||||||
certPem: '',
|
|
||||||
certSerial: 'pending',
|
|
||||||
certNotAfter: new Date(0),
|
|
||||||
clientKeyPem: null,
|
|
||||||
state: 'pending' as const,
|
|
||||||
endpointUrl: null,
|
|
||||||
createdAt: new Date('2026-01-01T00:00:00Z'),
|
|
||||||
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// DB mock builder
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function makeDbMock(rows: unknown[] = []) {
|
|
||||||
const orderBy = vi.fn().mockResolvedValue(rows);
|
|
||||||
const where = vi.fn().mockReturnValue({ orderBy });
|
|
||||||
const from = vi.fn().mockReturnValue({ where, orderBy });
|
|
||||||
const select = vi.fn().mockReturnValue({ from });
|
|
||||||
|
|
||||||
return {
|
|
||||||
select,
|
|
||||||
from,
|
|
||||||
where,
|
|
||||||
orderBy,
|
|
||||||
insert: vi.fn(),
|
|
||||||
update: vi.fn(),
|
|
||||||
delete: vi.fn(),
|
|
||||||
} as unknown as Db;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Tests
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
describe('FederationController', () => {
|
|
||||||
let db: Db;
|
|
||||||
let grantsService: GrantsService;
|
|
||||||
let enrollmentService: EnrollmentService;
|
|
||||||
let controller: FederationController;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
db = makeDbMock([MOCK_PEER]);
|
|
||||||
|
|
||||||
grantsService = {
|
|
||||||
createGrant: vi.fn().mockResolvedValue(MOCK_GRANT),
|
|
||||||
getGrant: vi.fn().mockResolvedValue(MOCK_GRANT),
|
|
||||||
listGrants: vi.fn().mockResolvedValue([MOCK_GRANT]),
|
|
||||||
revokeGrant: vi.fn().mockResolvedValue({ ...MOCK_GRANT, status: 'revoked' }),
|
|
||||||
activateGrant: vi.fn(),
|
|
||||||
expireGrant: vi.fn(),
|
|
||||||
} as unknown as GrantsService;
|
|
||||||
|
|
||||||
enrollmentService = {
|
|
||||||
createToken: vi.fn().mockResolvedValue({
|
|
||||||
token: 'abc123def456abc123def456abc123def456abc123def456abc123def456ab12',
|
|
||||||
expiresAt: '2026-01-01T00:15:00.000Z',
|
|
||||||
}),
|
|
||||||
redeem: vi.fn(),
|
|
||||||
} as unknown as EnrollmentService;
|
|
||||||
|
|
||||||
controller = new FederationController(db, grantsService, enrollmentService);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Grant management ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('listGrants', () => {
|
|
||||||
it('delegates to GrantsService with provided query params', async () => {
|
|
||||||
const query = { peerId: PEER_ID, status: 'pending' as const };
|
|
||||||
const result = await controller.listGrants(query);
|
|
||||||
|
|
||||||
expect(grantsService.listGrants).toHaveBeenCalledWith(query);
|
|
||||||
expect(result).toEqual([MOCK_GRANT]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('delegates to GrantsService with empty filters', async () => {
|
|
||||||
const result = await controller.listGrants({});
|
|
||||||
|
|
||||||
expect(grantsService.listGrants).toHaveBeenCalledWith({});
|
|
||||||
expect(result).toEqual([MOCK_GRANT]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('createGrant', () => {
|
|
||||||
it('delegates to GrantsService and returns created grant', async () => {
|
|
||||||
const body = {
|
|
||||||
peerId: PEER_ID,
|
|
||||||
subjectUserId: USER_ID,
|
|
||||||
scope: { resources: ['tasks'], operations: ['list'] },
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await controller.createGrant(body);
|
|
||||||
|
|
||||||
expect(grantsService.createGrant).toHaveBeenCalledWith(body);
|
|
||||||
expect(result).toEqual(MOCK_GRANT);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getGrant', () => {
|
|
||||||
it('delegates to GrantsService with provided ID', async () => {
|
|
||||||
const result = await controller.getGrant(GRANT_ID);
|
|
||||||
|
|
||||||
expect(grantsService.getGrant).toHaveBeenCalledWith(GRANT_ID);
|
|
||||||
expect(result).toEqual(MOCK_GRANT);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('revokeGrant', () => {
|
|
||||||
it('delegates to GrantsService with id and reason', async () => {
|
|
||||||
const result = await controller.revokeGrant(GRANT_ID, { reason: 'test reason' });
|
|
||||||
|
|
||||||
expect(grantsService.revokeGrant).toHaveBeenCalledWith(GRANT_ID, 'test reason');
|
|
||||||
expect(result).toMatchObject({ status: 'revoked' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('delegates without reason when omitted', async () => {
|
|
||||||
await controller.revokeGrant(GRANT_ID, {});
|
|
||||||
|
|
||||||
expect(grantsService.revokeGrant).toHaveBeenCalledWith(GRANT_ID, undefined);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('generateToken', () => {
|
|
||||||
it('returns enrollmentUrl containing the token', async () => {
|
|
||||||
const token = 'abc123def456abc123def456abc123def456abc123def456abc123def456ab12';
|
|
||||||
vi.mocked(enrollmentService.createToken).mockResolvedValueOnce({
|
|
||||||
token,
|
|
||||||
expiresAt: '2026-01-01T00:15:00.000Z',
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await controller.generateToken(GRANT_ID, { ttlSeconds: 900 });
|
|
||||||
|
|
||||||
expect(result.token).toBe(token);
|
|
||||||
expect(result.enrollmentUrl).toContain(token);
|
|
||||||
expect(result.enrollmentUrl).toContain('/api/federation/enrollment/');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('creates token via EnrollmentService with correct grantId and peerId', async () => {
|
|
||||||
await controller.generateToken(GRANT_ID, { ttlSeconds: 300 });
|
|
||||||
|
|
||||||
expect(enrollmentService.createToken).toHaveBeenCalledWith({
|
|
||||||
grantId: GRANT_ID,
|
|
||||||
peerId: PEER_ID,
|
|
||||||
ttlSeconds: 300,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws NotFoundException when grant does not exist', async () => {
|
|
||||||
vi.mocked(grantsService.getGrant).mockRejectedValueOnce(
|
|
||||||
new NotFoundException(`Grant ${GRANT_ID} not found`),
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(controller.generateToken(GRANT_ID, { ttlSeconds: 900 })).rejects.toThrow(
|
|
||||||
NotFoundException,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Peer management ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('listPeers', () => {
|
|
||||||
it('returns DB rows ordered by commonName', async () => {
|
|
||||||
const result = await controller.listPeers();
|
|
||||||
|
|
||||||
expect(db.select).toHaveBeenCalled();
|
|
||||||
// The DB mock resolves with [MOCK_PEER]
|
|
||||||
expect(result).toEqual([MOCK_PEER]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,351 +0,0 @@
|
|||||||
/**
|
|
||||||
* Unit tests for GrantsService — federation grants CRUD + status transitions (FED-M2-06).
|
|
||||||
*
|
|
||||||
* Coverage:
|
|
||||||
* - createGrant: validates scope via parseFederationScope
|
|
||||||
* - createGrant: inserts with status 'pending'
|
|
||||||
* - getGrant: returns grant when found
|
|
||||||
* - getGrant: throws NotFoundException when not found
|
|
||||||
* - listGrants: no filters returns all grants
|
|
||||||
* - listGrants: filters by peerId
|
|
||||||
* - listGrants: filters by subjectUserId
|
|
||||||
* - listGrants: filters by status
|
|
||||||
* - listGrants: multiple filters combined
|
|
||||||
* - activateGrant: pending → active works
|
|
||||||
* - activateGrant: non-pending throws ConflictException
|
|
||||||
* - revokeGrant: active → revoked works, sets revokedAt
|
|
||||||
* - revokeGrant: non-active throws ConflictException
|
|
||||||
* - expireGrant: active → expired works
|
|
||||||
* - expireGrant: non-active throws ConflictException
|
|
||||||
*/
|
|
||||||
|
|
||||||
import 'reflect-metadata';
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import { ConflictException, NotFoundException } from '@nestjs/common';
|
|
||||||
import type { Db } from '@mosaicstack/db';
|
|
||||||
import { GrantsService } from '../grants.service.js';
|
|
||||||
import { FederationScopeError } from '../scope-schema.js';
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Minimal valid federation scope for testing
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
const VALID_SCOPE = {
|
|
||||||
resources: ['tasks'] as const,
|
|
||||||
excluded_resources: [],
|
|
||||||
max_rows_per_query: 100,
|
|
||||||
};
|
|
||||||
|
|
||||||
const PEER_ID = 'a1111111-1111-1111-1111-111111111111';
|
|
||||||
const USER_ID = 'u2222222-2222-2222-2222-222222222222';
|
|
||||||
const GRANT_ID = 'g3333333-3333-3333-3333-333333333333';
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Build a mock DB that mimics chained Drizzle query builder calls
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function makeMockGrant(overrides: Partial<Record<string, unknown>> = {}) {
|
|
||||||
return {
|
|
||||||
id: GRANT_ID,
|
|
||||||
peerId: PEER_ID,
|
|
||||||
subjectUserId: USER_ID,
|
|
||||||
scope: VALID_SCOPE,
|
|
||||||
status: 'pending',
|
|
||||||
expiresAt: null,
|
|
||||||
createdAt: new Date('2026-01-01T00:00:00Z'),
|
|
||||||
revokedAt: null,
|
|
||||||
revokedReason: null,
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeDb(
|
|
||||||
overrides: {
|
|
||||||
insertReturning?: unknown[];
|
|
||||||
selectRows?: unknown[];
|
|
||||||
updateReturning?: unknown[];
|
|
||||||
} = {},
|
|
||||||
) {
|
|
||||||
const insertReturning = overrides.insertReturning ?? [makeMockGrant()];
|
|
||||||
const selectRows = overrides.selectRows ?? [makeMockGrant()];
|
|
||||||
const updateReturning = overrides.updateReturning ?? [makeMockGrant({ status: 'active' })];
|
|
||||||
|
|
||||||
// Drizzle returns a chainable builder; we need to mock the full chain.
|
|
||||||
const returningInsert = vi.fn().mockResolvedValue(insertReturning);
|
|
||||||
const valuesInsert = vi.fn().mockReturnValue({ returning: returningInsert });
|
|
||||||
const insertMock = vi.fn().mockReturnValue({ values: valuesInsert });
|
|
||||||
|
|
||||||
// select().from().where().limit()
|
|
||||||
const limitSelect = vi.fn().mockResolvedValue(selectRows);
|
|
||||||
const whereSelect = vi.fn().mockReturnValue({ limit: limitSelect });
|
|
||||||
// from returns something that is both thenable (for full-table select) and has .where()
|
|
||||||
const fromSelect = vi.fn().mockReturnValue({
|
|
||||||
where: whereSelect,
|
|
||||||
limit: limitSelect,
|
|
||||||
// Make it thenable for listGrants with no filters (await db.select().from(federationGrants))
|
|
||||||
then: (resolve: (v: unknown) => unknown) => resolve(selectRows),
|
|
||||||
});
|
|
||||||
const selectMock = vi.fn().mockReturnValue({ from: fromSelect });
|
|
||||||
|
|
||||||
const returningUpdate = vi.fn().mockResolvedValue(updateReturning);
|
|
||||||
const whereUpdate = vi.fn().mockReturnValue({ returning: returningUpdate });
|
|
||||||
const setMock = vi.fn().mockReturnValue({ where: whereUpdate });
|
|
||||||
const updateMock = vi.fn().mockReturnValue({ set: setMock });
|
|
||||||
|
|
||||||
return {
|
|
||||||
insert: insertMock,
|
|
||||||
select: selectMock,
|
|
||||||
update: updateMock,
|
|
||||||
// Expose internals for assertions
|
|
||||||
_mocks: {
|
|
||||||
insertReturning,
|
|
||||||
valuesInsert,
|
|
||||||
insertMock,
|
|
||||||
limitSelect,
|
|
||||||
whereSelect,
|
|
||||||
fromSelect,
|
|
||||||
selectMock,
|
|
||||||
returningUpdate,
|
|
||||||
whereUpdate,
|
|
||||||
setMock,
|
|
||||||
updateMock,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Tests
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
describe('GrantsService', () => {
|
|
||||||
let db: ReturnType<typeof makeDb>;
|
|
||||||
let service: GrantsService;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
db = makeDb();
|
|
||||||
service = new GrantsService(db as unknown as Db);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── createGrant ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('createGrant', () => {
|
|
||||||
it('calls parseFederationScope — rejects an invalid scope', async () => {
|
|
||||||
const invalidScope = { resources: [], max_rows_per_query: 0 };
|
|
||||||
await expect(
|
|
||||||
service.createGrant({ peerId: PEER_ID, subjectUserId: USER_ID, scope: invalidScope }),
|
|
||||||
).rejects.toBeInstanceOf(FederationScopeError);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('inserts a grant with status pending and returns it', async () => {
|
|
||||||
const result = await service.createGrant({
|
|
||||||
peerId: PEER_ID,
|
|
||||||
subjectUserId: USER_ID,
|
|
||||||
scope: VALID_SCOPE,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(db._mocks.valuesInsert).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ status: 'pending', peerId: PEER_ID, subjectUserId: USER_ID }),
|
|
||||||
);
|
|
||||||
expect(result.status).toBe('pending');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('passes expiresAt as a Date when provided', async () => {
|
|
||||||
await service.createGrant({
|
|
||||||
peerId: PEER_ID,
|
|
||||||
subjectUserId: USER_ID,
|
|
||||||
scope: VALID_SCOPE,
|
|
||||||
expiresAt: '2027-01-01T00:00:00Z',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(db._mocks.valuesInsert).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ expiresAt: expect.any(Date) }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sets expiresAt to null when not provided', async () => {
|
|
||||||
await service.createGrant({ peerId: PEER_ID, subjectUserId: USER_ID, scope: VALID_SCOPE });
|
|
||||||
|
|
||||||
expect(db._mocks.valuesInsert).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ expiresAt: null }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── getGrant ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('getGrant', () => {
|
|
||||||
it('returns the grant when found', async () => {
|
|
||||||
const result = await service.getGrant(GRANT_ID);
|
|
||||||
expect(result.id).toBe(GRANT_ID);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws NotFoundException when no rows returned', async () => {
|
|
||||||
db = makeDb({ selectRows: [] });
|
|
||||||
service = new GrantsService(db as unknown as Db);
|
|
||||||
await expect(service.getGrant(GRANT_ID)).rejects.toBeInstanceOf(NotFoundException);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── listGrants ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('listGrants', () => {
|
|
||||||
it('queries without where clause when no filters provided', async () => {
|
|
||||||
const result = await service.listGrants({});
|
|
||||||
expect(Array.isArray(result)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('applies peerId filter', async () => {
|
|
||||||
await service.listGrants({ peerId: PEER_ID });
|
|
||||||
expect(db._mocks.whereSelect).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('applies subjectUserId filter', async () => {
|
|
||||||
await service.listGrants({ subjectUserId: USER_ID });
|
|
||||||
expect(db._mocks.whereSelect).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('applies status filter', async () => {
|
|
||||||
await service.listGrants({ status: 'active' });
|
|
||||||
expect(db._mocks.whereSelect).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('applies multiple filters combined', async () => {
|
|
||||||
await service.listGrants({ peerId: PEER_ID, status: 'pending' });
|
|
||||||
expect(db._mocks.whereSelect).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── activateGrant ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('activateGrant', () => {
|
|
||||||
it('transitions pending → active and returns updated grant', async () => {
|
|
||||||
db = makeDb({
|
|
||||||
selectRows: [makeMockGrant({ status: 'pending' })],
|
|
||||||
updateReturning: [makeMockGrant({ status: 'active' })],
|
|
||||||
});
|
|
||||||
service = new GrantsService(db as unknown as Db);
|
|
||||||
|
|
||||||
const result = await service.activateGrant(GRANT_ID);
|
|
||||||
|
|
||||||
expect(db._mocks.setMock).toHaveBeenCalledWith({ status: 'active' });
|
|
||||||
expect(result.status).toBe('active');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws ConflictException when grant is already active', async () => {
|
|
||||||
db = makeDb({ selectRows: [makeMockGrant({ status: 'active' })] });
|
|
||||||
service = new GrantsService(db as unknown as Db);
|
|
||||||
|
|
||||||
await expect(service.activateGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws ConflictException when grant is revoked', async () => {
|
|
||||||
db = makeDb({ selectRows: [makeMockGrant({ status: 'revoked' })] });
|
|
||||||
service = new GrantsService(db as unknown as Db);
|
|
||||||
|
|
||||||
await expect(service.activateGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws ConflictException when grant is expired', async () => {
|
|
||||||
db = makeDb({ selectRows: [makeMockGrant({ status: 'expired' })] });
|
|
||||||
service = new GrantsService(db as unknown as Db);
|
|
||||||
|
|
||||||
await expect(service.activateGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── revokeGrant ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('revokeGrant', () => {
|
|
||||||
it('transitions active → revoked and sets revokedAt', async () => {
|
|
||||||
const revokedAt = new Date();
|
|
||||||
db = makeDb({
|
|
||||||
selectRows: [makeMockGrant({ status: 'active' })],
|
|
||||||
updateReturning: [makeMockGrant({ status: 'revoked', revokedAt })],
|
|
||||||
});
|
|
||||||
service = new GrantsService(db as unknown as Db);
|
|
||||||
|
|
||||||
const result = await service.revokeGrant(GRANT_ID, 'test reason');
|
|
||||||
|
|
||||||
expect(db._mocks.setMock).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
status: 'revoked',
|
|
||||||
revokedAt: expect.any(Date),
|
|
||||||
revokedReason: 'test reason',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(result.status).toBe('revoked');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sets revokedReason to null when not provided', async () => {
|
|
||||||
db = makeDb({
|
|
||||||
selectRows: [makeMockGrant({ status: 'active' })],
|
|
||||||
updateReturning: [makeMockGrant({ status: 'revoked', revokedAt: new Date() })],
|
|
||||||
});
|
|
||||||
service = new GrantsService(db as unknown as Db);
|
|
||||||
|
|
||||||
await service.revokeGrant(GRANT_ID);
|
|
||||||
|
|
||||||
expect(db._mocks.setMock).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ revokedReason: null }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws ConflictException when grant is pending', async () => {
|
|
||||||
db = makeDb({ selectRows: [makeMockGrant({ status: 'pending' })] });
|
|
||||||
service = new GrantsService(db as unknown as Db);
|
|
||||||
|
|
||||||
await expect(service.revokeGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws ConflictException when grant is already revoked', async () => {
|
|
||||||
db = makeDb({ selectRows: [makeMockGrant({ status: 'revoked' })] });
|
|
||||||
service = new GrantsService(db as unknown as Db);
|
|
||||||
|
|
||||||
await expect(service.revokeGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws ConflictException when grant is expired', async () => {
|
|
||||||
db = makeDb({ selectRows: [makeMockGrant({ status: 'expired' })] });
|
|
||||||
service = new GrantsService(db as unknown as Db);
|
|
||||||
|
|
||||||
await expect(service.revokeGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── expireGrant ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('expireGrant', () => {
|
|
||||||
it('transitions active → expired and returns updated grant', async () => {
|
|
||||||
db = makeDb({
|
|
||||||
selectRows: [makeMockGrant({ status: 'active' })],
|
|
||||||
updateReturning: [makeMockGrant({ status: 'expired' })],
|
|
||||||
});
|
|
||||||
service = new GrantsService(db as unknown as Db);
|
|
||||||
|
|
||||||
const result = await service.expireGrant(GRANT_ID);
|
|
||||||
|
|
||||||
expect(db._mocks.setMock).toHaveBeenCalledWith({ status: 'expired' });
|
|
||||||
expect(result.status).toBe('expired');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws ConflictException when grant is pending', async () => {
|
|
||||||
db = makeDb({ selectRows: [makeMockGrant({ status: 'pending' })] });
|
|
||||||
service = new GrantsService(db as unknown as Db);
|
|
||||||
|
|
||||||
await expect(service.expireGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws ConflictException when grant is already expired', async () => {
|
|
||||||
db = makeDb({ selectRows: [makeMockGrant({ status: 'expired' })] });
|
|
||||||
service = new GrantsService(db as unknown as Db);
|
|
||||||
|
|
||||||
await expect(service.expireGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws ConflictException when grant is revoked', async () => {
|
|
||||||
db = makeDb({ selectRows: [makeMockGrant({ status: 'revoked' })] });
|
|
||||||
service = new GrantsService(db as unknown as Db);
|
|
||||||
|
|
||||||
await expect(service.expireGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
||||||
import { sealClientKey, unsealClientKey } from '../peer-key.util.js';
|
|
||||||
|
|
||||||
const TEST_SECRET = 'test-secret-for-peer-key-unit-tests-only';
|
|
||||||
|
|
||||||
const TEST_PEM = `-----BEGIN PRIVATE KEY-----
|
|
||||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7o4qne60TB3wo
|
|
||||||
pCOW8QqstpxEBpnFo37JxLYEJbpE3gUlJajsHv9UWRQ7m5B7n+MBXwTCQqMEY8Wl
|
|
||||||
kHv9tGgz1YGwzBjNKxPJXE6pPTXQ1Oa0VB9l3qHdqF5HtZoJzE0c6dO8HJ5YUVL
|
|
||||||
-----END PRIVATE KEY-----`;
|
|
||||||
|
|
||||||
let savedSecret: string | undefined;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
savedSecret = process.env['BETTER_AUTH_SECRET'];
|
|
||||||
process.env['BETTER_AUTH_SECRET'] = TEST_SECRET;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
if (savedSecret === undefined) {
|
|
||||||
delete process.env['BETTER_AUTH_SECRET'];
|
|
||||||
} else {
|
|
||||||
process.env['BETTER_AUTH_SECRET'] = savedSecret;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('peer-key seal/unseal', () => {
|
|
||||||
it('round-trip: unsealClientKey(sealClientKey(pem)) returns original pem', () => {
|
|
||||||
const sealed = sealClientKey(TEST_PEM);
|
|
||||||
const roundTripped = unsealClientKey(sealed);
|
|
||||||
expect(roundTripped).toBe(TEST_PEM);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('non-determinism: sealClientKey produces different ciphertext each call', () => {
|
|
||||||
const sealed1 = sealClientKey(TEST_PEM);
|
|
||||||
const sealed2 = sealClientKey(TEST_PEM);
|
|
||||||
expect(sealed1).not.toBe(sealed2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('at-rest: sealed output does not contain plaintext PEM content', () => {
|
|
||||||
const sealed = sealClientKey(TEST_PEM);
|
|
||||||
expect(sealed).not.toContain('PRIVATE KEY');
|
|
||||||
expect(sealed).not.toContain(
|
|
||||||
'MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7o4qne60TB3wo',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('tamper: flipping a byte in the sealed payload causes unseal to throw', () => {
|
|
||||||
const sealed = sealClientKey(TEST_PEM);
|
|
||||||
const buf = Buffer.from(sealed, 'base64');
|
|
||||||
// Flip a byte in the middle of the buffer (past IV and authTag)
|
|
||||||
const midpoint = Math.floor(buf.length / 2);
|
|
||||||
buf[midpoint] = buf[midpoint]! ^ 0xff;
|
|
||||||
const tampered = buf.toString('base64');
|
|
||||||
expect(() => unsealClientKey(tampered)).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('missing secret: unsealClientKey throws when BETTER_AUTH_SECRET is unset', () => {
|
|
||||||
const sealed = sealClientKey(TEST_PEM);
|
|
||||||
delete process.env['BETTER_AUTH_SECRET'];
|
|
||||||
expect(() => unsealClientKey(sealed)).toThrow('BETTER_AUTH_SECRET is not set');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
* IssuedCertDto — output from CaService.issueCert()
|
* IssuedCertDto — output from CaService.issueCert()
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { IsInt, IsNotEmpty, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator';
|
import { IsInt, IsNotEmpty, IsString, IsUUID, Max, Min } from 'class-validator';
|
||||||
|
|
||||||
export class IssueCertRequestDto {
|
export class IssueCertRequestDto {
|
||||||
/**
|
/**
|
||||||
@@ -32,14 +32,13 @@ export class IssueCertRequestDto {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Requested certificate validity in seconds.
|
* Requested certificate validity in seconds.
|
||||||
* Hard cap: 900 s (15 minutes). Default: 300 s (5 minutes).
|
* Capped at the step-ca provisioner policy ceiling.
|
||||||
* The service will always clamp to 900 s regardless of this value.
|
* Defaults to 86 400 s (24 h) when omitted by callers.
|
||||||
*/
|
*/
|
||||||
@IsOptional()
|
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(60)
|
@Min(60)
|
||||||
@Max(15 * 60)
|
@Max(365 * 24 * 3600)
|
||||||
ttlSeconds: number = 300;
|
ttlSeconds!: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class IssuedCertDto {
|
export class IssuedCertDto {
|
||||||
|
|||||||
@@ -7,22 +7,15 @@
|
|||||||
* - certChainPem from ca field: uses crt+ca when certChain absent but ca present
|
* - certChainPem from ca field: uses crt+ca when certChain absent but ca present
|
||||||
* - HTTP 401: throws CaServiceError with cause + remediation
|
* - HTTP 401: throws CaServiceError with cause + remediation
|
||||||
* - HTTP non-401 error: throws CaServiceError
|
* - HTTP non-401 error: throws CaServiceError
|
||||||
* - Malformed CSR: throws before HTTP call (INVALID_CSR)
|
* - Malformed CSR: throws before HTTP call
|
||||||
* - Non-JSON response: throws CaServiceError
|
* - Non-JSON response: throws CaServiceError
|
||||||
* - HTTPS connection error: throws CaServiceError
|
* - HTTPS connection error: throws CaServiceError
|
||||||
* - JWT custom claims: mosaic_grant_id and mosaic_subject_user_id present in OTT payload
|
* - JWT custom claims: mosaic_grant_id and mosaic_subject_user_id present in OTT payload
|
||||||
* verified with jose.jwtVerify (real signature check)
|
|
||||||
* - CaServiceError: has cause + remediation properties
|
* - CaServiceError: has cause + remediation properties
|
||||||
* - Missing crt in response: throws CaServiceError
|
* - Missing crt in response: throws CaServiceError
|
||||||
* - Real CSR validation: valid P-256 CSR passes; malformed CSR fails with INVALID_CSR
|
|
||||||
* - provisionerPassword never appears in CaServiceError messages
|
|
||||||
* - HTTPS-only enforcement: http:// URL throws in constructor
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import 'reflect-metadata';
|
|
||||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||||
import { jwtVerify, exportJWK, generateKeyPair } from 'jose';
|
|
||||||
import { Pkcs10CertificateRequestGenerator } from '@peculiar/x509';
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Mock node:https BEFORE importing CaService so the mock is in place when
|
// Mock node:https BEFORE importing CaService so the mock is in place when
|
||||||
@@ -53,82 +46,36 @@ vi.mock('node:fs', () => {
|
|||||||
// Helpers
|
// Helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// Real self-signed EC P-256 certificate generated with openssl for testing.
|
// Minimal self-signed certificate PEM produced by openssl for testing.
|
||||||
// openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -keyout /dev/null \
|
// Serial 01, RSA 512 bit (invalid for production, fine for unit tests).
|
||||||
// -out /dev/stdout -subj "/CN=test" -days 1
|
|
||||||
const FAKE_CERT_PEM = `-----BEGIN CERTIFICATE-----
|
const FAKE_CERT_PEM = `-----BEGIN CERTIFICATE-----
|
||||||
MIIBdDCCARmgAwIBAgIUM+iUJSayN+PwXkyVN6qwSY7sr6gwCgYIKoZIzj0EAwIw
|
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0000000000000000AAAA
|
||||||
DzENMAsGA1UEAwwEdGVzdDAeFw0yNjA0MjIwMzE5MTlaFw0yNjA0MjMwMzE5MTla
|
-----END CERTIFICATE-----\n`;
|
||||||
MA8xDTALBgNVBAMMBHRlc3QwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAR21kHL
|
|
||||||
n1GmFQ4TEBw3EA53pD+2McIBf5WcoHE+x0eMz5DpRKJe0ksHwOVN5Yev5d57kb+4
|
|
||||||
MvG1LhbHCB/uQo8So1MwUTAdBgNVHQ4EFgQUPq0pdIGiQ7pLBRXICS8GTliCrLsw
|
|
||||||
HwYDVR0jBBgwFoAUPq0pdIGiQ7pLBRXICS8GTliCrLswDwYDVR0TAQH/BAUwAwEB
|
|
||||||
/zAKBggqhkjOPQQDAgNJADBGAiEAypJqyC6S77aQ3eEXokM6sgAsD7Oa3tJbCbVm
|
|
||||||
zG3uJb0CIQC1w+GE+Ad0OTR5Quja46R1RjOo8ydpzZ7Fh4rouAiwEw==
|
|
||||||
-----END CERTIFICATE-----
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Use a second copy of the same cert for the CA field in tests.
|
const FAKE_CSR_PEM = `-----BEGIN CERTIFICATE REQUEST-----
|
||||||
const FAKE_CA_PEM = FAKE_CERT_PEM;
|
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0000000000000000AAAA
|
||||||
|
-----END CERTIFICATE REQUEST-----\n`;
|
||||||
|
|
||||||
|
const FAKE_CA_PEM = `-----BEGIN CERTIFICATE-----
|
||||||
|
CAROOT000000000000000000000000000000000000000000000000AAAA
|
||||||
|
-----END CERTIFICATE-----\n`;
|
||||||
|
|
||||||
const GRANT_ID = 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11';
|
const GRANT_ID = 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11';
|
||||||
const SUBJECT_USER_ID = 'b1ffcd00-0d1c-5f09-cc7e-7cc0ce491b22';
|
const SUBJECT_USER_ID = 'b1ffcd00-0d1c-5fg9-cc7e-7cc0ce491b22';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Generate a real EC P-256 key pair and CSR for integration-style tests
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// We generate this once at module level so it's available to all tests.
|
|
||||||
// The key pair and CSR PEM are populated asynchronously in the test that needs them.
|
|
||||||
|
|
||||||
let realCsrPem: string;
|
|
||||||
|
|
||||||
async function generateRealCsr(): Promise<string> {
|
|
||||||
const { privateKey, publicKey } = await generateKeyPair('ES256');
|
|
||||||
// Export public key JWK for potential verification (not used here but confirms key is exportable)
|
|
||||||
await exportJWK(publicKey);
|
|
||||||
|
|
||||||
// Use @peculiar/x509 to build a proper CSR
|
|
||||||
const csr = await Pkcs10CertificateRequestGenerator.create({
|
|
||||||
name: 'CN=test.federation.local',
|
|
||||||
signingAlgorithm: { name: 'ECDSA', hash: 'SHA-256' },
|
|
||||||
keys: { privateKey, publicKey },
|
|
||||||
});
|
|
||||||
|
|
||||||
return csr.toString('pem');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Setup env before importing service
|
// Setup env before importing service
|
||||||
// We use an EC P-256 key pair here so the JWK-based signing works.
|
|
||||||
// The key pair is generated once and stored in module-level vars.
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// Real EC P-256 test JWK (test-only, never used in production).
|
const JWK_KEY = JSON.stringify({
|
||||||
// Generated with node webcrypto for use in unit tests.
|
kty: 'oct',
|
||||||
const TEST_EC_PRIVATE_JWK = {
|
kid: 'test-kid',
|
||||||
key_ops: ['sign'],
|
k: 'dGVzdC1zZWNyZXQ=', // base64url("test-secret")
|
||||||
ext: true,
|
});
|
||||||
kty: 'EC',
|
|
||||||
x: 'Xq2RjZctcPcUMU14qfjs3MtZTmFk8z1lFGQyypgXZOU',
|
|
||||||
y: 't8w9Cbt4RVmR47Wnb_i5cLwefEnMcvwse049zu9Rl_E',
|
|
||||||
crv: 'P-256',
|
|
||||||
d: 'TM6N79w1HE-PiML5Td4mbXfJaLHEaZrVyVrrwlJv7q8',
|
|
||||||
kid: 'test-ec-kid',
|
|
||||||
};
|
|
||||||
|
|
||||||
const TEST_EC_PUBLIC_JWK = {
|
|
||||||
key_ops: ['verify'],
|
|
||||||
ext: true,
|
|
||||||
kty: 'EC',
|
|
||||||
x: 'Xq2RjZctcPcUMU14qfjs3MtZTmFk8z1lFGQyypgXZOU',
|
|
||||||
y: 't8w9Cbt4RVmR47Wnb_i5cLwefEnMcvwse049zu9Rl_E',
|
|
||||||
crv: 'P-256',
|
|
||||||
kid: 'test-ec-kid',
|
|
||||||
};
|
|
||||||
|
|
||||||
process.env['STEP_CA_URL'] = 'https://step-ca:9000';
|
process.env['STEP_CA_URL'] = 'https://step-ca:9000';
|
||||||
process.env['STEP_CA_PROVISIONER_KEY_JSON'] = JSON.stringify(TEST_EC_PRIVATE_JWK);
|
process.env['STEP_CA_PROVISIONER_PASSWORD'] = 'test-password';
|
||||||
|
process.env['STEP_CA_PROVISIONER_KEY_JSON'] = JWK_KEY;
|
||||||
process.env['STEP_CA_ROOT_CERT_PATH'] = '/fake/root.pem';
|
process.env['STEP_CA_ROOT_CERT_PATH'] = '/fake/root.pem';
|
||||||
|
|
||||||
// Import AFTER env is set and mocks are registered
|
// Import AFTER env is set and mocks are registered
|
||||||
@@ -145,7 +92,6 @@ function makeHttpsMock(statusCode: number, body: unknown, errorMsg?: string): vo
|
|||||||
write: vi.fn(),
|
write: vi.fn(),
|
||||||
end: vi.fn(),
|
end: vi.fn(),
|
||||||
on: vi.fn(),
|
on: vi.fn(),
|
||||||
setTimeout: vi.fn(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
(httpsModule.request as unknown as Mock).mockImplementation(
|
(httpsModule.request as unknown as Mock).mockImplementation(
|
||||||
@@ -200,56 +146,20 @@ describe('CaService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function makeReq(overrides: Partial<IssueCertRequestDto> = {}): IssueCertRequestDto {
|
function makeReq(overrides: Partial<IssueCertRequestDto> = {}): IssueCertRequestDto {
|
||||||
// Use a real CSR if available; fall back to a minimal placeholder
|
|
||||||
const defaultCsr = realCsrPem ?? makeFakeCsr();
|
|
||||||
return {
|
return {
|
||||||
csrPem: defaultCsr,
|
csrPem: FAKE_CSR_PEM,
|
||||||
grantId: GRANT_ID,
|
grantId: GRANT_ID,
|
||||||
subjectUserId: SUBJECT_USER_ID,
|
subjectUserId: SUBJECT_USER_ID,
|
||||||
ttlSeconds: 300,
|
ttlSeconds: 86400,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeFakeCsr(): string {
|
|
||||||
// A structurally valid-looking CSR header/footer (body will fail crypto verify)
|
|
||||||
return `-----BEGIN CERTIFICATE REQUEST-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0000000000000000AAAA\n-----END CERTIFICATE REQUEST-----\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Real CSR generation — runs once and populates realCsrPem
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
it('generates a real P-256 CSR that passes validateCsr', async () => {
|
|
||||||
realCsrPem = await generateRealCsr();
|
|
||||||
expect(realCsrPem).toMatch(/BEGIN CERTIFICATE REQUEST/);
|
|
||||||
|
|
||||||
// Now test that the service's validateCsr accepts it.
|
|
||||||
// We call it indirectly via issueCert with a successful mock.
|
|
||||||
makeHttpsMock(200, { crt: FAKE_CERT_PEM, certChain: [FAKE_CERT_PEM, FAKE_CA_PEM] });
|
|
||||||
const result = await service.issueCert(makeReq({ csrPem: realCsrPem }));
|
|
||||||
expect(result.certPem).toBe(FAKE_CERT_PEM);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws INVALID_CSR for a malformed PEM-shaped CSR', async () => {
|
|
||||||
const malformedCsr =
|
|
||||||
'-----BEGIN CERTIFICATE REQUEST-----\nTm90QVJlYWxDU1I=\n-----END CERTIFICATE REQUEST-----\n';
|
|
||||||
|
|
||||||
await expect(service.issueCert(makeReq({ csrPem: malformedCsr }))).rejects.toSatisfy(
|
|
||||||
(err: unknown) => {
|
|
||||||
if (!(err instanceof CaServiceError)) return false;
|
|
||||||
expect(err.code).toBe('INVALID_CSR');
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Happy path
|
// Happy path
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
it('returns IssuedCertDto on success (certChain present)', async () => {
|
it('returns IssuedCertDto on success (certChain present)', async () => {
|
||||||
if (!realCsrPem) realCsrPem = await generateRealCsr();
|
|
||||||
makeHttpsMock(200, {
|
makeHttpsMock(200, {
|
||||||
crt: FAKE_CERT_PEM,
|
crt: FAKE_CERT_PEM,
|
||||||
certChain: [FAKE_CERT_PEM, FAKE_CA_PEM],
|
certChain: [FAKE_CERT_PEM, FAKE_CA_PEM],
|
||||||
@@ -268,7 +178,6 @@ describe('CaService', () => {
|
|||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
it('builds certChainPem from crt+ca when certChain is absent', async () => {
|
it('builds certChainPem from crt+ca when certChain is absent', async () => {
|
||||||
if (!realCsrPem) realCsrPem = await generateRealCsr();
|
|
||||||
makeHttpsMock(200, {
|
makeHttpsMock(200, {
|
||||||
crt: FAKE_CERT_PEM,
|
crt: FAKE_CERT_PEM,
|
||||||
ca: FAKE_CA_PEM,
|
ca: FAKE_CA_PEM,
|
||||||
@@ -286,7 +195,6 @@ describe('CaService', () => {
|
|||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
it('falls back to certPem alone when certChain and ca are absent', async () => {
|
it('falls back to certPem alone when certChain and ca are absent', async () => {
|
||||||
if (!realCsrPem) realCsrPem = await generateRealCsr();
|
|
||||||
makeHttpsMock(200, { crt: FAKE_CERT_PEM });
|
makeHttpsMock(200, { crt: FAKE_CERT_PEM });
|
||||||
|
|
||||||
const result = await service.issueCert(makeReq());
|
const result = await service.issueCert(makeReq());
|
||||||
@@ -300,7 +208,6 @@ describe('CaService', () => {
|
|||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
it('throws CaServiceError on HTTP 401', async () => {
|
it('throws CaServiceError on HTTP 401', async () => {
|
||||||
if (!realCsrPem) realCsrPem = await generateRealCsr();
|
|
||||||
makeHttpsMock(401, { message: 'Unauthorized' });
|
makeHttpsMock(401, { message: 'Unauthorized' });
|
||||||
|
|
||||||
await expect(service.issueCert(makeReq())).rejects.toSatisfy((err: unknown) => {
|
await expect(service.issueCert(makeReq())).rejects.toSatisfy((err: unknown) => {
|
||||||
@@ -316,7 +223,6 @@ describe('CaService', () => {
|
|||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
it('throws CaServiceError on HTTP 422', async () => {
|
it('throws CaServiceError on HTTP 422', async () => {
|
||||||
if (!realCsrPem) realCsrPem = await generateRealCsr();
|
|
||||||
makeHttpsMock(422, { message: 'Unprocessable Entity' });
|
makeHttpsMock(422, { message: 'Unprocessable Entity' });
|
||||||
|
|
||||||
await expect(service.issueCert(makeReq())).rejects.toBeInstanceOf(CaServiceError);
|
await expect(service.issueCert(makeReq())).rejects.toBeInstanceOf(CaServiceError);
|
||||||
@@ -341,7 +247,6 @@ describe('CaService', () => {
|
|||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
it('throws CaServiceError when step-ca returns non-JSON', async () => {
|
it('throws CaServiceError when step-ca returns non-JSON', async () => {
|
||||||
if (!realCsrPem) realCsrPem = await generateRealCsr();
|
|
||||||
makeHttpsMock(200, 'this is not json');
|
makeHttpsMock(200, 'this is not json');
|
||||||
|
|
||||||
await expect(service.issueCert(makeReq())).rejects.toSatisfy((err: unknown) => {
|
await expect(service.issueCert(makeReq())).rejects.toSatisfy((err: unknown) => {
|
||||||
@@ -356,7 +261,6 @@ describe('CaService', () => {
|
|||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
it('throws CaServiceError on HTTPS connection error', async () => {
|
it('throws CaServiceError on HTTPS connection error', async () => {
|
||||||
if (!realCsrPem) realCsrPem = await generateRealCsr();
|
|
||||||
makeHttpsMock(0, undefined, 'connect ECONNREFUSED 127.0.0.1:9000');
|
makeHttpsMock(0, undefined, 'connect ECONNREFUSED 127.0.0.1:9000');
|
||||||
|
|
||||||
await expect(service.issueCert(makeReq())).rejects.toSatisfy((err: unknown) => {
|
await expect(service.issueCert(makeReq())).rejects.toSatisfy((err: unknown) => {
|
||||||
@@ -369,21 +273,18 @@ describe('CaService', () => {
|
|||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// JWT custom claims: mosaic_grant_id and mosaic_subject_user_id
|
// JWT custom claims: mosaic_grant_id and mosaic_subject_user_id
|
||||||
// Verified with jose.jwtVerify for real signature verification (M6)
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
it('OTT contains mosaic_grant_id, mosaic_subject_user_id, and jti; signature verifies with jose', async () => {
|
it('includes mosaic_grant_id and mosaic_subject_user_id in the OTT payload', async () => {
|
||||||
if (!realCsrPem) realCsrPem = await generateRealCsr();
|
|
||||||
|
|
||||||
let capturedBody: Record<string, unknown> | undefined;
|
let capturedBody: Record<string, unknown> | undefined;
|
||||||
|
|
||||||
|
// Override the mock to capture the request body
|
||||||
const mockReq = {
|
const mockReq = {
|
||||||
write: vi.fn((data: string) => {
|
write: vi.fn((data: string) => {
|
||||||
capturedBody = JSON.parse(data) as Record<string, unknown>;
|
capturedBody = JSON.parse(data) as Record<string, unknown>;
|
||||||
}),
|
}),
|
||||||
end: vi.fn(),
|
end: vi.fn(),
|
||||||
on: vi.fn(),
|
on: vi.fn(),
|
||||||
setTimeout: vi.fn(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
(httpsModule.request as unknown as Mock).mockImplementation(
|
(httpsModule.request as unknown as Mock).mockImplementation(
|
||||||
@@ -410,35 +311,21 @@ describe('CaService', () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
await service.issueCert(makeReq({ csrPem: realCsrPem }));
|
await service.issueCert(makeReq());
|
||||||
|
|
||||||
expect(capturedBody).toBeDefined();
|
expect(capturedBody).toBeDefined();
|
||||||
const ott = capturedBody!['ott'] as string;
|
const ott = capturedBody!['ott'] as string;
|
||||||
expect(typeof ott).toBe('string');
|
expect(typeof ott).toBe('string');
|
||||||
|
|
||||||
// Verify JWT structure
|
// Decode JWT payload (second segment)
|
||||||
const parts = ott.split('.');
|
const parts = ott.split('.');
|
||||||
expect(parts).toHaveLength(3);
|
expect(parts).toHaveLength(3);
|
||||||
|
|
||||||
// Decode payload without signature check first
|
|
||||||
const payloadJson = Buffer.from(parts[1]!, 'base64url').toString('utf8');
|
const payloadJson = Buffer.from(parts[1]!, 'base64url').toString('utf8');
|
||||||
const payload = JSON.parse(payloadJson) as Record<string, unknown>;
|
const payload = JSON.parse(payloadJson) as Record<string, unknown>;
|
||||||
|
|
||||||
expect(payload['mosaic_grant_id']).toBe(GRANT_ID);
|
expect(payload['mosaic_grant_id']).toBe(GRANT_ID);
|
||||||
expect(payload['mosaic_subject_user_id']).toBe(SUBJECT_USER_ID);
|
expect(payload['mosaic_subject_user_id']).toBe(SUBJECT_USER_ID);
|
||||||
expect(typeof payload['jti']).toBe('string'); // M2: jti present
|
|
||||||
expect(payload['jti']).toMatch(/^[0-9a-f-]{36}$/); // UUID format
|
|
||||||
|
|
||||||
// M3: top-level sha should NOT be present; step.sha should be present
|
|
||||||
expect(payload['sha']).toBeUndefined();
|
|
||||||
const step = payload['step'] as Record<string, unknown> | undefined;
|
|
||||||
expect(step?.['sha']).toBeDefined();
|
|
||||||
|
|
||||||
// M6: Verify signature with jose.jwtVerify using the public key
|
|
||||||
const { importJWK: importJose } = await import('jose');
|
|
||||||
const publicKey = await importJose(TEST_EC_PUBLIC_JWK, 'ES256');
|
|
||||||
const verified = await jwtVerify(ott, publicKey);
|
|
||||||
expect(verified.payload['mosaic_grant_id']).toBe(GRANT_ID);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -462,7 +349,6 @@ describe('CaService', () => {
|
|||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
it('throws CaServiceError when response is missing the crt field', async () => {
|
it('throws CaServiceError when response is missing the crt field', async () => {
|
||||||
if (!realCsrPem) realCsrPem = await generateRealCsr();
|
|
||||||
makeHttpsMock(200, { ca: FAKE_CA_PEM });
|
makeHttpsMock(200, { ca: FAKE_CA_PEM });
|
||||||
|
|
||||||
await expect(service.issueCert(makeReq())).rejects.toSatisfy((err: unknown) => {
|
await expect(service.issueCert(makeReq())).rejects.toSatisfy((err: unknown) => {
|
||||||
@@ -471,107 +357,4 @@ describe('CaService', () => {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// M6: provisionerPassword must never appear in CaServiceError messages
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
it('provisionerPassword does not appear in any CaServiceError message', async () => {
|
|
||||||
// Temporarily set a recognizable password to test against
|
|
||||||
const originalPassword = process.env['STEP_CA_PROVISIONER_PASSWORD'];
|
|
||||||
process.env['STEP_CA_PROVISIONER_PASSWORD'] = 'super-secret-password-12345';
|
|
||||||
|
|
||||||
// Generate a bad CSR to trigger an error path
|
|
||||||
const caughtErrors: CaServiceError[] = [];
|
|
||||||
try {
|
|
||||||
await service.issueCert(makeReq({ csrPem: 'not-a-csr' }));
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof CaServiceError) {
|
|
||||||
caughtErrors.push(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also try HTTP 401 path
|
|
||||||
if (!realCsrPem) realCsrPem = await generateRealCsr();
|
|
||||||
makeHttpsMock(401, { message: 'Unauthorized' });
|
|
||||||
try {
|
|
||||||
await service.issueCert(makeReq({ csrPem: realCsrPem }));
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof CaServiceError) {
|
|
||||||
caughtErrors.push(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const err of caughtErrors) {
|
|
||||||
expect(err.message).not.toContain('super-secret-password-12345');
|
|
||||||
if (err.remediation) {
|
|
||||||
expect(err.remediation).not.toContain('super-secret-password-12345');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
process.env['STEP_CA_PROVISIONER_PASSWORD'] = originalPassword;
|
|
||||||
});
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// M7: HTTPS-only enforcement in constructor
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
it('throws in constructor if STEP_CA_URL uses http://', () => {
|
|
||||||
const originalUrl = process.env['STEP_CA_URL'];
|
|
||||||
process.env['STEP_CA_URL'] = 'http://step-ca:9000';
|
|
||||||
|
|
||||||
expect(() => new CaService()).toThrow(CaServiceError);
|
|
||||||
|
|
||||||
process.env['STEP_CA_URL'] = originalUrl;
|
|
||||||
});
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// TTL clamp: ttlSeconds is clamped to 900 s (15 min) maximum
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
it('clamps ttlSeconds to 900 s regardless of input', async () => {
|
|
||||||
if (!realCsrPem) realCsrPem = await generateRealCsr();
|
|
||||||
|
|
||||||
let capturedBody: Record<string, unknown> | undefined;
|
|
||||||
|
|
||||||
const mockReq = {
|
|
||||||
write: vi.fn((data: string) => {
|
|
||||||
capturedBody = JSON.parse(data) as Record<string, unknown>;
|
|
||||||
}),
|
|
||||||
end: vi.fn(),
|
|
||||||
on: vi.fn(),
|
|
||||||
setTimeout: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
(httpsModule.request as unknown as Mock).mockImplementation(
|
|
||||||
(
|
|
||||||
_options: unknown,
|
|
||||||
callback: (res: {
|
|
||||||
statusCode: number;
|
|
||||||
on: (event: string, cb: (chunk?: Buffer) => void) => void;
|
|
||||||
}) => void,
|
|
||||||
) => {
|
|
||||||
const mockRes = {
|
|
||||||
statusCode: 200,
|
|
||||||
on: (event: string, cb: (chunk?: Buffer) => void) => {
|
|
||||||
if (event === 'data') {
|
|
||||||
cb(Buffer.from(JSON.stringify({ crt: FAKE_CERT_PEM })));
|
|
||||||
}
|
|
||||||
if (event === 'end') {
|
|
||||||
cb();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
setImmediate(() => callback(mockRes));
|
|
||||||
return mockReq;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Request 86400 s — should be clamped to 900
|
|
||||||
await service.issueCert(makeReq({ ttlSeconds: 86400 }));
|
|
||||||
|
|
||||||
expect(capturedBody).toBeDefined();
|
|
||||||
const validity = capturedBody!['validity'] as Record<string, unknown>;
|
|
||||||
expect(validity['duration']).toBe('900s');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
* CaService — Step-CA client for federation grant certificate issuance.
|
* CaService — Step-CA client for federation grant certificate issuance.
|
||||||
*
|
*
|
||||||
* Responsibilities:
|
* Responsibilities:
|
||||||
* 1. Build a JWK-provisioner One-Time Token (OTT) signed with the provisioner
|
* 1. Build a JWK-provisioner One-Time Token (OTT) signed with HS256
|
||||||
* private key (ES256/ES384/RS256 per JWK kty/crv) carrying Mosaic-specific
|
* carrying Mosaic-specific claims (`mosaic_grant_id`,
|
||||||
* claims (`mosaic_grant_id`, `mosaic_subject_user_id`, `step.sha`) per the
|
* `mosaic_subject_user_id`, `step.sha`) per the step-ca JWK provisioner
|
||||||
* step-ca JWK provisioner protocol.
|
* protocol.
|
||||||
* 2. POST the CSR + OTT to the step-ca `/1.0/sign` endpoint over HTTPS,
|
* 2. POST the CSR + OTT to the step-ca `/1.0/sign` endpoint over HTTPS,
|
||||||
* pinning the trust to the CA root cert supplied via env.
|
* pinning the trust to the CA root cert supplied via env.
|
||||||
* 3. Return an IssuedCertDto containing the leaf cert, full chain, and
|
* 3. Return an IssuedCertDto containing the leaf cert, full chain, and
|
||||||
@@ -13,11 +13,9 @@
|
|||||||
*
|
*
|
||||||
* Environment variables (all required at runtime — validated in constructor):
|
* Environment variables (all required at runtime — validated in constructor):
|
||||||
* STEP_CA_URL https://step-ca:9000
|
* STEP_CA_URL https://step-ca:9000
|
||||||
* STEP_CA_PROVISIONER_KEY_JSON JWK provisioner private key (JSON)
|
|
||||||
* STEP_CA_ROOT_CERT_PATH Absolute path to the CA root PEM
|
|
||||||
*
|
|
||||||
* Optional (only used for JWK PBES2 decrypt at startup if key is encrypted):
|
|
||||||
* STEP_CA_PROVISIONER_PASSWORD JWK provisioner password (raw string)
|
* STEP_CA_PROVISIONER_PASSWORD JWK provisioner password (raw string)
|
||||||
|
* STEP_CA_PROVISIONER_KEY_JSON JWK provisioner public+private key (JSON)
|
||||||
|
* STEP_CA_ROOT_CERT_PATH Absolute path to the CA root PEM
|
||||||
*
|
*
|
||||||
* Custom OID registry (PRD §6, docs/federation/SETUP.md):
|
* Custom OID registry (PRD §6, docs/federation/SETUP.md):
|
||||||
* 1.3.6.1.4.1.99999.1 — mosaic_grant_id
|
* 1.3.6.1.4.1.99999.1 — mosaic_grant_id
|
||||||
@@ -34,8 +32,6 @@ import { Injectable, Logger } from '@nestjs/common';
|
|||||||
import * as crypto from 'node:crypto';
|
import * as crypto from 'node:crypto';
|
||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
import * as https from 'node:https';
|
import * as https from 'node:https';
|
||||||
import { SignJWT, importJWK } from 'jose';
|
|
||||||
import { Pkcs10CertificateRequest } from '@peculiar/x509';
|
|
||||||
import type { IssueCertRequestDto } from './ca.dto.js';
|
import type { IssueCertRequestDto } from './ca.dto.js';
|
||||||
import { IssuedCertDto } from './ca.dto.js';
|
import { IssuedCertDto } from './ca.dto.js';
|
||||||
|
|
||||||
@@ -46,14 +42,12 @@ import { IssuedCertDto } from './ca.dto.js';
|
|||||||
export class CaServiceError extends Error {
|
export class CaServiceError extends Error {
|
||||||
readonly cause: unknown;
|
readonly cause: unknown;
|
||||||
readonly remediation: string;
|
readonly remediation: string;
|
||||||
readonly code?: string;
|
|
||||||
|
|
||||||
constructor(message: string, remediation: string, cause?: unknown, code?: string) {
|
constructor(message: string, remediation: string, cause?: unknown) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = 'CaServiceError';
|
this.name = 'CaServiceError';
|
||||||
this.cause = cause;
|
this.cause = cause;
|
||||||
this.remediation = remediation;
|
this.remediation = remediation;
|
||||||
this.code = code;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,24 +80,12 @@ interface JwkKey {
|
|||||||
// Helpers
|
// Helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/** UUID regex for validation */
|
|
||||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Derive the JWT algorithm string from a JWK's kty/crv fields.
|
* Base64url-encode a Buffer or string (no padding).
|
||||||
* EC P-256 → ES256, EC P-384 → ES384, RSA → RS256.
|
|
||||||
*/
|
*/
|
||||||
function algFromJwk(jwk: JwkKey): string {
|
function b64url(input: Buffer | string): string {
|
||||||
if (jwk.alg) return jwk.alg;
|
const buf = typeof input === 'string' ? Buffer.from(input, 'utf8') : input;
|
||||||
if (jwk.kty === 'EC') {
|
return buf.toString('base64url');
|
||||||
if (jwk.crv === 'P-384') return 'ES384';
|
|
||||||
return 'ES256'; // default for P-256 and Ed25519-style EC keys
|
|
||||||
}
|
|
||||||
if (jwk.kty === 'RSA') return 'RS256';
|
|
||||||
throw new CaServiceError(
|
|
||||||
`Unsupported JWK kty: ${jwk.kty}`,
|
|
||||||
'STEP_CA_PROVISIONER_KEY_JSON must be an EC (P-256/P-384) or RSA JWK private key.',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -138,6 +120,91 @@ function csrFingerprint(csrPem: string): string {
|
|||||||
return crypto.createHash('sha256').update(derBuf).digest('hex');
|
return crypto.createHash('sha256').update(derBuf).digest('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive a signing key from the JWK provisioner password using PBKDF2
|
||||||
|
* then sign with HMAC-SHA256 to produce an HS256 JWT.
|
||||||
|
*
|
||||||
|
* step-ca JWK provisioner tokens:
|
||||||
|
* - alg: HS256
|
||||||
|
* - header.kid: provisioner key ID
|
||||||
|
* - The key is the raw password bytes (step-ca uses the password directly
|
||||||
|
* as the HMAC key when the JWK provisioner type is "JWK" with symmetric
|
||||||
|
* key, or the password-derived key when encrypting the JWK).
|
||||||
|
*
|
||||||
|
* Per step-ca source (jose/jwk.go), for a JWK provisioner the OTT is a
|
||||||
|
* JWT signed with the provisioner's decrypted private key. For HS256 the
|
||||||
|
* key material is the `k` field of the JWK (symmetric secret), which itself
|
||||||
|
* was encrypted with the provisioner password. Since we already have the
|
||||||
|
* raw provisioner password we use it directly as the HMAC key — this mirrors
|
||||||
|
* what `step ca token` does for symmetric JWK provisioners.
|
||||||
|
*/
|
||||||
|
function buildOtt(params: {
|
||||||
|
caUrl: string;
|
||||||
|
provisionerPassword: string;
|
||||||
|
provisionerKeyJson: string;
|
||||||
|
csrPem: string;
|
||||||
|
grantId: string;
|
||||||
|
subjectUserId: string;
|
||||||
|
ttlSeconds: number;
|
||||||
|
}): string {
|
||||||
|
const {
|
||||||
|
caUrl,
|
||||||
|
provisionerPassword,
|
||||||
|
provisionerKeyJson,
|
||||||
|
csrPem,
|
||||||
|
grantId,
|
||||||
|
subjectUserId,
|
||||||
|
ttlSeconds,
|
||||||
|
} = params;
|
||||||
|
|
||||||
|
let jwk: JwkKey;
|
||||||
|
try {
|
||||||
|
jwk = JSON.parse(provisionerKeyJson) as JwkKey;
|
||||||
|
} catch (err) {
|
||||||
|
throw new CaServiceError(
|
||||||
|
'STEP_CA_PROVISIONER_KEY_JSON is not valid JSON',
|
||||||
|
'Set STEP_CA_PROVISIONER_KEY_JSON to the JSON-serialised JWK object for the mosaic-fed provisioner.',
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sha = csrFingerprint(csrPem);
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const kid = jwk.kid ?? 'mosaic-fed';
|
||||||
|
|
||||||
|
const header = {
|
||||||
|
alg: 'HS256',
|
||||||
|
typ: 'JWT',
|
||||||
|
kid,
|
||||||
|
};
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
iss: kid,
|
||||||
|
sub: `${caUrl}/1.0/sign`,
|
||||||
|
aud: [`${caUrl}/1.0/sign`],
|
||||||
|
iat: now,
|
||||||
|
nbf: now - 30, // 30 s clock-skew tolerance
|
||||||
|
exp: now + Math.min(ttlSeconds, 3600), // OTT validity ≤ 1 h
|
||||||
|
sha,
|
||||||
|
// Mosaic custom claims consumed by federation.tpl
|
||||||
|
mosaic_grant_id: grantId,
|
||||||
|
mosaic_subject_user_id: subjectUserId,
|
||||||
|
// step.sha is the canonical field name used in the template
|
||||||
|
step: { sha },
|
||||||
|
};
|
||||||
|
|
||||||
|
const headerB64 = b64url(JSON.stringify(header));
|
||||||
|
const payloadB64 = b64url(JSON.stringify(payload));
|
||||||
|
const signingInput = `${headerB64}.${payloadB64}`;
|
||||||
|
|
||||||
|
// Use the provisioner password as the raw HMAC-SHA256 key.
|
||||||
|
const hmac = crypto.createHmac('sha256', Buffer.from(provisionerPassword, 'utf8'));
|
||||||
|
hmac.update(signingInput);
|
||||||
|
const signature = hmac.digest();
|
||||||
|
|
||||||
|
return `${signingInput}.${b64url(signature)}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a JSON POST to the step-ca sign endpoint.
|
* Send a JSON POST to the step-ca sign endpoint.
|
||||||
* Returns the parsed response body or throws CaServiceError.
|
* Returns the parsed response body or throws CaServiceError.
|
||||||
@@ -157,7 +224,6 @@ function httpsPost(url: string, body: unknown, agent: https.Agent): Promise<Step
|
|||||||
'Content-Length': Buffer.byteLength(bodyStr),
|
'Content-Length': Buffer.byteLength(bodyStr),
|
||||||
},
|
},
|
||||||
agent,
|
agent,
|
||||||
timeout: 5000,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const req = https.request(options, (res) => {
|
const req = https.request(options, (res) => {
|
||||||
@@ -170,7 +236,7 @@ function httpsPost(url: string, body: unknown, agent: https.Agent): Promise<Step
|
|||||||
reject(
|
reject(
|
||||||
new CaServiceError(
|
new CaServiceError(
|
||||||
`step-ca returned HTTP 401 — invalid or expired OTT`,
|
`step-ca returned HTTP 401 — invalid or expired OTT`,
|
||||||
'Check STEP_CA_PROVISIONER_KEY_JSON. Ensure the mosaic-fed provisioner is configured in the CA.',
|
'Check STEP_CA_PROVISIONER_PASSWORD and STEP_CA_PROVISIONER_KEY_JSON. Ensure the mosaic-fed provisioner is configured in the CA.',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -204,10 +270,6 @@ function httpsPost(url: string, body: unknown, agent: https.Agent): Promise<Step
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
req.setTimeout(5000, () => {
|
|
||||||
req.destroy(new Error('Request timed out after 5000ms'));
|
|
||||||
});
|
|
||||||
|
|
||||||
req.on('error', (err: Error) => {
|
req.on('error', (err: Error) => {
|
||||||
reject(
|
reject(
|
||||||
new CaServiceError(
|
new CaServiceError(
|
||||||
@@ -225,21 +287,15 @@ function httpsPost(url: string, body: unknown, agent: https.Agent): Promise<Step
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract a decimal serial number from a PEM certificate.
|
* Extract a decimal serial number from a PEM certificate.
|
||||||
* Throws CaServiceError on failure — never silently returns 'unknown'.
|
* Returns the hex serial if conversion is not possible.
|
||||||
*/
|
*/
|
||||||
function extractSerial(certPem: string): string {
|
function extractSerial(certPem: string): string {
|
||||||
let cert: crypto.X509Certificate;
|
|
||||||
try {
|
try {
|
||||||
cert = new crypto.X509Certificate(certPem);
|
const cert = new crypto.X509Certificate(certPem);
|
||||||
} catch (err) {
|
|
||||||
throw new CaServiceError(
|
|
||||||
'Failed to parse the issued certificate PEM',
|
|
||||||
'The certificate returned by step-ca could not be parsed. Check that step-ca is returning a valid PEM certificate.',
|
|
||||||
err,
|
|
||||||
'CERT_PARSE',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return cert.serialNumber;
|
return cert.serialNumber;
|
||||||
|
} catch {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -251,15 +307,14 @@ export class CaService {
|
|||||||
private readonly logger = new Logger(CaService.name);
|
private readonly logger = new Logger(CaService.name);
|
||||||
|
|
||||||
private readonly caUrl: string;
|
private readonly caUrl: string;
|
||||||
|
private readonly provisionerPassword: string;
|
||||||
|
private readonly provisionerKeyJson: string;
|
||||||
private readonly rootCertPath: string;
|
private readonly rootCertPath: string;
|
||||||
private readonly httpsAgent: https.Agent;
|
private readonly httpsAgent: https.Agent;
|
||||||
private readonly jwk: JwkKey;
|
|
||||||
private cachedPrivateKey: crypto.KeyObject | null = null;
|
|
||||||
private readonly jwtAlg: string;
|
|
||||||
private readonly kid: string;
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
const caUrl = process.env['STEP_CA_URL'];
|
const caUrl = process.env['STEP_CA_URL'];
|
||||||
|
const provisionerPassword = process.env['STEP_CA_PROVISIONER_PASSWORD'];
|
||||||
const provisionerKeyJson = process.env['STEP_CA_PROVISIONER_KEY_JSON'];
|
const provisionerKeyJson = process.env['STEP_CA_PROVISIONER_KEY_JSON'];
|
||||||
const rootCertPath = process.env['STEP_CA_ROOT_CERT_PATH'];
|
const rootCertPath = process.env['STEP_CA_ROOT_CERT_PATH'];
|
||||||
|
|
||||||
@@ -269,25 +324,12 @@ export class CaService {
|
|||||||
'Set STEP_CA_URL to the base URL of the step-ca instance, e.g. https://step-ca:9000',
|
'Set STEP_CA_URL to the base URL of the step-ca instance, e.g. https://step-ca:9000',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (!provisionerPassword) {
|
||||||
// Enforce HTTPS-only URL
|
|
||||||
let parsedUrl: URL;
|
|
||||||
try {
|
|
||||||
parsedUrl = new URL(caUrl);
|
|
||||||
} catch (err) {
|
|
||||||
throw new CaServiceError(
|
throw new CaServiceError(
|
||||||
`STEP_CA_URL is not a valid URL: ${caUrl}`,
|
'STEP_CA_PROVISIONER_PASSWORD is not set',
|
||||||
'Set STEP_CA_URL to a valid HTTPS URL, e.g. https://step-ca:9000',
|
'Set STEP_CA_PROVISIONER_PASSWORD to the JWK provisioner password for the mosaic-fed provisioner.',
|
||||||
err,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (parsedUrl.protocol !== 'https:') {
|
|
||||||
throw new CaServiceError(
|
|
||||||
`STEP_CA_URL must use HTTPS — got: ${parsedUrl.protocol}`,
|
|
||||||
'Set STEP_CA_URL to an https:// URL. Unencrypted connections to the CA are not permitted.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!provisionerKeyJson) {
|
if (!provisionerKeyJson) {
|
||||||
throw new CaServiceError(
|
throw new CaServiceError(
|
||||||
'STEP_CA_PROVISIONER_KEY_JSON is not set',
|
'STEP_CA_PROVISIONER_KEY_JSON is not set',
|
||||||
@@ -301,32 +343,9 @@ export class CaService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse JWK once — do NOT store the raw JSON string as a class field
|
|
||||||
let jwk: JwkKey;
|
|
||||||
try {
|
|
||||||
jwk = JSON.parse(provisionerKeyJson) as JwkKey;
|
|
||||||
} catch (err) {
|
|
||||||
throw new CaServiceError(
|
|
||||||
'STEP_CA_PROVISIONER_KEY_JSON is not valid JSON',
|
|
||||||
'Set STEP_CA_PROVISIONER_KEY_JSON to the JSON-serialised JWK object for the mosaic-fed provisioner.',
|
|
||||||
err,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Derive algorithm from JWK metadata
|
|
||||||
const jwtAlg = algFromJwk(jwk);
|
|
||||||
const kid = jwk.kid ?? 'mosaic-fed';
|
|
||||||
|
|
||||||
// Import the JWK into a native KeyObject — fail loudly if it cannot be loaded.
|
|
||||||
// We do this synchronously here by calling the async importJWK via a blocking workaround.
|
|
||||||
// Actually importJWK is async, so we store it for use during token building.
|
|
||||||
// We keep the raw jwk object for later async import inside buildOtt.
|
|
||||||
// NOTE: We do NOT store provisionerKeyJson string as a class field.
|
|
||||||
this.jwk = jwk;
|
|
||||||
this.jwtAlg = jwtAlg;
|
|
||||||
this.kid = kid;
|
|
||||||
|
|
||||||
this.caUrl = caUrl;
|
this.caUrl = caUrl;
|
||||||
|
this.provisionerPassword = provisionerPassword;
|
||||||
|
this.provisionerKeyJson = provisionerKeyJson;
|
||||||
this.rootCertPath = rootCertPath;
|
this.rootCertPath = rootCertPath;
|
||||||
|
|
||||||
// Read the root cert and pin it for all HTTPS connections.
|
// Read the root cert and pin it for all HTTPS connections.
|
||||||
@@ -349,225 +368,6 @@ export class CaService {
|
|||||||
this.logger.log(`CaService initialised — CA URL: ${this.caUrl}`);
|
this.logger.log(`CaService initialised — CA URL: ${this.caUrl}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Lazily import the private key from JWK on first use.
|
|
||||||
* The key is cached in cachedPrivateKey after first import.
|
|
||||||
*/
|
|
||||||
private async getPrivateKey(): Promise<crypto.KeyObject> {
|
|
||||||
if (this.cachedPrivateKey !== null) return this.cachedPrivateKey;
|
|
||||||
try {
|
|
||||||
const key = await importJWK(this.jwk, this.jwtAlg);
|
|
||||||
// importJWK returns KeyLike (crypto.KeyObject | Uint8Array) — in Node.js it's KeyObject
|
|
||||||
this.cachedPrivateKey = key as unknown as crypto.KeyObject;
|
|
||||||
return this.cachedPrivateKey;
|
|
||||||
} catch (err) {
|
|
||||||
throw new CaServiceError(
|
|
||||||
'Failed to import STEP_CA_PROVISIONER_KEY_JSON as a cryptographic key',
|
|
||||||
'Ensure STEP_CA_PROVISIONER_KEY_JSON contains a valid JWK private key (EC P-256/P-384 or RSA).',
|
|
||||||
err,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the JWK-provisioner OTT signed with the provisioner private key.
|
|
||||||
* Algorithm is derived from the JWK kty/crv fields.
|
|
||||||
*/
|
|
||||||
private async buildOtt(params: {
|
|
||||||
csrPem: string;
|
|
||||||
grantId: string;
|
|
||||||
subjectUserId: string;
|
|
||||||
ttlSeconds: number;
|
|
||||||
csrCn: string;
|
|
||||||
}): Promise<string> {
|
|
||||||
const { csrPem, grantId, subjectUserId, ttlSeconds, csrCn } = params;
|
|
||||||
|
|
||||||
// Validate UUID shape for grant id and subject user id
|
|
||||||
if (!UUID_RE.test(grantId)) {
|
|
||||||
throw new CaServiceError(
|
|
||||||
`grantId is not a valid UUID: ${grantId}`,
|
|
||||||
'Provide a valid UUID (RFC 4122) for grantId.',
|
|
||||||
undefined,
|
|
||||||
'INVALID_GRANT_ID',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!UUID_RE.test(subjectUserId)) {
|
|
||||||
throw new CaServiceError(
|
|
||||||
`subjectUserId is not a valid UUID: ${subjectUserId}`,
|
|
||||||
'Provide a valid UUID (RFC 4122) for subjectUserId.',
|
|
||||||
undefined,
|
|
||||||
'INVALID_GRANT_ID',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sha = csrFingerprint(csrPem);
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
const privateKey = await this.getPrivateKey();
|
|
||||||
|
|
||||||
const ott = await new SignJWT({
|
|
||||||
iss: this.kid,
|
|
||||||
sub: csrCn, // M1: set sub to identity from CSR CN
|
|
||||||
aud: [`${this.caUrl}/1.0/sign`],
|
|
||||||
iat: now,
|
|
||||||
nbf: now - 30, // 30 s clock-skew tolerance
|
|
||||||
exp: now + Math.min(ttlSeconds, 3600), // OTT validity ≤ 1 h
|
|
||||||
jti: crypto.randomUUID(), // M2: unique token ID
|
|
||||||
// step.sha is the canonical field name used in the template — M3: keep only step.sha
|
|
||||||
step: { sha },
|
|
||||||
// Mosaic custom claims consumed by federation.tpl
|
|
||||||
mosaic_grant_id: grantId,
|
|
||||||
mosaic_subject_user_id: subjectUserId,
|
|
||||||
})
|
|
||||||
.setProtectedHeader({ alg: this.jwtAlg, typ: 'JWT', kid: this.kid })
|
|
||||||
.sign(privateKey);
|
|
||||||
|
|
||||||
return ott;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate a PEM-encoded CSR using @peculiar/x509.
|
|
||||||
* Verifies the self-signature, key type/size, and signature algorithm.
|
|
||||||
* Optionally verifies that the CSR's SANs match the expected set.
|
|
||||||
*
|
|
||||||
* Throws CaServiceError with code 'INVALID_CSR' on failure.
|
|
||||||
*/
|
|
||||||
private async validateCsr(pem: string, expectedSans?: string[]): Promise<string> {
|
|
||||||
let csr: Pkcs10CertificateRequest;
|
|
||||||
try {
|
|
||||||
csr = new Pkcs10CertificateRequest(pem);
|
|
||||||
} catch (err) {
|
|
||||||
throw new CaServiceError(
|
|
||||||
'Failed to parse CSR PEM as a valid PKCS#10 certificate request',
|
|
||||||
'Provide a valid PEM-encoded PKCS#10 CSR.',
|
|
||||||
err,
|
|
||||||
'INVALID_CSR',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify self-signature
|
|
||||||
let valid: boolean;
|
|
||||||
try {
|
|
||||||
valid = await csr.verify();
|
|
||||||
} catch (err) {
|
|
||||||
throw new CaServiceError(
|
|
||||||
'CSR signature verification threw an error',
|
|
||||||
'The CSR self-signature could not be verified. Ensure the CSR is properly formed.',
|
|
||||||
err,
|
|
||||||
'INVALID_CSR',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!valid) {
|
|
||||||
throw new CaServiceError(
|
|
||||||
'CSR self-signature is invalid',
|
|
||||||
'The CSR must be self-signed with the corresponding private key.',
|
|
||||||
undefined,
|
|
||||||
'INVALID_CSR',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate signature algorithm — reject MD5 and SHA-1
|
|
||||||
// signatureAlgorithm is HashedAlgorithm which extends Algorithm.
|
|
||||||
// Cast through unknown to access .name and .hash.name without DOM lib globals.
|
|
||||||
const sigAlgAny = csr.signatureAlgorithm as unknown as {
|
|
||||||
name?: string;
|
|
||||||
hash?: { name?: string };
|
|
||||||
};
|
|
||||||
const sigAlgName = (sigAlgAny.name ?? '').toLowerCase();
|
|
||||||
const hashName = (sigAlgAny.hash?.name ?? '').toLowerCase();
|
|
||||||
if (
|
|
||||||
sigAlgName.includes('md5') ||
|
|
||||||
sigAlgName.includes('sha1') ||
|
|
||||||
hashName === 'sha-1' ||
|
|
||||||
hashName === 'sha1'
|
|
||||||
) {
|
|
||||||
throw new CaServiceError(
|
|
||||||
`CSR uses a forbidden signature algorithm: ${sigAlgAny.name ?? 'unknown'}`,
|
|
||||||
'Use SHA-256 or stronger. MD5 and SHA-1 are not permitted.',
|
|
||||||
undefined,
|
|
||||||
'INVALID_CSR',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate public key algorithm and strength via the algorithm descriptor on the key.
|
|
||||||
// csr.publicKey.algorithm is type Algorithm (WebCrypto) — use name-based checks.
|
|
||||||
// We cast to an extended interface to access curve/modulus info without DOM globals.
|
|
||||||
const pubKeyAlgo = csr.publicKey.algorithm as {
|
|
||||||
name: string;
|
|
||||||
namedCurve?: string;
|
|
||||||
modulusLength?: number;
|
|
||||||
};
|
|
||||||
const keyAlgoName = pubKeyAlgo.name;
|
|
||||||
|
|
||||||
if (keyAlgoName === 'RSASSA-PKCS1-v1_5' || keyAlgoName === 'RSA-PSS') {
|
|
||||||
const modulusLength = pubKeyAlgo.modulusLength ?? 0;
|
|
||||||
if (modulusLength < 2048) {
|
|
||||||
throw new CaServiceError(
|
|
||||||
`CSR RSA key is too short: ${modulusLength} bits (minimum 2048)`,
|
|
||||||
'Use an RSA key of at least 2048 bits.',
|
|
||||||
undefined,
|
|
||||||
'INVALID_CSR',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if (keyAlgoName === 'ECDSA') {
|
|
||||||
const namedCurve = pubKeyAlgo.namedCurve ?? '';
|
|
||||||
const allowedCurves = new Set(['P-256', 'P-384']);
|
|
||||||
if (!allowedCurves.has(namedCurve)) {
|
|
||||||
throw new CaServiceError(
|
|
||||||
`CSR EC key uses disallowed curve: ${namedCurve}`,
|
|
||||||
'Use EC P-256 or P-384. Other curves are not permitted.',
|
|
||||||
undefined,
|
|
||||||
'INVALID_CSR',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if (keyAlgoName === 'Ed25519') {
|
|
||||||
// Ed25519 is explicitly allowed
|
|
||||||
} else {
|
|
||||||
throw new CaServiceError(
|
|
||||||
`CSR uses unsupported key algorithm: ${keyAlgoName}`,
|
|
||||||
'Use EC (P-256/P-384), Ed25519, or RSA (≥2048 bit) keys.',
|
|
||||||
undefined,
|
|
||||||
'INVALID_CSR',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract SANs if expectedSans provided
|
|
||||||
if (expectedSans && expectedSans.length > 0) {
|
|
||||||
// Get SANs from CSR extensions
|
|
||||||
const sanExtension = csr.extensions?.find(
|
|
||||||
(ext) => ext.type === '2.5.29.17', // Subject Alternative Name OID
|
|
||||||
);
|
|
||||||
const csrSans: string[] = [];
|
|
||||||
if (sanExtension) {
|
|
||||||
// Parse the raw SAN extension — store as stringified for comparison
|
|
||||||
// @peculiar/x509 exposes SANs through the parsed extension
|
|
||||||
const sanExt = sanExtension as { names?: Array<{ type: string; value: string }> };
|
|
||||||
if (sanExt.names) {
|
|
||||||
for (const name of sanExt.names) {
|
|
||||||
csrSans.push(name.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const csrSanSet = new Set(csrSans);
|
|
||||||
const expectedSanSet = new Set(expectedSans);
|
|
||||||
const missing = expectedSans.filter((s) => !csrSanSet.has(s));
|
|
||||||
const extra = csrSans.filter((s) => !expectedSanSet.has(s));
|
|
||||||
|
|
||||||
if (missing.length > 0 || extra.length > 0) {
|
|
||||||
throw new CaServiceError(
|
|
||||||
`CSR SANs do not match expected set. Missing: [${missing.join(', ')}], Extra: [${extra.join(', ')}]`,
|
|
||||||
'The CSR must include exactly the SANs specified in the issuance request.',
|
|
||||||
undefined,
|
|
||||||
'INVALID_CSR',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return the CN from the CSR subject for use as JWT sub
|
|
||||||
const cn = csr.subjectName.getField('CN')?.[0] ?? '';
|
|
||||||
return cn;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Submit a CSR to step-ca and return the issued certificate.
|
* Submit a CSR to step-ca and return the issued certificate.
|
||||||
*
|
*
|
||||||
@@ -575,22 +375,26 @@ export class CaService {
|
|||||||
* Never silently swallows errors — fail-loud is a hard contract per M2-02 review.
|
* Never silently swallows errors — fail-loud is a hard contract per M2-02 review.
|
||||||
*/
|
*/
|
||||||
async issueCert(req: IssueCertRequestDto): Promise<IssuedCertDto> {
|
async issueCert(req: IssueCertRequestDto): Promise<IssuedCertDto> {
|
||||||
// Clamp TTL to 15-minute maximum (H2)
|
|
||||||
const ttl = Math.min(req.ttlSeconds ?? 300, 900);
|
|
||||||
|
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`issueCert — grantId=${req.grantId} subjectUserId=${req.subjectUserId} ttl=${ttl}s`,
|
`issueCert — grantId=${req.grantId} subjectUserId=${req.subjectUserId} ttl=${req.ttlSeconds}s`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Validate CSR — real cryptographic validation (H3)
|
// Validate CSR before making network calls
|
||||||
const csrCn = await this.validateCsr(req.csrPem);
|
if (!req.csrPem || !req.csrPem.includes('CERTIFICATE REQUEST')) {
|
||||||
|
throw new CaServiceError(
|
||||||
|
'csrPem does not appear to be a valid PKCS#10 PEM',
|
||||||
|
'Provide a PEM-encoded CSR starting with -----BEGIN CERTIFICATE REQUEST-----.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const ott = await this.buildOtt({
|
const ott = buildOtt({
|
||||||
|
caUrl: this.caUrl,
|
||||||
|
provisionerPassword: this.provisionerPassword,
|
||||||
|
provisionerKeyJson: this.provisionerKeyJson,
|
||||||
csrPem: req.csrPem,
|
csrPem: req.csrPem,
|
||||||
grantId: req.grantId,
|
grantId: req.grantId,
|
||||||
subjectUserId: req.subjectUserId,
|
subjectUserId: req.subjectUserId,
|
||||||
ttlSeconds: ttl,
|
ttlSeconds: req.ttlSeconds,
|
||||||
csrCn,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const signUrl = `${this.caUrl}/1.0/sign`;
|
const signUrl = `${this.caUrl}/1.0/sign`;
|
||||||
@@ -598,7 +402,7 @@ export class CaService {
|
|||||||
csr: req.csrPem,
|
csr: req.csrPem,
|
||||||
ott,
|
ott,
|
||||||
validity: {
|
validity: {
|
||||||
duration: `${ttl}s`,
|
duration: `${req.ttlSeconds}s`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
/**
|
|
||||||
* EnrollmentController — federation enrollment HTTP layer (FED-M2-07).
|
|
||||||
*
|
|
||||||
* Routes:
|
|
||||||
* POST /api/federation/enrollment/tokens — admin creates a single-use token
|
|
||||||
* POST /api/federation/enrollment/:token — unauthenticated; token IS the auth
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
Body,
|
|
||||||
Controller,
|
|
||||||
HttpCode,
|
|
||||||
HttpStatus,
|
|
||||||
Inject,
|
|
||||||
Param,
|
|
||||||
Post,
|
|
||||||
UseGuards,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { AdminGuard } from '../admin/admin.guard.js';
|
|
||||||
import { EnrollmentService } from './enrollment.service.js';
|
|
||||||
import { CreateEnrollmentTokenDto, RedeemEnrollmentTokenDto } from './enrollment.dto.js';
|
|
||||||
|
|
||||||
@Controller('api/federation/enrollment')
|
|
||||||
export class EnrollmentController {
|
|
||||||
constructor(@Inject(EnrollmentService) private readonly enrollmentService: EnrollmentService) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Admin-only: generate a single-use enrollment token for a pending grant.
|
|
||||||
* The token should be distributed out-of-band to the remote peer operator.
|
|
||||||
*
|
|
||||||
* POST /api/federation/enrollment/tokens
|
|
||||||
*/
|
|
||||||
@Post('tokens')
|
|
||||||
@UseGuards(AdminGuard)
|
|
||||||
@HttpCode(HttpStatus.CREATED)
|
|
||||||
async createToken(@Body() dto: CreateEnrollmentTokenDto) {
|
|
||||||
return this.enrollmentService.createToken(dto);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unauthenticated: remote peer redeems a token by submitting its CSR.
|
|
||||||
* The token itself is the credential — no session or bearer token required.
|
|
||||||
*
|
|
||||||
* POST /api/federation/enrollment/:token
|
|
||||||
*
|
|
||||||
* Returns the signed leaf cert and full chain PEM on success.
|
|
||||||
* Returns 410 Gone if the token was already used or has expired.
|
|
||||||
*/
|
|
||||||
@Post(':token')
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
async redeem(@Param('token') token: string, @Body() dto: RedeemEnrollmentTokenDto) {
|
|
||||||
return this.enrollmentService.redeem(token, dto.csrPem);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
/**
|
|
||||||
* DTOs for the federation enrollment flow (FED-M2-07).
|
|
||||||
*
|
|
||||||
* CreateEnrollmentTokenDto — admin generates a single-use enrollment token
|
|
||||||
* RedeemEnrollmentTokenDto — remote peer submits CSR to redeem the token
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { IsInt, IsNotEmpty, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator';
|
|
||||||
|
|
||||||
export class CreateEnrollmentTokenDto {
|
|
||||||
/** UUID of the federation grant this token will activate on redemption. */
|
|
||||||
@IsUUID()
|
|
||||||
grantId!: string;
|
|
||||||
|
|
||||||
/** UUID of the peer record that will receive the issued cert on redemption. */
|
|
||||||
@IsUUID()
|
|
||||||
peerId!: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Token lifetime in seconds. Default 900 (15 min). Min 60. Max 900.
|
|
||||||
* After this time the token is rejected even if unused.
|
|
||||||
*/
|
|
||||||
@IsOptional()
|
|
||||||
@IsInt()
|
|
||||||
@Min(60)
|
|
||||||
@Max(900)
|
|
||||||
ttlSeconds: number = 900;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RedeemEnrollmentTokenDto {
|
|
||||||
/** PEM-encoded PKCS#10 Certificate Signing Request from the remote peer. */
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
csrPem!: string;
|
|
||||||
}
|
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
/**
|
|
||||||
* EnrollmentService — single-use enrollment token lifecycle (FED-M2-07).
|
|
||||||
*
|
|
||||||
* Responsibilities:
|
|
||||||
* 1. Generate time-limited single-use enrollment tokens (admin action).
|
|
||||||
* 2. Redeem a token: validate → atomically claim token → issue cert via
|
|
||||||
* CaService → transactionally activate grant + update peer + write audit.
|
|
||||||
*
|
|
||||||
* Replay protection: the token is claimed (UPDATE WHERE used_at IS NULL) BEFORE
|
|
||||||
* cert issuance. This prevents double cert minting on concurrent requests.
|
|
||||||
* If cert issuance fails after claim, the token is consumed and the grant
|
|
||||||
* stays pending — admin must create a new grant.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
BadRequestException,
|
|
||||||
GoneException,
|
|
||||||
Inject,
|
|
||||||
Injectable,
|
|
||||||
Logger,
|
|
||||||
NotFoundException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import * as crypto from 'node:crypto';
|
|
||||||
// X509Certificate is available as a named export in Node.js ≥ 15.6
|
|
||||||
const { X509Certificate } = crypto;
|
|
||||||
import {
|
|
||||||
type Db,
|
|
||||||
and,
|
|
||||||
eq,
|
|
||||||
isNull,
|
|
||||||
sql,
|
|
||||||
federationEnrollmentTokens,
|
|
||||||
federationGrants,
|
|
||||||
federationPeers,
|
|
||||||
federationAuditLog,
|
|
||||||
} from '@mosaicstack/db';
|
|
||||||
import { DB } from '../database/database.module.js';
|
|
||||||
import { CaService } from './ca.service.js';
|
|
||||||
import { GrantsService } from './grants.service.js';
|
|
||||||
import { FederationScopeError } from './scope-schema.js';
|
|
||||||
import type { CreateEnrollmentTokenDto } from './enrollment.dto.js';
|
|
||||||
|
|
||||||
export interface EnrollmentTokenResult {
|
|
||||||
token: string;
|
|
||||||
expiresAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RedeemResult {
|
|
||||||
certPem: string;
|
|
||||||
certChainPem: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class EnrollmentService {
|
|
||||||
private readonly logger = new Logger(EnrollmentService.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
@Inject(DB) private readonly db: Db,
|
|
||||||
private readonly caService: CaService,
|
|
||||||
private readonly grantsService: GrantsService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a single-use enrollment token for an admin to distribute
|
|
||||||
* out-of-band to the remote peer operator.
|
|
||||||
*/
|
|
||||||
async createToken(dto: CreateEnrollmentTokenDto): Promise<EnrollmentTokenResult> {
|
|
||||||
const ttl = Math.min(dto.ttlSeconds, 900);
|
|
||||||
const token = crypto.randomBytes(32).toString('hex');
|
|
||||||
const expiresAt = new Date(Date.now() + ttl * 1000);
|
|
||||||
|
|
||||||
await this.db.insert(federationEnrollmentTokens).values({
|
|
||||||
token,
|
|
||||||
grantId: dto.grantId,
|
|
||||||
peerId: dto.peerId,
|
|
||||||
expiresAt,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log(
|
|
||||||
`Enrollment token created — grantId=${dto.grantId} peerId=${dto.peerId} expiresAt=${expiresAt.toISOString()}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return { token, expiresAt: expiresAt.toISOString() };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Redeem an enrollment token.
|
|
||||||
*
|
|
||||||
* Full flow:
|
|
||||||
* 1. Fetch token row — NotFoundException if not found
|
|
||||||
* 2. usedAt set → GoneException (already used)
|
|
||||||
* 3. expiresAt < now → GoneException (expired)
|
|
||||||
* 4. Load grant — verify status is 'pending'
|
|
||||||
* 5. Atomically claim token (UPDATE WHERE used_at IS NULL RETURNING token)
|
|
||||||
* — if no rows returned, concurrent request won → GoneException
|
|
||||||
* 6. Issue cert via CaService (network call, outside transaction)
|
|
||||||
* — if this fails, token is consumed; grant stays pending; admin must recreate
|
|
||||||
* 7. Transaction: activate grant + update peer record + write audit log
|
|
||||||
* 8. Return { certPem, certChainPem }
|
|
||||||
*/
|
|
||||||
async redeem(token: string, csrPem: string): Promise<RedeemResult> {
|
|
||||||
// 1. Fetch token row
|
|
||||||
const [row] = await this.db
|
|
||||||
.select()
|
|
||||||
.from(federationEnrollmentTokens)
|
|
||||||
.where(eq(federationEnrollmentTokens.token, token))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!row) {
|
|
||||||
throw new NotFoundException('Enrollment token not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Already used?
|
|
||||||
if (row.usedAt !== null) {
|
|
||||||
throw new GoneException('Enrollment token has already been used');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Expired?
|
|
||||||
if (row.expiresAt < new Date()) {
|
|
||||||
throw new GoneException('Enrollment token has expired');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Load grant and verify it is still pending
|
|
||||||
let grant;
|
|
||||||
try {
|
|
||||||
grant = await this.grantsService.getGrant(row.grantId);
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof FederationScopeError) {
|
|
||||||
throw new BadRequestException(err.message);
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (grant.status !== 'pending') {
|
|
||||||
throw new GoneException(
|
|
||||||
`Grant ${row.grantId} is no longer pending (status: ${grant.status})`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Atomically claim the token BEFORE cert issuance to prevent double-minting.
|
|
||||||
// WHERE used_at IS NULL ensures only one concurrent request wins.
|
|
||||||
// Using .returning() works on both node-postgres and PGlite without rowCount inspection.
|
|
||||||
const claimed = await this.db
|
|
||||||
.update(federationEnrollmentTokens)
|
|
||||||
.set({ usedAt: sql`NOW()` })
|
|
||||||
.where(
|
|
||||||
and(eq(federationEnrollmentTokens.token, token), isNull(federationEnrollmentTokens.usedAt)),
|
|
||||||
)
|
|
||||||
.returning({ token: federationEnrollmentTokens.token });
|
|
||||||
|
|
||||||
if (claimed.length === 0) {
|
|
||||||
throw new GoneException('Enrollment token has already been used (concurrent request)');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. Issue certificate via CaService (network call — outside any transaction).
|
|
||||||
// If this throws, the token is already consumed. The grant stays pending.
|
|
||||||
// Admin must revoke the grant and create a new one.
|
|
||||||
let issued;
|
|
||||||
try {
|
|
||||||
issued = await this.caService.issueCert({
|
|
||||||
csrPem,
|
|
||||||
grantId: row.grantId,
|
|
||||||
subjectUserId: grant.subjectUserId,
|
|
||||||
ttlSeconds: 300,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error(
|
|
||||||
`issueCert failed after token ${token} was claimed — grant ${row.grantId} is stranded pending`,
|
|
||||||
err instanceof Error ? err.stack : String(err),
|
|
||||||
);
|
|
||||||
if (err instanceof FederationScopeError) {
|
|
||||||
throw new BadRequestException((err as Error).message);
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7. Atomically activate grant, update peer record, and write audit log.
|
|
||||||
const certNotAfter = this.extractCertNotAfter(issued.certPem);
|
|
||||||
await this.db.transaction(async (tx) => {
|
|
||||||
await tx
|
|
||||||
.update(federationGrants)
|
|
||||||
.set({ status: 'active' })
|
|
||||||
.where(eq(federationGrants.id, row.grantId));
|
|
||||||
|
|
||||||
await tx
|
|
||||||
.update(federationPeers)
|
|
||||||
.set({
|
|
||||||
certPem: issued.certPem,
|
|
||||||
certSerial: issued.serialNumber,
|
|
||||||
certNotAfter,
|
|
||||||
state: 'active',
|
|
||||||
})
|
|
||||||
.where(eq(federationPeers.id, row.peerId));
|
|
||||||
|
|
||||||
await tx.insert(federationAuditLog).values({
|
|
||||||
requestId: crypto.randomUUID(),
|
|
||||||
peerId: row.peerId,
|
|
||||||
grantId: row.grantId,
|
|
||||||
verb: 'enrollment',
|
|
||||||
resource: 'federation_grant',
|
|
||||||
statusCode: 200,
|
|
||||||
outcome: 'allowed',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log(
|
|
||||||
`Enrollment complete — peerId=${row.peerId} grantId=${row.grantId} serial=${issued.serialNumber}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 8. Return cert material
|
|
||||||
return {
|
|
||||||
certPem: issued.certPem,
|
|
||||||
certChainPem: issued.certChainPem,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract the notAfter date from a PEM certificate.
|
|
||||||
* Falls back to 90 days from now if parsing fails.
|
|
||||||
*/
|
|
||||||
private extractCertNotAfter(certPem: string): Date {
|
|
||||||
try {
|
|
||||||
const cert = new X509Certificate(certPem);
|
|
||||||
return new Date(cert.validTo);
|
|
||||||
} catch {
|
|
||||||
// Fallback: 90 days from now
|
|
||||||
return new Date(Date.now() + 90 * 24 * 60 * 60 * 1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
/**
|
|
||||||
* DTOs for the federation admin controller (FED-M2-08).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { IsInt, IsNotEmpty, IsOptional, IsString, IsUrl, Max, Min } from 'class-validator';
|
|
||||||
|
|
||||||
export class CreatePeerKeypairDto {
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
commonName!: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
displayName!: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsUrl()
|
|
||||||
endpointUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class StorePeerCertDto {
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
certPem!: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class GenerateEnrollmentTokenDto {
|
|
||||||
@IsOptional()
|
|
||||||
@IsInt()
|
|
||||||
@Min(60)
|
|
||||||
@Max(900)
|
|
||||||
ttlSeconds: number = 900;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RevokeGrantBodyDto {
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
reason?: string;
|
|
||||||
}
|
|
||||||
@@ -1,266 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +1,8 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AdminGuard } from '../admin/admin.guard.js';
|
|
||||||
import { CaService } from './ca.service.js';
|
import { CaService } from './ca.service.js';
|
||||||
import { EnrollmentController } from './enrollment.controller.js';
|
|
||||||
import { EnrollmentService } from './enrollment.service.js';
|
|
||||||
import { FederationController } from './federation.controller.js';
|
|
||||||
import { GrantsService } from './grants.service.js';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [EnrollmentController, FederationController],
|
providers: [CaService],
|
||||||
providers: [AdminGuard, CaService, EnrollmentService, GrantsService],
|
exports: [CaService],
|
||||||
exports: [CaService, EnrollmentService, GrantsService],
|
|
||||||
})
|
})
|
||||||
export class FederationModule {}
|
export class FederationModule {}
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
import { IsDateString, IsIn, IsObject, IsOptional, IsString, IsUUID } from 'class-validator';
|
|
||||||
|
|
||||||
export class CreateGrantDto {
|
|
||||||
@IsUUID()
|
|
||||||
peerId!: string;
|
|
||||||
|
|
||||||
@IsUUID()
|
|
||||||
subjectUserId!: string;
|
|
||||||
|
|
||||||
@IsObject()
|
|
||||||
scope!: Record<string, unknown>;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsDateString()
|
|
||||||
expiresAt?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ListGrantsDto {
|
|
||||||
@IsOptional()
|
|
||||||
@IsUUID()
|
|
||||||
peerId?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsUUID()
|
|
||||||
subjectUserId?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsIn(['pending', 'active', 'revoked', 'expired'])
|
|
||||||
status?: 'pending' | 'active' | 'revoked' | 'expired';
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RevokeGrantDto {
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
reason?: string;
|
|
||||||
}
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
/**
|
|
||||||
* Federation grants service — CRUD + status transitions (FED-M2-06).
|
|
||||||
*
|
|
||||||
* Business logic only. CSR/cert work is handled by M2-07.
|
|
||||||
*
|
|
||||||
* Status lifecycle:
|
|
||||||
* pending → active (activateGrant, called by M2-07 enrollment controller after cert signed)
|
|
||||||
* active → revoked (revokeGrant)
|
|
||||||
* active → expired (expireGrant, called by M6 scheduler)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
|
||||||
import { type Db, and, eq, federationGrants } from '@mosaicstack/db';
|
|
||||||
import { DB } from '../database/database.module.js';
|
|
||||||
import { parseFederationScope } from './scope-schema.js';
|
|
||||||
import type { CreateGrantDto, ListGrantsDto } from './grants.dto.js';
|
|
||||||
|
|
||||||
export type Grant = typeof federationGrants.$inferSelect;
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class GrantsService {
|
|
||||||
constructor(@Inject(DB) private readonly db: Db) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new grant in `pending` state.
|
|
||||||
* Validates the scope against the federation scope JSON schema before inserting.
|
|
||||||
*/
|
|
||||||
async createGrant(dto: CreateGrantDto): Promise<Grant> {
|
|
||||||
// Throws FederationScopeError (a plain Error subclass) on invalid scope.
|
|
||||||
parseFederationScope(dto.scope);
|
|
||||||
|
|
||||||
const [grant] = await this.db
|
|
||||||
.insert(federationGrants)
|
|
||||||
.values({
|
|
||||||
peerId: dto.peerId,
|
|
||||||
subjectUserId: dto.subjectUserId,
|
|
||||||
scope: dto.scope,
|
|
||||||
status: 'pending',
|
|
||||||
expiresAt: dto.expiresAt != null ? new Date(dto.expiresAt) : null,
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
return grant!;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch a single grant by ID. Throws NotFoundException if not found.
|
|
||||||
*/
|
|
||||||
async getGrant(id: string): Promise<Grant> {
|
|
||||||
const [grant] = await this.db
|
|
||||||
.select()
|
|
||||||
.from(federationGrants)
|
|
||||||
.where(eq(federationGrants.id, id))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!grant) {
|
|
||||||
throw new NotFoundException(`Grant ${id} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return grant;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List grants with optional filters for peerId, subjectUserId, and status.
|
|
||||||
*/
|
|
||||||
async listGrants(filters: ListGrantsDto): Promise<Grant[]> {
|
|
||||||
const conditions = [];
|
|
||||||
|
|
||||||
if (filters.peerId != null) {
|
|
||||||
conditions.push(eq(federationGrants.peerId, filters.peerId));
|
|
||||||
}
|
|
||||||
if (filters.subjectUserId != null) {
|
|
||||||
conditions.push(eq(federationGrants.subjectUserId, filters.subjectUserId));
|
|
||||||
}
|
|
||||||
if (filters.status != null) {
|
|
||||||
conditions.push(eq(federationGrants.status, filters.status));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (conditions.length === 0) {
|
|
||||||
return this.db.select().from(federationGrants);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.db
|
|
||||||
.select()
|
|
||||||
.from(federationGrants)
|
|
||||||
.where(and(...conditions));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Transition a grant from `pending` → `active`.
|
|
||||||
* Called by M2-07 enrollment controller after cert is signed.
|
|
||||||
* Throws ConflictException if the grant is not in `pending` state.
|
|
||||||
*/
|
|
||||||
async activateGrant(id: string): Promise<Grant> {
|
|
||||||
const grant = await this.getGrant(id);
|
|
||||||
|
|
||||||
if (grant.status !== 'pending') {
|
|
||||||
throw new ConflictException(
|
|
||||||
`Grant ${id} cannot be activated: expected status 'pending', got '${grant.status}'`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [updated] = await this.db
|
|
||||||
.update(federationGrants)
|
|
||||||
.set({ status: 'active' })
|
|
||||||
.where(eq(federationGrants.id, id))
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
return updated!;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Transition a grant from `active` → `revoked`.
|
|
||||||
* Sets revokedAt and optionally revokedReason.
|
|
||||||
* Throws ConflictException if the grant is not in `active` state.
|
|
||||||
*/
|
|
||||||
async revokeGrant(id: string, reason?: string): Promise<Grant> {
|
|
||||||
const grant = await this.getGrant(id);
|
|
||||||
|
|
||||||
if (grant.status !== 'active') {
|
|
||||||
throw new ConflictException(
|
|
||||||
`Grant ${id} cannot be revoked: expected status 'active', got '${grant.status}'`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [updated] = await this.db
|
|
||||||
.update(federationGrants)
|
|
||||||
.set({
|
|
||||||
status: 'revoked',
|
|
||||||
revokedAt: new Date(),
|
|
||||||
revokedReason: reason ?? null,
|
|
||||||
})
|
|
||||||
.where(eq(federationGrants.id, id))
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
return updated!;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Transition a grant from `active` → `expired`.
|
|
||||||
* Intended for use by the M6 scheduler.
|
|
||||||
* Throws ConflictException if the grant is not in `active` state.
|
|
||||||
*/
|
|
||||||
async expireGrant(id: string): Promise<Grant> {
|
|
||||||
const grant = await this.getGrant(id);
|
|
||||||
|
|
||||||
if (grant.status !== 'active') {
|
|
||||||
throw new ConflictException(
|
|
||||||
`Grant ${id} cannot be expired: expected status 'active', got '${grant.status}'`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [updated] = await this.db
|
|
||||||
.update(federationGrants)
|
|
||||||
.set({ status: 'expired' })
|
|
||||||
.where(eq(federationGrants.id, id))
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
return updated!;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { seal, unseal } from '@mosaicstack/auth';
|
|
||||||
|
|
||||||
export function sealClientKey(privateKeyPem: string): string {
|
|
||||||
return seal(privateKeyPem);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function unsealClientKey(sealedKey: string): string {
|
|
||||||
return unseal(sealedKey);
|
|
||||||
}
|
|
||||||
@@ -36,12 +36,6 @@
|
|||||||
# tested locally — gateway boots, imports resolve, tier-detector runs.
|
# tested locally — gateway boots, imports resolve, tier-detector runs.
|
||||||
# Update digest here when promoting a new build.
|
# Update digest here when promoting a new build.
|
||||||
#
|
#
|
||||||
# HEALTHCHECK NOTE (2026-04-21)
|
|
||||||
# Switched from busybox wget to node http.get on 127.0.0.1 (not localhost) to
|
|
||||||
# avoid IPv6 resolution issues on Alpine. Retries increased to 5 and
|
|
||||||
# start_period to 60s to cover the NestJS/GC cold-start window (~40-50s).
|
|
||||||
# restart_policy set to `any` so SIGTERM/clean-exit also triggers restart.
|
|
||||||
#
|
|
||||||
# NOTE: This is a TEST template — production deployments use a separate
|
# NOTE: This is a TEST template — production deployments use a separate
|
||||||
# parameterised template with stricter resource limits and secrets.
|
# parameterised template with stricter resource limits and secrets.
|
||||||
|
|
||||||
@@ -82,7 +76,7 @@ services:
|
|||||||
deploy:
|
deploy:
|
||||||
replicas: 1
|
replicas: 1
|
||||||
restart_policy:
|
restart_policy:
|
||||||
condition: any
|
condition: on-failure
|
||||||
delay: 5s
|
delay: 5s
|
||||||
max_attempts: 3
|
max_attempts: 3
|
||||||
labels:
|
labels:
|
||||||
@@ -94,15 +88,11 @@ services:
|
|||||||
- 'traefik.http.routers.${STACK_NAME}.tls.certresolver=letsencrypt'
|
- 'traefik.http.routers.${STACK_NAME}.tls.certresolver=letsencrypt'
|
||||||
- 'traefik.http.services.${STACK_NAME}.loadbalancer.server.port=3000'
|
- 'traefik.http.services.${STACK_NAME}.loadbalancer.server.port=3000'
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test: ['CMD', 'wget', '-qO-', 'http://localhost:3000/health']
|
||||||
- 'CMD'
|
|
||||||
- 'node'
|
|
||||||
- '-e'
|
|
||||||
- "require('http').get('http://127.0.0.1:3000/health',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"
|
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 3
|
||||||
start_period: 60s
|
start_period: 20s
|
||||||
depends_on:
|
depends_on:
|
||||||
- postgres
|
- postgres
|
||||||
- valkey
|
- valkey
|
||||||
|
|||||||
@@ -118,12 +118,6 @@ docker compose -f docker-compose.federated.yml logs valkey-federated
|
|||||||
|
|
||||||
If Valkey is running, verify your firewall allows 6380. On macOS, Docker Desktop may require binding to `host.docker.internal` instead of `localhost`.
|
If Valkey is running, verify your firewall allows 6380. On macOS, Docker Desktop may require binding to `host.docker.internal` instead of `localhost`.
|
||||||
|
|
||||||
## Key rotation (deferred)
|
|
||||||
|
|
||||||
Federation peer private keys (`federation_peers.client_key_pem`) are sealed at rest using AES-256-GCM with a key derived from `BETTER_AUTH_SECRET` via SHA-256. If `BETTER_AUTH_SECRET` is rotated, all sealed `client_key_pem` values in the database become unreadable and must be re-sealed with the new key before rotation completes.
|
|
||||||
|
|
||||||
The full key rotation procedure (decrypt all rows with old key, re-encrypt with new key, atomically swap the secret) is out of scope for M2. Operators must not rotate `BETTER_AUTH_SECRET` without a migration plan for all sealed federation peer keys.
|
|
||||||
|
|
||||||
## OID Assignments — Mosaic Internal OID Arc
|
## OID Assignments — Mosaic Internal OID Arc
|
||||||
|
|
||||||
Mosaic uses the private enterprise arc `1.3.6.1.4.1.99999` for custom X.509
|
Mosaic uses the private enterprise arc `1.3.6.1.4.1.99999` for custom X.509
|
||||||
|
|||||||
@@ -39,12 +39,12 @@
|
|||||||
{
|
{
|
||||||
"id": "1.3.6.1.4.1.99999.1",
|
"id": "1.3.6.1.4.1.99999.1",
|
||||||
"critical": false,
|
"critical": false,
|
||||||
"value": "{{ printf "\x0c%c%s" (len .Token.mosaic_grant_id) .Token.mosaic_grant_id | b64enc }}"
|
"value": "{{ printf "\x0c\x24%s" .Token.mosaic_grant_id | b64enc }}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "1.3.6.1.4.1.99999.2",
|
"id": "1.3.6.1.4.1.99999.2",
|
||||||
"critical": false,
|
"critical": false,
|
||||||
"value": "{{ printf "\x0c%c%s" (len .Token.mosaic_subject_user_id) .Token.mosaic_subject_user_id | b64enc }}"
|
"value": "{{ printf "\x0c\x24%s" .Token.mosaic_subject_user_id | b64enc }}"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|||||||
@@ -10,4 +10,3 @@ export {
|
|||||||
type SsoTeamSyncConfig,
|
type SsoTeamSyncConfig,
|
||||||
type SupportedSsoProviderId,
|
type SupportedSsoProviderId,
|
||||||
} from './sso.js';
|
} from './sso.js';
|
||||||
export { seal, unseal } from './seal.js';
|
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
|
|
||||||
|
|
||||||
const ALGORITHM = 'aes-256-gcm';
|
|
||||||
const IV_LENGTH = 12; // 96-bit IV for GCM
|
|
||||||
const TAG_LENGTH = 16; // 128-bit auth tag
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Derive a 32-byte AES-256 key from BETTER_AUTH_SECRET using SHA-256.
|
|
||||||
* Throws if BETTER_AUTH_SECRET is not set.
|
|
||||||
*/
|
|
||||||
function deriveKey(): Buffer {
|
|
||||||
const secret = process.env['BETTER_AUTH_SECRET'];
|
|
||||||
if (!secret) {
|
|
||||||
throw new Error('BETTER_AUTH_SECRET is not set — cannot derive encryption key');
|
|
||||||
}
|
|
||||||
return createHash('sha256').update(secret).digest();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Seal a plaintext string using AES-256-GCM.
|
|
||||||
* Output format: base64(IV || authTag || ciphertext)
|
|
||||||
*/
|
|
||||||
export function seal(plaintext: string): string {
|
|
||||||
const key = deriveKey();
|
|
||||||
const iv = randomBytes(IV_LENGTH);
|
|
||||||
const cipher = createCipheriv(ALGORITHM, key, iv);
|
|
||||||
|
|
||||||
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
||||||
const authTag = cipher.getAuthTag();
|
|
||||||
|
|
||||||
const combined = Buffer.concat([iv, authTag, encrypted]);
|
|
||||||
return combined.toString('base64');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unseal a value sealed by `seal()`.
|
|
||||||
* Throws on authentication failure (tampered data) or if BETTER_AUTH_SECRET is unset.
|
|
||||||
*/
|
|
||||||
export function unseal(encoded: string): string {
|
|
||||||
const key = deriveKey();
|
|
||||||
const combined = Buffer.from(encoded, 'base64');
|
|
||||||
|
|
||||||
const iv = combined.subarray(0, IV_LENGTH);
|
|
||||||
const authTag = combined.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
|
|
||||||
const ciphertext = combined.subarray(IV_LENGTH + TAG_LENGTH);
|
|
||||||
|
|
||||||
const decipher = createDecipheriv(ALGORITHM, key, iv);
|
|
||||||
decipher.setAuthTag(authTag);
|
|
||||||
|
|
||||||
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
||||||
return decrypted.toString('utf8');
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
ALTER TYPE "public"."grant_status" ADD VALUE 'pending' BEFORE 'active';--> statement-breakpoint
|
|
||||||
ALTER TABLE "federation_grants" ALTER COLUMN "status" SET DEFAULT 'pending';
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
CREATE TABLE "federation_enrollment_tokens" (
|
|
||||||
"token" text PRIMARY KEY NOT NULL,
|
|
||||||
"grant_id" uuid NOT NULL,
|
|
||||||
"peer_id" uuid NOT NULL,
|
|
||||||
"expires_at" timestamp with time zone NOT NULL,
|
|
||||||
"used_at" timestamp with time zone,
|
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE "federation_enrollment_tokens" ADD CONSTRAINT "federation_enrollment_tokens_grant_id_federation_grants_id_fk" FOREIGN KEY ("grant_id") REFERENCES "public"."federation_grants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "federation_enrollment_tokens" ADD CONSTRAINT "federation_enrollment_tokens_peer_id_federation_peers_id_fk" FOREIGN KEY ("peer_id") REFERENCES "public"."federation_peers"("id") ON DELETE cascade ON UPDATE no action;
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -64,20 +64,6 @@
|
|||||||
"when": 1776822435828,
|
"when": 1776822435828,
|
||||||
"tag": "0008_smart_lyja",
|
"tag": "0008_smart_lyja",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 9,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1745280000000,
|
|
||||||
"tag": "0009_federation_grant_pending",
|
|
||||||
"breakpoints": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 10,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1745366400000,
|
|
||||||
"tag": "0010_federation_enrollment_tokens",
|
|
||||||
"breakpoints": true
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -17,5 +17,4 @@ export {
|
|||||||
federationPeers,
|
federationPeers,
|
||||||
federationGrants,
|
federationGrants,
|
||||||
federationAuditLog,
|
federationAuditLog,
|
||||||
federationEnrollmentTokens,
|
|
||||||
} from './schema.js';
|
} from './schema.js';
|
||||||
|
|||||||
@@ -604,12 +604,11 @@ export const peerStateEnum = pgEnum('peer_state', ['pending', 'active', 'suspend
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Lifecycle state of a federation grant.
|
* Lifecycle state of a federation grant.
|
||||||
* - pending: created but not yet activated (awaiting cert enrollment, M2-07)
|
|
||||||
* - active: grant is in effect
|
* - active: grant is in effect
|
||||||
* - revoked: manually revoked before expiry
|
* - revoked: manually revoked before expiry
|
||||||
* - expired: natural expiry (expires_at passed)
|
* - expired: natural expiry (expires_at passed)
|
||||||
*/
|
*/
|
||||||
export const grantStatusEnum = pgEnum('grant_status', ['pending', 'active', 'revoked', 'expired']);
|
export const grantStatusEnum = pgEnum('grant_status', ['active', 'revoked', 'expired']);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A registered peer gateway identified by its Step-CA certificate CN.
|
* A registered peer gateway identified by its Step-CA certificate CN.
|
||||||
@@ -697,7 +696,7 @@ export const federationGrants = pgTable(
|
|||||||
scope: jsonb('scope').notNull(),
|
scope: jsonb('scope').notNull(),
|
||||||
|
|
||||||
/** Current grant lifecycle state. */
|
/** Current grant lifecycle state. */
|
||||||
status: grantStatusEnum('status').notNull().default('pending'),
|
status: grantStatusEnum('status').notNull().default('active'),
|
||||||
|
|
||||||
/** Optional hard expiry. NULL means the grant does not expire automatically. */
|
/** Optional hard expiry. NULL means the grant does not expire automatically. */
|
||||||
expiresAt: timestamp('expires_at', { withTimezone: true }),
|
expiresAt: timestamp('expires_at', { withTimezone: true }),
|
||||||
@@ -778,34 +777,3 @@ export const federationAuditLog = pgTable(
|
|||||||
index('federation_audit_log_created_at_idx').on(t.createdAt.desc()),
|
index('federation_audit_log_created_at_idx').on(t.createdAt.desc()),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
|
||||||
* Single-use enrollment tokens — M2-07.
|
|
||||||
*
|
|
||||||
* An admin creates a token (with a TTL) and hands it out-of-band to the
|
|
||||||
* remote peer operator. The peer redeems it exactly once by posting its
|
|
||||||
* CSR to POST /api/federation/enrollment/:token. The token is atomically
|
|
||||||
* marked as used to prevent replay attacks.
|
|
||||||
*/
|
|
||||||
export const federationEnrollmentTokens = pgTable('federation_enrollment_tokens', {
|
|
||||||
/** 32-byte hex token — crypto.randomBytes(32).toString('hex') */
|
|
||||||
token: text('token').primaryKey(),
|
|
||||||
|
|
||||||
/** The federation grant this enrollment activates. */
|
|
||||||
grantId: uuid('grant_id')
|
|
||||||
.notNull()
|
|
||||||
.references(() => federationGrants.id, { onDelete: 'cascade' }),
|
|
||||||
|
|
||||||
/** The peer record that will be updated on successful enrollment. */
|
|
||||||
peerId: uuid('peer_id')
|
|
||||||
.notNull()
|
|
||||||
.references(() => federationPeers.id, { onDelete: 'cascade' }),
|
|
||||||
|
|
||||||
/** Hard expiry — token rejected after this time even if not used. */
|
|
||||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
|
||||||
|
|
||||||
/** NULL until the token is redeemed. Set atomically to prevent replay. */
|
|
||||||
usedAt: timestamp('used_at', { withTimezone: true }),
|
|
||||||
|
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import { registerUninstallCommand } from './commands/uninstall.js';
|
|||||||
// prdy is registered via launch.ts
|
// prdy is registered via launch.ts
|
||||||
import { registerLaunchCommands } from './commands/launch.js';
|
import { registerLaunchCommands } from './commands/launch.js';
|
||||||
import { registerAuthCommand } from './commands/auth.js';
|
import { registerAuthCommand } from './commands/auth.js';
|
||||||
import { registerFederationCommand } from './commands/federation.js';
|
|
||||||
import { registerGatewayCommand } from './commands/gateway.js';
|
import { registerGatewayCommand } from './commands/gateway.js';
|
||||||
import {
|
import {
|
||||||
backgroundUpdateCheck,
|
backgroundUpdateCheck,
|
||||||
@@ -337,10 +336,6 @@ registerAuthCommand(program);
|
|||||||
|
|
||||||
registerGatewayCommand(program);
|
registerGatewayCommand(program);
|
||||||
|
|
||||||
// ─── federation ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
registerFederationCommand(program);
|
|
||||||
|
|
||||||
// ─── agent ─────────────────────────────────────────────────────────────
|
// ─── agent ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
registerAgentCommand(program);
|
registerAgentCommand(program);
|
||||||
|
|||||||
@@ -1,410 +0,0 @@
|
|||||||
/**
|
|
||||||
* `mosaic federation` command group — federation grant + peer management (FED-M2-08).
|
|
||||||
*
|
|
||||||
* All HTTP calls go to the local gateway admin API using an admin token
|
|
||||||
* resolved from CLI options or meta.json.
|
|
||||||
*
|
|
||||||
* Subcommands:
|
|
||||||
* grant create --peer-id <uuid> --user-id <uuid> --scope <json> [--expires-at <iso>]
|
|
||||||
* grant list [--peer-id <uuid>] [--user-id <uuid>] [--status pending|active|revoked|expired]
|
|
||||||
* grant show <id>
|
|
||||||
* grant revoke <id> [--reason <text>]
|
|
||||||
* grant token <id> [--ttl 900]
|
|
||||||
*
|
|
||||||
* peer list
|
|
||||||
* peer add <enrollment-url>
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Command } from 'commander';
|
|
||||||
import { readMeta } from './gateway/daemon.js';
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
interface FedParentOpts {
|
|
||||||
host: string;
|
|
||||||
port: string;
|
|
||||||
token?: string;
|
|
||||||
json?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ResolvedOpts {
|
|
||||||
baseUrl: string;
|
|
||||||
token?: string;
|
|
||||||
json: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function resolveOpts(raw: FedParentOpts): ResolvedOpts {
|
|
||||||
const meta = readMeta();
|
|
||||||
const host = raw.host ?? meta?.host ?? 'localhost';
|
|
||||||
const port = parseInt(raw.port, 10) || meta?.port || 14242;
|
|
||||||
const token = raw.token ?? meta?.adminToken;
|
|
||||||
return {
|
|
||||||
baseUrl: `http://${host}:${port.toString()}`,
|
|
||||||
token,
|
|
||||||
json: raw.json ?? false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function requireToken(opts: ResolvedOpts): string {
|
|
||||||
if (!opts.token) {
|
|
||||||
console.error(
|
|
||||||
'Error: admin token required. Use -t/--token <token> or ensure meta.json has adminToken.',
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
return opts.token;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function apiRequest<T>(
|
|
||||||
opts: ResolvedOpts,
|
|
||||||
method: string,
|
|
||||||
path: string,
|
|
||||||
body?: unknown,
|
|
||||||
): Promise<T> {
|
|
||||||
const token = requireToken(opts);
|
|
||||||
const url = `${opts.baseUrl}${path}`;
|
|
||||||
|
|
||||||
const res = await fetch(url, {
|
|
||||||
method,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const text = await res.text();
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
let message = text;
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(text) as { message?: string };
|
|
||||||
message = parsed.message ?? text;
|
|
||||||
} catch {
|
|
||||||
// use raw text
|
|
||||||
}
|
|
||||||
throw new Error(`HTTP ${res.status.toString()}: ${message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!text) return undefined as unknown as T;
|
|
||||||
return JSON.parse(text) as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
function printJson(data: unknown, useJson: boolean): void {
|
|
||||||
if (useJson) {
|
|
||||||
console.log(JSON.stringify(data, null, 2));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function printTable(rows: Record<string, unknown>[]): void {
|
|
||||||
if (rows.length === 0) {
|
|
||||||
console.log('(none)');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (const row of rows) {
|
|
||||||
for (const [key, val] of Object.entries(row)) {
|
|
||||||
console.log(` ${key}: ${String(val ?? '')}`);
|
|
||||||
}
|
|
||||||
console.log('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Command registration
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export function registerFederationCommand(program: Command): void {
|
|
||||||
const fed = program
|
|
||||||
.command('federation')
|
|
||||||
.alias('fed')
|
|
||||||
.description('Manage federation grants and peers')
|
|
||||||
.option('-h, --host <host>', 'Gateway host', 'localhost')
|
|
||||||
.option('-p, --port <port>', 'Gateway port', '14242')
|
|
||||||
.option('-t, --token <token>', 'Admin token')
|
|
||||||
.option('--json', 'Machine-readable JSON output')
|
|
||||||
.action(() => fed.outputHelp());
|
|
||||||
|
|
||||||
// ─── grant subcommands ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const grant = fed
|
|
||||||
.command('grant')
|
|
||||||
.description('Manage federation grants')
|
|
||||||
.action(() => grant.outputHelp());
|
|
||||||
|
|
||||||
grant
|
|
||||||
.command('create')
|
|
||||||
.description('Create a new federation grant')
|
|
||||||
.requiredOption('--peer-id <uuid>', 'Peer UUID')
|
|
||||||
.requiredOption('--user-id <uuid>', 'Subject user UUID')
|
|
||||||
.requiredOption('--scope <json>', 'Grant scope as JSON string')
|
|
||||||
.option('--expires-at <iso>', 'Optional expiry (ISO 8601)')
|
|
||||||
.action(
|
|
||||||
async (cmdOpts: { peerId: string; userId: string; scope: string; expiresAt?: string }) => {
|
|
||||||
const opts = resolveOpts(fed.opts() as FedParentOpts);
|
|
||||||
try {
|
|
||||||
let scope: Record<string, unknown>;
|
|
||||||
try {
|
|
||||||
scope = JSON.parse(cmdOpts.scope) as Record<string, unknown>;
|
|
||||||
} catch {
|
|
||||||
console.error('Error: --scope must be valid JSON');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const body: Record<string, unknown> = {
|
|
||||||
peerId: cmdOpts.peerId,
|
|
||||||
subjectUserId: cmdOpts.userId,
|
|
||||||
scope,
|
|
||||||
};
|
|
||||||
if (cmdOpts.expiresAt) body['expiresAt'] = cmdOpts.expiresAt;
|
|
||||||
|
|
||||||
const result = await apiRequest<Record<string, unknown>>(
|
|
||||||
opts,
|
|
||||||
'POST',
|
|
||||||
'/api/admin/federation/grants',
|
|
||||||
body,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (opts.json) {
|
|
||||||
printJson(result, true);
|
|
||||||
} else {
|
|
||||||
console.log(`Grant created: ${String(result['id'])}`);
|
|
||||||
console.log(` Peer: ${String(result['peerId'])}`);
|
|
||||||
console.log(` User: ${String(result['subjectUserId'])}`);
|
|
||||||
console.log(` Status: ${String(result['status'])}`);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err instanceof Error ? err.message : String(err));
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
grant
|
|
||||||
.command('list')
|
|
||||||
.description('List federation grants')
|
|
||||||
.option('--peer-id <uuid>', 'Filter by peer UUID')
|
|
||||||
.option('--user-id <uuid>', 'Filter by subject user UUID')
|
|
||||||
.option('--status <status>', 'Filter by status (pending|active|revoked|expired)')
|
|
||||||
.action(async (cmdOpts: { peerId?: string; userId?: string; status?: string }) => {
|
|
||||||
const opts = resolveOpts(fed.opts() as FedParentOpts);
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (cmdOpts.peerId) params.set('peerId', cmdOpts.peerId);
|
|
||||||
if (cmdOpts.userId) params.set('subjectUserId', cmdOpts.userId);
|
|
||||||
if (cmdOpts.status) params.set('status', cmdOpts.status);
|
|
||||||
|
|
||||||
const qs = params.toString() ? `?${params.toString()}` : '';
|
|
||||||
const result = await apiRequest<Record<string, unknown>[]>(
|
|
||||||
opts,
|
|
||||||
'GET',
|
|
||||||
`/api/admin/federation/grants${qs}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (opts.json) {
|
|
||||||
printJson(result, true);
|
|
||||||
} else {
|
|
||||||
console.log(`Grants (${result.length.toString()}):\n`);
|
|
||||||
printTable(result);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err instanceof Error ? err.message : String(err));
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
grant
|
|
||||||
.command('show <id>')
|
|
||||||
.description('Get a single grant by ID')
|
|
||||||
.action(async (id: string) => {
|
|
||||||
const opts = resolveOpts(fed.opts() as FedParentOpts);
|
|
||||||
try {
|
|
||||||
const result = await apiRequest<Record<string, unknown>>(
|
|
||||||
opts,
|
|
||||||
'GET',
|
|
||||||
`/api/admin/federation/grants/${id}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (opts.json) {
|
|
||||||
printJson(result, true);
|
|
||||||
} else {
|
|
||||||
for (const [key, val] of Object.entries(result)) {
|
|
||||||
console.log(` ${key}: ${String(val ?? '')}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err instanceof Error ? err.message : String(err));
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
grant
|
|
||||||
.command('revoke <id>')
|
|
||||||
.description('Revoke an active grant')
|
|
||||||
.option('--reason <text>', 'Revocation reason')
|
|
||||||
.action(async (id: string, cmdOpts: { reason?: string }) => {
|
|
||||||
const opts = resolveOpts(fed.opts() as FedParentOpts);
|
|
||||||
try {
|
|
||||||
const body: Record<string, unknown> = {};
|
|
||||||
if (cmdOpts.reason) body['reason'] = cmdOpts.reason;
|
|
||||||
|
|
||||||
const result = await apiRequest<Record<string, unknown>>(
|
|
||||||
opts,
|
|
||||||
'PATCH',
|
|
||||||
`/api/admin/federation/grants/${id}/revoke`,
|
|
||||||
body,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (opts.json) {
|
|
||||||
printJson(result, true);
|
|
||||||
} else {
|
|
||||||
console.log(`Grant ${id} revoked.`);
|
|
||||||
if (result['revokedReason']) console.log(` Reason: ${String(result['revokedReason'])}`);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err instanceof Error ? err.message : String(err));
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
grant
|
|
||||||
.command('token <id>')
|
|
||||||
.description('Generate a single-use enrollment token for a grant')
|
|
||||||
.option('--ttl <seconds>', 'Token lifetime in seconds (60-900)', '900')
|
|
||||||
.action(async (id: string, cmdOpts: { ttl: string }) => {
|
|
||||||
const opts = resolveOpts(fed.opts() as FedParentOpts);
|
|
||||||
try {
|
|
||||||
const ttlSeconds = parseInt(cmdOpts.ttl, 10) || 900;
|
|
||||||
const result = await apiRequest<{
|
|
||||||
token: string;
|
|
||||||
expiresAt: string;
|
|
||||||
enrollmentUrl: string;
|
|
||||||
}>(opts, 'POST', `/api/admin/federation/grants/${id}/tokens`, { ttlSeconds });
|
|
||||||
|
|
||||||
if (opts.json) {
|
|
||||||
printJson(result, true);
|
|
||||||
} else {
|
|
||||||
console.log('Enrollment token generated:');
|
|
||||||
console.log(` Token: ${result.token}`);
|
|
||||||
console.log(` Expires at: ${result.expiresAt}`);
|
|
||||||
console.log(` Enrollment URL: ${result.enrollmentUrl}`);
|
|
||||||
console.log('');
|
|
||||||
console.log('Share the enrollment URL with the remote peer operator.');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err instanceof Error ? err.message : String(err));
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── peer subcommands ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const peer = fed
|
|
||||||
.command('peer')
|
|
||||||
.description('Manage federation peers')
|
|
||||||
.action(() => peer.outputHelp());
|
|
||||||
|
|
||||||
peer
|
|
||||||
.command('list')
|
|
||||||
.description('List all federation peers')
|
|
||||||
.action(async () => {
|
|
||||||
const opts = resolveOpts(fed.opts() as FedParentOpts);
|
|
||||||
try {
|
|
||||||
const result = await apiRequest<Record<string, unknown>[]>(
|
|
||||||
opts,
|
|
||||||
'GET',
|
|
||||||
'/api/admin/federation/peers',
|
|
||||||
);
|
|
||||||
|
|
||||||
if (opts.json) {
|
|
||||||
printJson(result, true);
|
|
||||||
} else {
|
|
||||||
console.log(`Peers (${result.length.toString()}):\n`);
|
|
||||||
printTable(result);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err instanceof Error ? err.message : String(err));
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
peer
|
|
||||||
.command('add <enrollment-url>')
|
|
||||||
.description('Enroll as a peer using a remote enrollment URL')
|
|
||||||
.action(async (enrollmentUrl: string) => {
|
|
||||||
const opts = resolveOpts(fed.opts() as FedParentOpts);
|
|
||||||
try {
|
|
||||||
// 1. Validate enrollment URL
|
|
||||||
let parsedUrl: URL;
|
|
||||||
try {
|
|
||||||
parsedUrl = new URL(enrollmentUrl);
|
|
||||||
} catch {
|
|
||||||
console.error(`Error: invalid enrollment URL: ${enrollmentUrl}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parsedUrl.protocol !== 'https:' && parsedUrl.protocol !== 'http:') {
|
|
||||||
console.error('Error: enrollment URL must use http or https');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const hostname = parsedUrl.hostname;
|
|
||||||
const commonName = hostname.replace(/\./g, '-');
|
|
||||||
|
|
||||||
console.log(`Enrolling as peer with remote: ${enrollmentUrl}`);
|
|
||||||
console.log(` Common name: ${commonName}`);
|
|
||||||
|
|
||||||
// 2. Generate key pair and CSR via local gateway
|
|
||||||
console.log('Generating key pair and CSR...');
|
|
||||||
const keypairResult = await apiRequest<{ peerId: string; csrPem: string }>(
|
|
||||||
opts,
|
|
||||||
'POST',
|
|
||||||
'/api/admin/federation/peers/keypair',
|
|
||||||
{ commonName, displayName: hostname },
|
|
||||||
);
|
|
||||||
|
|
||||||
const { peerId, csrPem } = keypairResult;
|
|
||||||
console.log(` Peer ID: ${peerId}`);
|
|
||||||
|
|
||||||
// 3. Submit CSR to remote enrollment endpoint
|
|
||||||
console.log('Submitting CSR to remote enrollment endpoint...');
|
|
||||||
const remoteRes = await fetch(enrollmentUrl, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ csrPem }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!remoteRes.ok) {
|
|
||||||
const errText = await remoteRes.text();
|
|
||||||
throw new Error(`Remote enrollment failed (${remoteRes.status.toString()}): ${errText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const remoteResult = (await remoteRes.json()) as { certPem: string; certChainPem: string };
|
|
||||||
|
|
||||||
if (!remoteResult.certPem) {
|
|
||||||
throw new Error('Remote enrollment response missing certPem');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Store the signed certificate in the local gateway
|
|
||||||
console.log('Storing signed certificate...');
|
|
||||||
await apiRequest<Record<string, unknown>>(
|
|
||||||
opts,
|
|
||||||
'PATCH',
|
|
||||||
`/api/admin/federation/peers/${peerId}/cert`,
|
|
||||||
{ certPem: remoteResult.certPem },
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log(`\nPeer enrolled successfully.`);
|
|
||||||
console.log(` ID: ${peerId}`);
|
|
||||||
console.log(` State: active`);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err instanceof Error ? err.message : String(err));
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
267
pnpm-lock.yaml
generated
267
pnpm-lock.yaml
generated
@@ -131,9 +131,6 @@ importers:
|
|||||||
'@opentelemetry/semantic-conventions':
|
'@opentelemetry/semantic-conventions':
|
||||||
specifier: ^1.40.0
|
specifier: ^1.40.0
|
||||||
version: 1.40.0
|
version: 1.40.0
|
||||||
'@peculiar/x509':
|
|
||||||
specifier: ^2.0.0
|
|
||||||
version: 2.0.0
|
|
||||||
'@sinclair/typebox':
|
'@sinclair/typebox':
|
||||||
specifier: ^0.34.48
|
specifier: ^0.34.48
|
||||||
version: 0.34.48
|
version: 0.34.48
|
||||||
@@ -158,9 +155,6 @@ importers:
|
|||||||
ioredis:
|
ioredis:
|
||||||
specifier: ^5.10.0
|
specifier: ^5.10.0
|
||||||
version: 5.10.0
|
version: 5.10.0
|
||||||
jose:
|
|
||||||
specifier: ^6.2.2
|
|
||||||
version: 6.2.2
|
|
||||||
node-cron:
|
node-cron:
|
||||||
specifier: ^4.2.1
|
specifier: ^4.2.1
|
||||||
version: 4.2.1
|
version: 4.2.1
|
||||||
@@ -710,10 +704,10 @@ importers:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@mariozechner/pi-agent-core':
|
'@mariozechner/pi-agent-core':
|
||||||
specifier: ^0.63.1
|
specifier: ^0.63.1
|
||||||
version: 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
|
version: 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@3.25.76)
|
||||||
'@mariozechner/pi-ai':
|
'@mariozechner/pi-ai':
|
||||||
specifier: ^0.63.1
|
specifier: ^0.63.1
|
||||||
version: 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
|
version: 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@3.25.76)
|
||||||
'@sinclair/typebox':
|
'@sinclair/typebox':
|
||||||
specifier: ^0.34.41
|
specifier: ^0.34.41
|
||||||
version: 0.34.48
|
version: 0.34.48
|
||||||
@@ -3066,40 +3060,6 @@ packages:
|
|||||||
'@paralleldrive/cuid2@2.3.1':
|
'@paralleldrive/cuid2@2.3.1':
|
||||||
resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==}
|
resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==}
|
||||||
|
|
||||||
'@peculiar/asn1-cms@2.6.1':
|
|
||||||
resolution: {integrity: sha512-vdG4fBF6Lkirkcl53q6eOdn3XYKt+kJTG59edgRZORlg/3atWWEReRCx5rYE1ZzTTX6vLK5zDMjHh7vbrcXGtw==}
|
|
||||||
|
|
||||||
'@peculiar/asn1-csr@2.6.1':
|
|
||||||
resolution: {integrity: sha512-WRWnKfIocHyzFYQTka8O/tXCiBquAPSrRjXbOkHbO4qdmS6loffCEGs+rby6WxxGdJCuunnhS2duHURhjyio6w==}
|
|
||||||
|
|
||||||
'@peculiar/asn1-ecc@2.6.1':
|
|
||||||
resolution: {integrity: sha512-+Vqw8WFxrtDIN5ehUdvlN2m73exS2JVG0UAyfVB31gIfor3zWEAQPD+K9ydCxaj3MLen9k0JhKpu9LqviuCE1g==}
|
|
||||||
|
|
||||||
'@peculiar/asn1-pfx@2.6.1':
|
|
||||||
resolution: {integrity: sha512-nB5jVQy3MAAWvq0KY0R2JUZG8bO/bTLpnwyOzXyEh/e54ynGTatAR+csOnXkkVD9AFZ2uL8Z7EV918+qB1qDvw==}
|
|
||||||
|
|
||||||
'@peculiar/asn1-pkcs8@2.6.1':
|
|
||||||
resolution: {integrity: sha512-JB5iQ9Izn5yGMw3ZG4Nw3Xn/hb/G38GYF3lf7WmJb8JZUydhVGEjK/ZlFSWhnlB7K/4oqEs8HnfFIKklhR58Tw==}
|
|
||||||
|
|
||||||
'@peculiar/asn1-pkcs9@2.6.1':
|
|
||||||
resolution: {integrity: sha512-5EV8nZoMSxeWmcxWmmcolg22ojZRgJg+Y9MX2fnE2bGRo5KQLqV5IL9kdSQDZxlHz95tHvIq9F//bvL1OeNILw==}
|
|
||||||
|
|
||||||
'@peculiar/asn1-rsa@2.6.1':
|
|
||||||
resolution: {integrity: sha512-1nVMEh46SElUt5CB3RUTV4EG/z7iYc7EoaDY5ECwganibQPkZ/Y2eMsTKB/LeyrUJ+W/tKoD9WUqIy8vB+CEdA==}
|
|
||||||
|
|
||||||
'@peculiar/asn1-schema@2.6.0':
|
|
||||||
resolution: {integrity: sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==}
|
|
||||||
|
|
||||||
'@peculiar/asn1-x509-attr@2.6.1':
|
|
||||||
resolution: {integrity: sha512-tlW6cxoHwgcQghnJwv3YS+9OO1737zgPogZ+CgWRUK4roEwIPzRH4JEiG770xe5HX2ATfCpmX60gurfWIF9dcQ==}
|
|
||||||
|
|
||||||
'@peculiar/asn1-x509@2.6.1':
|
|
||||||
resolution: {integrity: sha512-O9jT5F1A2+t3r7C4VT7LYGXqkGLK7Kj1xFpz7U0isPrubwU5PbDoyYtx6MiGst29yq7pXN5vZbQFKRCP+lLZlA==}
|
|
||||||
|
|
||||||
'@peculiar/x509@2.0.0':
|
|
||||||
resolution: {integrity: sha512-r10lkuy6BNfRmyYdRAfgu6dq0HOmyIV2OLhXWE3gDEPBdX1b8miztJVyX/UxWhLwemNyDP3CLZHpDxDwSY0xaA==}
|
|
||||||
engines: {node: '>=20.0.0'}
|
|
||||||
|
|
||||||
'@pinojs/redact@0.4.0':
|
'@pinojs/redact@0.4.0':
|
||||||
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
|
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
|
||||||
|
|
||||||
@@ -3995,10 +3955,6 @@ packages:
|
|||||||
asap@2.0.6:
|
asap@2.0.6:
|
||||||
resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==}
|
resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==}
|
||||||
|
|
||||||
asn1js@3.0.10:
|
|
||||||
resolution: {integrity: sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==}
|
|
||||||
engines: {node: '>=12.0.0'}
|
|
||||||
|
|
||||||
assertion-error@2.0.1:
|
assertion-error@2.0.1:
|
||||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -5370,9 +5326,6 @@ packages:
|
|||||||
jose@6.2.1:
|
jose@6.2.1:
|
||||||
resolution: {integrity: sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==}
|
resolution: {integrity: sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==}
|
||||||
|
|
||||||
jose@6.2.2:
|
|
||||||
resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==}
|
|
||||||
|
|
||||||
js-tokens@4.0.0:
|
js-tokens@4.0.0:
|
||||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||||
|
|
||||||
@@ -6311,13 +6264,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
pvtsutils@1.3.6:
|
|
||||||
resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==}
|
|
||||||
|
|
||||||
pvutils@1.1.5:
|
|
||||||
resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==}
|
|
||||||
engines: {node: '>=16.0.0'}
|
|
||||||
|
|
||||||
qrcode-terminal@0.12.0:
|
qrcode-terminal@0.12.0:
|
||||||
resolution: {integrity: sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==}
|
resolution: {integrity: sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -6886,9 +6832,6 @@ packages:
|
|||||||
ts-mixer@6.0.4:
|
ts-mixer@6.0.4:
|
||||||
resolution: {integrity: sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==}
|
resolution: {integrity: sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==}
|
||||||
|
|
||||||
tslib@1.14.1:
|
|
||||||
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
|
|
||||||
|
|
||||||
tslib@2.8.1:
|
tslib@2.8.1:
|
||||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||||
|
|
||||||
@@ -6901,10 +6844,6 @@ packages:
|
|||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
tsyringe@4.10.0:
|
|
||||||
resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==}
|
|
||||||
engines: {node: '>= 6.0.0'}
|
|
||||||
|
|
||||||
tunnel-agent@0.6.0:
|
tunnel-agent@0.6.0:
|
||||||
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
|
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
|
||||||
|
|
||||||
@@ -7326,6 +7265,12 @@ snapshots:
|
|||||||
'@jridgewell/gen-mapping': 0.3.13
|
'@jridgewell/gen-mapping': 0.3.13
|
||||||
'@jridgewell/trace-mapping': 0.3.31
|
'@jridgewell/trace-mapping': 0.3.31
|
||||||
|
|
||||||
|
'@anthropic-ai/sdk@0.73.0(zod@3.25.76)':
|
||||||
|
dependencies:
|
||||||
|
json-schema-to-ts: 3.1.1
|
||||||
|
optionalDependencies:
|
||||||
|
zod: 3.25.76
|
||||||
|
|
||||||
'@anthropic-ai/sdk@0.73.0(zod@4.3.6)':
|
'@anthropic-ai/sdk@0.73.0(zod@4.3.6)':
|
||||||
dependencies:
|
dependencies:
|
||||||
json-schema-to-ts: 3.1.1
|
json-schema-to-ts: 3.1.1
|
||||||
@@ -7820,49 +7765,49 @@ snapshots:
|
|||||||
|
|
||||||
'@bcoe/v8-coverage@0.2.3': {}
|
'@bcoe/v8-coverage@0.2.3': {}
|
||||||
|
|
||||||
'@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1)':
|
'@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@better-auth/utils': 0.3.1
|
'@better-auth/utils': 0.3.1
|
||||||
'@better-fetch/fetch': 1.1.21
|
'@better-fetch/fetch': 1.1.21
|
||||||
'@standard-schema/spec': 1.1.0
|
'@standard-schema/spec': 1.1.0
|
||||||
better-call: 1.3.2(zod@4.3.6)
|
better-call: 1.3.2(zod@4.3.6)
|
||||||
jose: 6.2.2
|
jose: 6.2.1
|
||||||
kysely: 0.28.11
|
kysely: 0.28.11
|
||||||
nanostores: 1.1.1
|
nanostores: 1.1.1
|
||||||
zod: 4.3.6
|
zod: 4.3.6
|
||||||
|
|
||||||
'@better-auth/drizzle-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.45.1(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.8.0)(kysely@0.28.11)(postgres@3.4.8))':
|
'@better-auth/drizzle-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.45.1(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.8.0)(kysely@0.28.11)(postgres@3.4.8))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1)
|
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1)
|
||||||
'@better-auth/utils': 0.3.1
|
'@better-auth/utils': 0.3.1
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
drizzle-orm: 0.45.1(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.8.0)(kysely@0.28.11)(postgres@3.4.8)
|
drizzle-orm: 0.45.1(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.8.0)(kysely@0.28.11)(postgres@3.4.8)
|
||||||
|
|
||||||
'@better-auth/kysely-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.11)':
|
'@better-auth/kysely-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.11)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1)
|
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1)
|
||||||
'@better-auth/utils': 0.3.1
|
'@better-auth/utils': 0.3.1
|
||||||
kysely: 0.28.11
|
kysely: 0.28.11
|
||||||
|
|
||||||
'@better-auth/memory-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)':
|
'@better-auth/memory-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1)
|
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1)
|
||||||
'@better-auth/utils': 0.3.1
|
'@better-auth/utils': 0.3.1
|
||||||
|
|
||||||
'@better-auth/mongo-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(mongodb@7.1.0(socks@2.8.7))':
|
'@better-auth/mongo-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(mongodb@7.1.0(socks@2.8.7))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1)
|
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1)
|
||||||
'@better-auth/utils': 0.3.1
|
'@better-auth/utils': 0.3.1
|
||||||
mongodb: 7.1.0(socks@2.8.7)
|
mongodb: 7.1.0(socks@2.8.7)
|
||||||
|
|
||||||
'@better-auth/prisma-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)':
|
'@better-auth/prisma-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1)
|
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1)
|
||||||
'@better-auth/utils': 0.3.1
|
'@better-auth/utils': 0.3.1
|
||||||
|
|
||||||
'@better-auth/telemetry@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1))':
|
'@better-auth/telemetry@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1)
|
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1)
|
||||||
'@better-auth/utils': 0.3.1
|
'@better-auth/utils': 0.3.1
|
||||||
'@better-fetch/fetch': 1.1.21
|
'@better-fetch/fetch': 1.1.21
|
||||||
|
|
||||||
@@ -8667,6 +8612,18 @@ snapshots:
|
|||||||
- ws
|
- ws
|
||||||
- zod
|
- zod
|
||||||
|
|
||||||
|
'@mariozechner/pi-agent-core@0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@3.25.76)':
|
||||||
|
dependencies:
|
||||||
|
'@mariozechner/pi-ai': 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@3.25.76)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@modelcontextprotocol/sdk'
|
||||||
|
- aws-crt
|
||||||
|
- bufferutil
|
||||||
|
- supports-color
|
||||||
|
- utf-8-validate
|
||||||
|
- ws
|
||||||
|
- zod
|
||||||
|
|
||||||
'@mariozechner/pi-agent-core@0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)':
|
'@mariozechner/pi-agent-core@0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@mariozechner/pi-ai': 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
|
'@mariozechner/pi-ai': 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
|
||||||
@@ -8715,6 +8672,30 @@ snapshots:
|
|||||||
- ws
|
- ws
|
||||||
- zod
|
- zod
|
||||||
|
|
||||||
|
'@mariozechner/pi-ai@0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@3.25.76)':
|
||||||
|
dependencies:
|
||||||
|
'@anthropic-ai/sdk': 0.73.0(zod@3.25.76)
|
||||||
|
'@aws-sdk/client-bedrock-runtime': 3.1008.0
|
||||||
|
'@google/genai': 1.45.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))
|
||||||
|
'@mistralai/mistralai': 1.14.1
|
||||||
|
'@sinclair/typebox': 0.34.48
|
||||||
|
ajv: 8.18.0
|
||||||
|
ajv-formats: 3.0.1(ajv@8.18.0)
|
||||||
|
chalk: 5.6.2
|
||||||
|
openai: 6.26.0(ws@8.20.0)(zod@3.25.76)
|
||||||
|
partial-json: 0.1.7
|
||||||
|
proxy-agent: 6.5.0
|
||||||
|
undici: 7.24.3
|
||||||
|
zod-to-json-schema: 3.25.1(zod@3.25.76)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@modelcontextprotocol/sdk'
|
||||||
|
- aws-crt
|
||||||
|
- bufferutil
|
||||||
|
- supports-color
|
||||||
|
- utf-8-validate
|
||||||
|
- ws
|
||||||
|
- zod
|
||||||
|
|
||||||
'@mariozechner/pi-ai@0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)':
|
'@mariozechner/pi-ai@0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@anthropic-ai/sdk': 0.73.0(zod@4.3.6)
|
'@anthropic-ai/sdk': 0.73.0(zod@4.3.6)
|
||||||
@@ -8881,7 +8862,7 @@ snapshots:
|
|||||||
express: 5.2.1
|
express: 5.2.1
|
||||||
express-rate-limit: 8.3.1(express@5.2.1)
|
express-rate-limit: 8.3.1(express@5.2.1)
|
||||||
hono: 4.12.8
|
hono: 4.12.8
|
||||||
jose: 6.2.2
|
jose: 6.2.1
|
||||||
json-schema-typed: 8.0.2
|
json-schema-typed: 8.0.2
|
||||||
pkce-challenge: 5.0.1
|
pkce-challenge: 5.0.1
|
||||||
raw-body: 3.0.2
|
raw-body: 3.0.2
|
||||||
@@ -10011,95 +9992,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@noble/hashes': 1.8.0
|
'@noble/hashes': 1.8.0
|
||||||
|
|
||||||
'@peculiar/asn1-cms@2.6.1':
|
|
||||||
dependencies:
|
|
||||||
'@peculiar/asn1-schema': 2.6.0
|
|
||||||
'@peculiar/asn1-x509': 2.6.1
|
|
||||||
'@peculiar/asn1-x509-attr': 2.6.1
|
|
||||||
asn1js: 3.0.10
|
|
||||||
tslib: 2.8.1
|
|
||||||
|
|
||||||
'@peculiar/asn1-csr@2.6.1':
|
|
||||||
dependencies:
|
|
||||||
'@peculiar/asn1-schema': 2.6.0
|
|
||||||
'@peculiar/asn1-x509': 2.6.1
|
|
||||||
asn1js: 3.0.10
|
|
||||||
tslib: 2.8.1
|
|
||||||
|
|
||||||
'@peculiar/asn1-ecc@2.6.1':
|
|
||||||
dependencies:
|
|
||||||
'@peculiar/asn1-schema': 2.6.0
|
|
||||||
'@peculiar/asn1-x509': 2.6.1
|
|
||||||
asn1js: 3.0.10
|
|
||||||
tslib: 2.8.1
|
|
||||||
|
|
||||||
'@peculiar/asn1-pfx@2.6.1':
|
|
||||||
dependencies:
|
|
||||||
'@peculiar/asn1-cms': 2.6.1
|
|
||||||
'@peculiar/asn1-pkcs8': 2.6.1
|
|
||||||
'@peculiar/asn1-rsa': 2.6.1
|
|
||||||
'@peculiar/asn1-schema': 2.6.0
|
|
||||||
asn1js: 3.0.10
|
|
||||||
tslib: 2.8.1
|
|
||||||
|
|
||||||
'@peculiar/asn1-pkcs8@2.6.1':
|
|
||||||
dependencies:
|
|
||||||
'@peculiar/asn1-schema': 2.6.0
|
|
||||||
'@peculiar/asn1-x509': 2.6.1
|
|
||||||
asn1js: 3.0.10
|
|
||||||
tslib: 2.8.1
|
|
||||||
|
|
||||||
'@peculiar/asn1-pkcs9@2.6.1':
|
|
||||||
dependencies:
|
|
||||||
'@peculiar/asn1-cms': 2.6.1
|
|
||||||
'@peculiar/asn1-pfx': 2.6.1
|
|
||||||
'@peculiar/asn1-pkcs8': 2.6.1
|
|
||||||
'@peculiar/asn1-schema': 2.6.0
|
|
||||||
'@peculiar/asn1-x509': 2.6.1
|
|
||||||
'@peculiar/asn1-x509-attr': 2.6.1
|
|
||||||
asn1js: 3.0.10
|
|
||||||
tslib: 2.8.1
|
|
||||||
|
|
||||||
'@peculiar/asn1-rsa@2.6.1':
|
|
||||||
dependencies:
|
|
||||||
'@peculiar/asn1-schema': 2.6.0
|
|
||||||
'@peculiar/asn1-x509': 2.6.1
|
|
||||||
asn1js: 3.0.10
|
|
||||||
tslib: 2.8.1
|
|
||||||
|
|
||||||
'@peculiar/asn1-schema@2.6.0':
|
|
||||||
dependencies:
|
|
||||||
asn1js: 3.0.10
|
|
||||||
pvtsutils: 1.3.6
|
|
||||||
tslib: 2.8.1
|
|
||||||
|
|
||||||
'@peculiar/asn1-x509-attr@2.6.1':
|
|
||||||
dependencies:
|
|
||||||
'@peculiar/asn1-schema': 2.6.0
|
|
||||||
'@peculiar/asn1-x509': 2.6.1
|
|
||||||
asn1js: 3.0.10
|
|
||||||
tslib: 2.8.1
|
|
||||||
|
|
||||||
'@peculiar/asn1-x509@2.6.1':
|
|
||||||
dependencies:
|
|
||||||
'@peculiar/asn1-schema': 2.6.0
|
|
||||||
asn1js: 3.0.10
|
|
||||||
pvtsutils: 1.3.6
|
|
||||||
tslib: 2.8.1
|
|
||||||
|
|
||||||
'@peculiar/x509@2.0.0':
|
|
||||||
dependencies:
|
|
||||||
'@peculiar/asn1-cms': 2.6.1
|
|
||||||
'@peculiar/asn1-csr': 2.6.1
|
|
||||||
'@peculiar/asn1-ecc': 2.6.1
|
|
||||||
'@peculiar/asn1-pkcs9': 2.6.1
|
|
||||||
'@peculiar/asn1-rsa': 2.6.1
|
|
||||||
'@peculiar/asn1-schema': 2.6.0
|
|
||||||
'@peculiar/asn1-x509': 2.6.1
|
|
||||||
pvtsutils: 1.3.6
|
|
||||||
tslib: 2.8.1
|
|
||||||
tsyringe: 4.10.0
|
|
||||||
|
|
||||||
'@pinojs/redact@0.4.0': {}
|
'@pinojs/redact@0.4.0': {}
|
||||||
|
|
||||||
'@pkgjs/parseargs@0.11.0':
|
'@pkgjs/parseargs@0.11.0':
|
||||||
@@ -11056,12 +10948,6 @@ snapshots:
|
|||||||
|
|
||||||
asap@2.0.6: {}
|
asap@2.0.6: {}
|
||||||
|
|
||||||
asn1js@3.0.10:
|
|
||||||
dependencies:
|
|
||||||
pvtsutils: 1.3.6
|
|
||||||
pvutils: 1.1.5
|
|
||||||
tslib: 2.8.1
|
|
||||||
|
|
||||||
assertion-error@2.0.1: {}
|
assertion-error@2.0.1: {}
|
||||||
|
|
||||||
ast-types@0.13.4:
|
ast-types@0.13.4:
|
||||||
@@ -11106,20 +10992,20 @@ snapshots:
|
|||||||
|
|
||||||
better-auth@1.5.5(better-sqlite3@12.8.0)(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.8.0)(kysely@0.28.11)(postgres@3.4.8))(mongodb@7.1.0(socks@2.8.7))(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@2.1.9(@types/node@22.19.15)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1)):
|
better-auth@1.5.5(better-sqlite3@12.8.0)(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.8.0)(kysely@0.28.11)(postgres@3.4.8))(mongodb@7.1.0(socks@2.8.7))(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@2.1.9(@types/node@22.19.15)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1)
|
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1)
|
||||||
'@better-auth/drizzle-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.45.1(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.8.0)(kysely@0.28.11)(postgres@3.4.8))
|
'@better-auth/drizzle-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.45.1(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.8.0)(kysely@0.28.11)(postgres@3.4.8))
|
||||||
'@better-auth/kysely-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.11)
|
'@better-auth/kysely-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.11)
|
||||||
'@better-auth/memory-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)
|
'@better-auth/memory-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)
|
||||||
'@better-auth/mongo-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(mongodb@7.1.0(socks@2.8.7))
|
'@better-auth/mongo-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(mongodb@7.1.0(socks@2.8.7))
|
||||||
'@better-auth/prisma-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)
|
'@better-auth/prisma-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)
|
||||||
'@better-auth/telemetry': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1))
|
'@better-auth/telemetry': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1))
|
||||||
'@better-auth/utils': 0.3.1
|
'@better-auth/utils': 0.3.1
|
||||||
'@better-fetch/fetch': 1.1.21
|
'@better-fetch/fetch': 1.1.21
|
||||||
'@noble/ciphers': 2.1.1
|
'@noble/ciphers': 2.1.1
|
||||||
'@noble/hashes': 2.0.1
|
'@noble/hashes': 2.0.1
|
||||||
better-call: 1.3.2(zod@4.3.6)
|
better-call: 1.3.2(zod@4.3.6)
|
||||||
defu: 6.1.4
|
defu: 6.1.4
|
||||||
jose: 6.2.2
|
jose: 6.2.1
|
||||||
kysely: 0.28.11
|
kysely: 0.28.11
|
||||||
nanostores: 1.1.1
|
nanostores: 1.1.1
|
||||||
zod: 4.3.6
|
zod: 4.3.6
|
||||||
@@ -12562,8 +12448,6 @@ snapshots:
|
|||||||
|
|
||||||
jose@6.2.1: {}
|
jose@6.2.1: {}
|
||||||
|
|
||||||
jose@6.2.2: {}
|
|
||||||
|
|
||||||
js-tokens@4.0.0: {}
|
js-tokens@4.0.0: {}
|
||||||
|
|
||||||
js-yaml@4.1.1:
|
js-yaml@4.1.1:
|
||||||
@@ -13307,6 +13191,11 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
mimic-function: 5.0.1
|
mimic-function: 5.0.1
|
||||||
|
|
||||||
|
openai@6.26.0(ws@8.20.0)(zod@3.25.76):
|
||||||
|
optionalDependencies:
|
||||||
|
ws: 8.20.0
|
||||||
|
zod: 3.25.76
|
||||||
|
|
||||||
openai@6.26.0(ws@8.20.0)(zod@4.3.6):
|
openai@6.26.0(ws@8.20.0)(zod@4.3.6):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
ws: 8.20.0
|
ws: 8.20.0
|
||||||
@@ -13659,12 +13548,6 @@ snapshots:
|
|||||||
|
|
||||||
punycode@2.3.1: {}
|
punycode@2.3.1: {}
|
||||||
|
|
||||||
pvtsutils@1.3.6:
|
|
||||||
dependencies:
|
|
||||||
tslib: 2.8.1
|
|
||||||
|
|
||||||
pvutils@1.1.5: {}
|
|
||||||
|
|
||||||
qrcode-terminal@0.12.0: {}
|
qrcode-terminal@0.12.0: {}
|
||||||
|
|
||||||
qs@6.15.0:
|
qs@6.15.0:
|
||||||
@@ -14348,8 +14231,6 @@ snapshots:
|
|||||||
|
|
||||||
ts-mixer@6.0.4: {}
|
ts-mixer@6.0.4: {}
|
||||||
|
|
||||||
tslib@1.14.1: {}
|
|
||||||
|
|
||||||
tslib@2.8.1: {}
|
tslib@2.8.1: {}
|
||||||
|
|
||||||
tslog@4.10.2: {}
|
tslog@4.10.2: {}
|
||||||
@@ -14361,10 +14242,6 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
|
|
||||||
tsyringe@4.10.0:
|
|
||||||
dependencies:
|
|
||||||
tslib: 1.14.1
|
|
||||||
|
|
||||||
tunnel-agent@0.6.0:
|
tunnel-agent@0.6.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
safe-buffer: 5.2.1
|
safe-buffer: 5.2.1
|
||||||
|
|||||||
Reference in New Issue
Block a user