feat(tokens): classify machine credentials
This commit is contained in:
parent
3398d1400f
commit
507f0fdd1f
@ -14,6 +14,8 @@ const baseToken: ApiTokenDoc = {
|
|||||||
userId: 'user_1',
|
userId: 'user_1',
|
||||||
userName: 'Test User',
|
userName: 'Test User',
|
||||||
name: 'Test Token',
|
name: 'Test Token',
|
||||||
|
tokenType: 'user_api',
|
||||||
|
environment: 'prod',
|
||||||
tokenHash: 'hash_abc',
|
tokenHash: 'hash_abc',
|
||||||
prefix: 'lysnr_',
|
prefix: 'lysnr_',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
|
|||||||
@ -23,6 +23,8 @@ const baseToken = {
|
|||||||
userId: 'admin_1',
|
userId: 'admin_1',
|
||||||
userName: 'admin@example.com',
|
userName: 'admin@example.com',
|
||||||
name: 'ci-token',
|
name: 'ci-token',
|
||||||
|
tokenType: 'service_api',
|
||||||
|
environment: 'prod',
|
||||||
prefix: 'wai_12345678',
|
prefix: 'wai_12345678',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
scopes: ['read'],
|
scopes: ['read'],
|
||||||
@ -117,6 +119,12 @@ describe('tokenRoutes', () => {
|
|||||||
const data = JSON.parse(res.body);
|
const data = JSON.parse(res.body);
|
||||||
expect(data).toHaveProperty('rawToken');
|
expect(data).toHaveProperty('rawToken');
|
||||||
expect(data.rawToken.startsWith('wai_')).toBe(true);
|
expect(data.rawToken.startsWith('wai_')).toBe(true);
|
||||||
|
expect(repoMock.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
tokenType: 'user_api',
|
||||||
|
environment: 'prod',
|
||||||
|
})
|
||||||
|
);
|
||||||
expect(repoMock.hashToken).toHaveBeenCalled();
|
expect(repoMock.hashToken).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -50,7 +50,7 @@ export async function tokenRoutes(app: FastifyInstance) {
|
|||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
||||||
}
|
}
|
||||||
const { name, scopes, expiresInDays } = parsed.data;
|
const { name, tokenType, environment, scopes, expiresInDays } = parsed.data;
|
||||||
|
|
||||||
const rawToken = `wai_${crypto.randomBytes(32).toString('hex')}`;
|
const rawToken = `wai_${crypto.randomBytes(32).toString('hex')}`;
|
||||||
const prefix = rawToken.slice(0, 12);
|
const prefix = rawToken.slice(0, 12);
|
||||||
@ -64,6 +64,8 @@ export async function tokenRoutes(app: FastifyInstance) {
|
|||||||
userId: payload.sub,
|
userId: payload.sub,
|
||||||
userName: payload.email ?? '',
|
userName: payload.email ?? '',
|
||||||
name,
|
name,
|
||||||
|
tokenType,
|
||||||
|
environment,
|
||||||
prefix,
|
prefix,
|
||||||
tokenHash,
|
tokenHash,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
|
|||||||
@ -12,6 +12,8 @@ describe('CreateTokenSchema', () => {
|
|||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
expect(result.data.name).toBe('CI Pipeline');
|
expect(result.data.name).toBe('CI Pipeline');
|
||||||
|
expect(result.data.tokenType).toBe('user_api');
|
||||||
|
expect(result.data.environment).toBe('prod');
|
||||||
expect(result.data.scopes).toEqual(['read']);
|
expect(result.data.scopes).toEqual(['read']);
|
||||||
expect(result.data.expiresInDays).toBe(90);
|
expect(result.data.expiresInDays).toBe(90);
|
||||||
}
|
}
|
||||||
@ -20,11 +22,15 @@ describe('CreateTokenSchema', () => {
|
|||||||
it('accepts full input', () => {
|
it('accepts full input', () => {
|
||||||
const result = CreateTokenSchema.safeParse({
|
const result = CreateTokenSchema.safeParse({
|
||||||
name: 'Deploy Key',
|
name: 'Deploy Key',
|
||||||
|
tokenType: 'service_api',
|
||||||
|
environment: 'staging',
|
||||||
scopes: ['read', 'write', 'admin'],
|
scopes: ['read', 'write', 'admin'],
|
||||||
expiresInDays: 30,
|
expiresInDays: 30,
|
||||||
});
|
});
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
expect(result.data.tokenType).toBe('service_api');
|
||||||
|
expect(result.data.environment).toBe('staging');
|
||||||
expect(result.data.scopes).toEqual(['read', 'write', 'admin']);
|
expect(result.data.scopes).toEqual(['read', 'write', 'admin']);
|
||||||
expect(result.data.expiresInDays).toBe(30);
|
expect(result.data.expiresInDays).toBe(30);
|
||||||
}
|
}
|
||||||
@ -102,6 +108,8 @@ describe('ApiTokenDoc type shape', () => {
|
|||||||
userId: 'user_1',
|
userId: 'user_1',
|
||||||
userName: 'admin@example.com',
|
userName: 'admin@example.com',
|
||||||
name: 'CI Token',
|
name: 'CI Token',
|
||||||
|
tokenType: 'service_api',
|
||||||
|
environment: 'prod',
|
||||||
prefix: 'wai_abc12345',
|
prefix: 'wai_abc12345',
|
||||||
tokenHash: '$2a$10$hashedvalue',
|
tokenHash: '$2a$10$hashedvalue',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
@ -113,6 +121,8 @@ describe('ApiTokenDoc type shape', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
expect(doc.status).toBe('active');
|
expect(doc.status).toBe('active');
|
||||||
|
expect(doc.tokenType).toBe('service_api');
|
||||||
|
expect(doc.environment).toBe('prod');
|
||||||
expect(doc.scopes).toContain('read');
|
expect(doc.scopes).toContain('read');
|
||||||
expect(doc.lastUsed).toBeNull();
|
expect(doc.lastUsed).toBeNull();
|
||||||
});
|
});
|
||||||
@ -126,6 +136,8 @@ describe('ApiTokenDoc type shape', () => {
|
|||||||
userId: 'u1',
|
userId: 'u1',
|
||||||
userName: 'test',
|
userName: 'test',
|
||||||
name: 'test',
|
name: 'test',
|
||||||
|
tokenType: 'user_api',
|
||||||
|
environment: 'prod',
|
||||||
prefix: 'wai_',
|
prefix: 'wai_',
|
||||||
tokenHash: 'hash',
|
tokenHash: 'hash',
|
||||||
status,
|
status,
|
||||||
@ -147,6 +159,8 @@ describe('ApiTokenResponse type shape', () => {
|
|||||||
userId: 'u1',
|
userId: 'u1',
|
||||||
userName: 'test@test.com',
|
userName: 'test@test.com',
|
||||||
name: 'My Token',
|
name: 'My Token',
|
||||||
|
tokenType: 'product_api',
|
||||||
|
environment: 'staging',
|
||||||
prefix: 'wai_abc',
|
prefix: 'wai_abc',
|
||||||
tokenHash: 'secret_hash',
|
tokenHash: 'secret_hash',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
@ -157,11 +171,14 @@ describe('ApiTokenResponse type shape', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Simulate what stripHash does
|
// Simulate what stripHash does
|
||||||
const { tokenHash: _hash, ...response } = doc;
|
const { tokenHash: _tokenHash, ...response } = doc;
|
||||||
|
void _tokenHash;
|
||||||
const apiResponse: ApiTokenResponse = response;
|
const apiResponse: ApiTokenResponse = response;
|
||||||
|
|
||||||
expect(apiResponse.id).toBe('tok_1');
|
expect(apiResponse.id).toBe('tok_1');
|
||||||
expect(apiResponse.name).toBe('My Token');
|
expect(apiResponse.name).toBe('My Token');
|
||||||
|
expect(apiResponse.tokenType).toBe('product_api');
|
||||||
|
expect(apiResponse.environment).toBe('staging');
|
||||||
expect('tokenHash' in apiResponse).toBe(false);
|
expect('tokenHash' in apiResponse).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -10,6 +10,8 @@ export interface ApiTokenDoc {
|
|||||||
userId: string;
|
userId: string;
|
||||||
userName: string;
|
userName: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
tokenType: 'user_api' | 'product_api' | 'service_api';
|
||||||
|
environment: 'dev' | 'staging' | 'prod';
|
||||||
prefix: string;
|
prefix: string;
|
||||||
tokenHash: string;
|
tokenHash: string;
|
||||||
status: 'active' | 'revoked' | 'expired';
|
status: 'active' | 'revoked' | 'expired';
|
||||||
@ -24,6 +26,8 @@ export type ApiTokenResponse = Omit<ApiTokenDoc, 'tokenHash'>;
|
|||||||
|
|
||||||
export const CreateTokenSchema = z.object({
|
export const CreateTokenSchema = z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
|
tokenType: z.enum(['user_api', 'product_api', 'service_api']).default('user_api'),
|
||||||
|
environment: z.enum(['dev', 'staging', 'prod']).default('prod'),
|
||||||
scopes: z.array(z.string()).default(['read']),
|
scopes: z.array(z.string()).default(['read']),
|
||||||
expiresInDays: z.number().int().min(1).max(365).default(90),
|
expiresInDays: z.number().int().min(1).max(365).default(90),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -101,7 +101,7 @@ describe('server bootstrap', () => {
|
|||||||
expect(appMock.register).toHaveBeenCalled();
|
expect(appMock.register).toHaveBeenCalled();
|
||||||
expect(appMock.register.mock.calls.length).toBeGreaterThan(15);
|
expect(appMock.register.mock.calls.length).toBeGreaterThan(15);
|
||||||
expect(startServiceMock).toHaveBeenCalledWith(appMock, { port: 4003, host: '0.0.0.0' });
|
expect(startServiceMock).toHaveBeenCalledWith(appMock, { port: 4003, host: '0.0.0.0' });
|
||||||
});
|
}, 15000);
|
||||||
|
|
||||||
it('registers optional jwt parsing with the shared fastify-core helper', async () => {
|
it('registers optional jwt parsing with the shared fastify-core helper', async () => {
|
||||||
await import('./server.js');
|
await import('./server.js');
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user