feat: revocable read-only API keys for machine access
Integrations such as the Wabenhain shop sync no longer need a service account with a password (and without MFA). An admin creates a key under Verwaltung > Automatisierung > API-Schlüssel. - Key format ack_<8>_<43>; only its SHA-256 hash is stored, the key is shown exactly once. Optional expiry, "last used" timestamp, revocation takes effect immediately; create/revoke are audit-logged without the key. - A key is not a user: JwtAuthGuard sets request.apiKey, PermissionsGuard admits it only to routes whose permission is among its scopes and never to user-only routes. Scopes come from a short allowlist (shop-sync:read). - Managing keys requires the new permission api-keys:manage. - Keys are excluded from backups on purpose; a restore means issuing a new key. Additive migration (new table). Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
ede86c062a
commit
445fb3b94b
21 files changed
+848
-5
No files matched your search
@@ -0,0 +1,193 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
import { createTestApp } from './utils/create-test-app';
|
||||
import { login } from './utils/api';
|
||||
|
||||
interface CreatedKeyBody {
|
||||
apiKey: {
|
||||
id: string;
|
||||
name: string;
|
||||
prefix: string;
|
||||
scopes: string[];
|
||||
revokedAt: string | null;
|
||||
};
|
||||
token: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only API keys for machine access (the shop sync): shown once, stored
|
||||
* only as a hash, limited to their scopes, revocable.
|
||||
*/
|
||||
describe('API keys (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
let prisma: PrismaService;
|
||||
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
|
||||
const password = 'SuperSecret123!';
|
||||
let adminToken: string;
|
||||
let staffToken: string;
|
||||
|
||||
const server = () => app.getHttpServer();
|
||||
|
||||
function createKey(body: Record<string, unknown>) {
|
||||
return request(server())
|
||||
.post('/api/v1/api-keys')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send(body);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createTestApp();
|
||||
prisma = app.get(PrismaService);
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
email: `keys-admin-${suffix}@example.com`,
|
||||
passwordHash,
|
||||
name: 'Admin',
|
||||
role: { connect: { key: 'ADMIN' } },
|
||||
},
|
||||
});
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
email: `keys-staff-${suffix}@example.com`,
|
||||
passwordHash,
|
||||
name: 'Staff',
|
||||
role: { connect: { key: 'STAFF' } },
|
||||
},
|
||||
});
|
||||
adminToken = (
|
||||
await login(app, `keys-admin-${suffix}@example.com`, password)
|
||||
).accessToken;
|
||||
staffToken = (
|
||||
await login(app, `keys-staff-${suffix}@example.com`, password)
|
||||
).accessToken;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('shows the key once and stores only a hash', async () => {
|
||||
const response = await createKey({
|
||||
name: `Wabenhain ${suffix}`,
|
||||
scopes: ['shop-sync:read'],
|
||||
}).expect(201);
|
||||
const body = response.body as CreatedKeyBody;
|
||||
|
||||
expect(body.token).toMatch(/^ack_[a-z0-9]{8}_[A-Za-z0-9_-]{43}$/);
|
||||
expect(body.token.startsWith(body.apiKey.prefix)).toBe(true);
|
||||
expect(JSON.stringify(body.apiKey)).not.toContain(body.token);
|
||||
|
||||
const stored = await prisma.apiKey.findUniqueOrThrow({
|
||||
where: { id: body.apiKey.id },
|
||||
});
|
||||
expect(stored.keyHash).not.toContain(body.token);
|
||||
|
||||
const list = await request(server())
|
||||
.get('/api/v1/api-keys')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.expect(200);
|
||||
const raw = JSON.stringify(list.body);
|
||||
expect(raw).not.toContain(body.token);
|
||||
expect(raw).not.toContain('keyHash');
|
||||
});
|
||||
|
||||
it('lets a key read exactly what its scopes allow', async () => {
|
||||
const { token } = (
|
||||
await createKey({ name: `Scope ${suffix}`, scopes: ['shop-sync:read'] })
|
||||
).body as CreatedKeyBody;
|
||||
|
||||
await request(server())
|
||||
.get('/api/v1/shop-sync')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.expect(200);
|
||||
// Not a user: no other data, no user-only routes.
|
||||
await request(server())
|
||||
.get('/api/v1/harvests')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.expect(403);
|
||||
await request(server())
|
||||
.get('/api/v1/auth/me')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('records when a key was last used', async () => {
|
||||
const created = (
|
||||
await createKey({ name: `Used ${suffix}`, scopes: ['shop-sync:read'] })
|
||||
).body as CreatedKeyBody;
|
||||
await request(server())
|
||||
.get('/api/v1/shop-sync')
|
||||
.set('Authorization', `Bearer ${created.token}`)
|
||||
.expect(200);
|
||||
const stored = await prisma.apiKey.findUniqueOrThrow({
|
||||
where: { id: created.apiKey.id },
|
||||
});
|
||||
expect(stored.lastUsedAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it('stops working immediately once revoked', async () => {
|
||||
const created = (
|
||||
await createKey({ name: `Revoke ${suffix}`, scopes: ['shop-sync:read'] })
|
||||
).body as CreatedKeyBody;
|
||||
await request(server())
|
||||
.post(`/api/v1/api-keys/${created.apiKey.id}/revoke`)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.expect(201);
|
||||
await request(server())
|
||||
.get('/api/v1/shop-sync')
|
||||
.set('Authorization', `Bearer ${created.token}`)
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it('rejects an expired key', async () => {
|
||||
const created = (
|
||||
await createKey({
|
||||
name: `Expired ${suffix}`,
|
||||
scopes: ['shop-sync:read'],
|
||||
expiresInDays: 1,
|
||||
})
|
||||
).body as CreatedKeyBody;
|
||||
await prisma.apiKey.update({
|
||||
where: { id: created.apiKey.id },
|
||||
data: { expiresAt: new Date(Date.now() - 1000) },
|
||||
});
|
||||
await request(server())
|
||||
.get('/api/v1/shop-sync')
|
||||
.set('Authorization', `Bearer ${created.token}`)
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it('rejects unknown and tampered keys', async () => {
|
||||
const created = (
|
||||
await createKey({ name: `Tamper ${suffix}`, scopes: ['shop-sync:read'] })
|
||||
).body as CreatedKeyBody;
|
||||
const tampered = `${created.token.slice(0, 20)}${created.token[20] === 'A' ? 'B' : 'A'}${created.token.slice(21)}`;
|
||||
await request(server())
|
||||
.get('/api/v1/shop-sync')
|
||||
.set('Authorization', `Bearer ${tampered}`)
|
||||
.expect(401);
|
||||
await request(server())
|
||||
.get('/api/v1/shop-sync')
|
||||
.set('Authorization', 'Bearer ack_nope')
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it('only allows scopes released for keys', async () => {
|
||||
await createKey({
|
||||
name: `Too much ${suffix}`,
|
||||
scopes: ['harvests:read'],
|
||||
}).expect(400);
|
||||
});
|
||||
|
||||
it('can only be managed with api-keys:manage', async () => {
|
||||
await request(server())
|
||||
.post('/api/v1/api-keys')
|
||||
.set('Authorization', `Bearer ${staffToken}`)
|
||||
.send({ name: 'x', scopes: ['shop-sync:read'] })
|
||||
.expect(403);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user