feat: Phase 1 MVP — complete AgentIdP implementation
Implements all P0 features per OpenSpec change phase-1-mvp-implementation: - Agent Registry Service (CRUD) — full lifecycle management - OAuth 2.0 Token Service (Client Credentials flow) - Credential Management (generate, rotate, revoke) - Immutable Audit Log Service Tech: Node.js 18+, TypeScript 5.3+ strict, Express 4.18+, PostgreSQL 14+, Redis 7+ Standards: OpenAPI 3.0 specs, DRY/SOLID, zero `any` types Quality: 18 unit test suites, 244 tests passing, 97%+ coverage OpenAPI: 4 complete specs (14 endpoints total) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
213
src/services/AgentService.ts
Normal file
213
src/services/AgentService.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Agent Registry Service for SentryAgent.ai AgentIdP.
|
||||
* Business logic for agent lifecycle management.
|
||||
*/
|
||||
|
||||
import { AgentRepository } from '../repositories/AgentRepository.js';
|
||||
import { CredentialRepository } from '../repositories/CredentialRepository.js';
|
||||
import { AuditService } from './AuditService.js';
|
||||
import {
|
||||
IAgent,
|
||||
ICreateAgentRequest,
|
||||
IUpdateAgentRequest,
|
||||
IAgentListFilters,
|
||||
IPaginatedAgentsResponse,
|
||||
} from '../types/index.js';
|
||||
import {
|
||||
AgentNotFoundError,
|
||||
AgentAlreadyExistsError,
|
||||
AgentAlreadyDecommissionedError,
|
||||
FreeTierLimitError,
|
||||
} from '../utils/errors.js';
|
||||
|
||||
const FREE_TIER_MAX_AGENTS = 100;
|
||||
|
||||
/**
|
||||
* Service for agent registration and lifecycle management.
|
||||
* Enforces free-tier limits and coordinates with AuditService.
|
||||
*/
|
||||
export class AgentService {
|
||||
/**
|
||||
* @param agentRepository - The agent data repository.
|
||||
* @param credentialRepository - The credential repository (for decommission cleanup).
|
||||
* @param auditService - The audit log service.
|
||||
*/
|
||||
constructor(
|
||||
private readonly agentRepository: AgentRepository,
|
||||
private readonly credentialRepository: CredentialRepository,
|
||||
private readonly auditService: AuditService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Registers a new AI agent identity.
|
||||
* Enforces the free-tier 100-agent limit and unique email constraint.
|
||||
*
|
||||
* @param data - The agent registration data.
|
||||
* @param ipAddress - Client IP for audit logging.
|
||||
* @param userAgent - Client User-Agent for audit logging.
|
||||
* @returns The newly created agent record.
|
||||
* @throws FreeTierLimitError if the 100-agent limit is reached.
|
||||
* @throws AgentAlreadyExistsError if the email is already registered.
|
||||
*/
|
||||
async registerAgent(
|
||||
data: ICreateAgentRequest,
|
||||
ipAddress: string,
|
||||
userAgent: string,
|
||||
): Promise<IAgent> {
|
||||
// Enforce free-tier agent count limit
|
||||
const currentCount = await this.agentRepository.countActive();
|
||||
if (currentCount >= FREE_TIER_MAX_AGENTS) {
|
||||
throw new FreeTierLimitError(
|
||||
'Free tier limit of 100 registered agents has been reached.',
|
||||
{ limit: FREE_TIER_MAX_AGENTS, current: currentCount },
|
||||
);
|
||||
}
|
||||
|
||||
// Check email uniqueness
|
||||
const existing = await this.agentRepository.findByEmail(data.email);
|
||||
if (existing !== null) {
|
||||
throw new AgentAlreadyExistsError(data.email);
|
||||
}
|
||||
|
||||
const agent = await this.agentRepository.create(data);
|
||||
|
||||
// Synchronous audit insert
|
||||
await this.auditService.logEvent(
|
||||
agent.agentId,
|
||||
'agent.created',
|
||||
'success',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
{ agentType: agent.agentType, owner: agent.owner },
|
||||
);
|
||||
|
||||
return agent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a single agent by its UUID.
|
||||
*
|
||||
* @param agentId - The agent UUID.
|
||||
* @returns The agent record.
|
||||
* @throws AgentNotFoundError if the agent does not exist.
|
||||
*/
|
||||
async getAgentById(agentId: string): Promise<IAgent> {
|
||||
const agent = await this.agentRepository.findById(agentId);
|
||||
if (!agent) {
|
||||
throw new AgentNotFoundError(agentId);
|
||||
}
|
||||
return agent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a paginated, optionally filtered list of agents.
|
||||
*
|
||||
* @param filters - Pagination and filter criteria.
|
||||
* @returns Paginated agents response.
|
||||
*/
|
||||
async listAgents(filters: IAgentListFilters): Promise<IPaginatedAgentsResponse> {
|
||||
const { agents, total } = await this.agentRepository.findAll(filters);
|
||||
return {
|
||||
data: agents,
|
||||
total,
|
||||
page: filters.page,
|
||||
limit: filters.limit,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Partially updates an agent's metadata.
|
||||
* Immutable fields (agentId, email, createdAt) cannot be changed.
|
||||
* Decommissioned agents cannot be updated.
|
||||
*
|
||||
* @param agentId - The agent UUID to update.
|
||||
* @param data - The fields to update.
|
||||
* @param ipAddress - Client IP for audit logging.
|
||||
* @param userAgent - Client User-Agent for audit logging.
|
||||
* @returns The updated agent record.
|
||||
* @throws AgentNotFoundError if the agent does not exist.
|
||||
* @throws AgentAlreadyDecommissionedError if the agent is decommissioned.
|
||||
* @throws ValidationError if immutable fields are included.
|
||||
*/
|
||||
async updateAgent(
|
||||
agentId: string,
|
||||
data: IUpdateAgentRequest,
|
||||
ipAddress: string,
|
||||
userAgent: string,
|
||||
): Promise<IAgent> {
|
||||
const agent = await this.agentRepository.findById(agentId);
|
||||
if (!agent) {
|
||||
throw new AgentNotFoundError(agentId);
|
||||
}
|
||||
|
||||
if (agent.status === 'decommissioned') {
|
||||
throw new AgentAlreadyDecommissionedError(agentId);
|
||||
}
|
||||
|
||||
// Detect if status changes
|
||||
const oldStatus = agent.status;
|
||||
const updated = await this.agentRepository.update(agentId, data);
|
||||
if (!updated) {
|
||||
throw new AgentNotFoundError(agentId);
|
||||
}
|
||||
|
||||
// Determine which audit action to log
|
||||
let auditAction: 'agent.updated' | 'agent.suspended' | 'agent.reactivated' | 'agent.decommissioned' =
|
||||
'agent.updated';
|
||||
if (data.status !== undefined && data.status !== oldStatus) {
|
||||
if (data.status === 'suspended') auditAction = 'agent.suspended';
|
||||
else if (data.status === 'active') auditAction = 'agent.reactivated';
|
||||
else if (data.status === 'decommissioned') auditAction = 'agent.decommissioned';
|
||||
}
|
||||
|
||||
await this.auditService.logEvent(
|
||||
agentId,
|
||||
auditAction,
|
||||
'success',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
{ updatedFields: Object.keys(data) },
|
||||
);
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently decommissions an agent (soft delete).
|
||||
* Revokes all active credentials for the agent.
|
||||
*
|
||||
* @param agentId - The agent UUID to decommission.
|
||||
* @param ipAddress - Client IP for audit logging.
|
||||
* @param userAgent - Client User-Agent for audit logging.
|
||||
* @throws AgentNotFoundError if the agent does not exist.
|
||||
* @throws AgentAlreadyDecommissionedError if already decommissioned.
|
||||
*/
|
||||
async decommissionAgent(
|
||||
agentId: string,
|
||||
ipAddress: string,
|
||||
userAgent: string,
|
||||
): Promise<void> {
|
||||
const agent = await this.agentRepository.findById(agentId);
|
||||
if (!agent) {
|
||||
throw new AgentNotFoundError(agentId);
|
||||
}
|
||||
|
||||
if (agent.status === 'decommissioned') {
|
||||
throw new AgentAlreadyDecommissionedError(agentId);
|
||||
}
|
||||
|
||||
// Revoke all active credentials
|
||||
await this.credentialRepository.revokeAllForAgent(agentId);
|
||||
|
||||
await this.agentRepository.decommission(agentId);
|
||||
|
||||
await this.auditService.logEvent(
|
||||
agentId,
|
||||
'agent.decommissioned',
|
||||
'success',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
{},
|
||||
);
|
||||
}
|
||||
}
|
||||
136
src/services/AuditService.ts
Normal file
136
src/services/AuditService.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Audit Log Service for SentryAgent.ai AgentIdP.
|
||||
* Provides methods for logging and querying immutable audit events.
|
||||
*/
|
||||
|
||||
import { AuditRepository } from '../repositories/AuditRepository.js';
|
||||
import {
|
||||
IAuditEvent,
|
||||
IAuditListFilters,
|
||||
IPaginatedAuditEventsResponse,
|
||||
AuditAction,
|
||||
AuditOutcome,
|
||||
} from '../types/index.js';
|
||||
import {
|
||||
AuditEventNotFoundError,
|
||||
RetentionWindowError,
|
||||
ValidationError,
|
||||
} from '../utils/errors.js';
|
||||
|
||||
const FREE_TIER_RETENTION_DAYS = 90;
|
||||
|
||||
/**
|
||||
* Service for creating and querying audit log events.
|
||||
* Enforces 90-day retention window on all queries.
|
||||
*/
|
||||
export class AuditService {
|
||||
/**
|
||||
* @param auditRepository - The audit event repository.
|
||||
*/
|
||||
constructor(private readonly auditRepository: AuditRepository) {}
|
||||
|
||||
/**
|
||||
* Computes the earliest allowed timestamp for audit queries (90-day retention).
|
||||
*
|
||||
* @returns The retention cutoff Date.
|
||||
*/
|
||||
private getRetentionCutoff(): Date {
|
||||
const cutoff = new Date();
|
||||
cutoff.setUTCDate(cutoff.getUTCDate() - FREE_TIER_RETENTION_DAYS);
|
||||
cutoff.setUTCHours(0, 0, 0, 0);
|
||||
return cutoff;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs an audit event. This is a fire-and-forget async insert for token
|
||||
* endpoints (do not await). For DB-backed operations, await this method.
|
||||
*
|
||||
* @param agentId - The agent that triggered the event.
|
||||
* @param action - The action that occurred.
|
||||
* @param outcome - Whether the action succeeded or failed.
|
||||
* @param ipAddress - The client IP address.
|
||||
* @param userAgent - The client User-Agent header.
|
||||
* @param metadata - Action-specific structured context data.
|
||||
* @returns Promise resolving to the created audit event.
|
||||
*/
|
||||
async logEvent(
|
||||
agentId: string,
|
||||
action: AuditAction,
|
||||
outcome: AuditOutcome,
|
||||
ipAddress: string,
|
||||
userAgent: string,
|
||||
metadata: Record<string, unknown>,
|
||||
): Promise<IAuditEvent> {
|
||||
return this.auditRepository.create({
|
||||
agentId,
|
||||
action,
|
||||
outcome,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
metadata,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries the audit log with optional filters, pagination, and retention enforcement.
|
||||
*
|
||||
* @param filters - Query filters and pagination parameters.
|
||||
* @returns Paginated audit events response.
|
||||
* @throws RetentionWindowError if fromDate is before the 90-day retention cutoff.
|
||||
* @throws ValidationError if fromDate is after toDate.
|
||||
*/
|
||||
async queryEvents(filters: IAuditListFilters): Promise<IPaginatedAuditEventsResponse> {
|
||||
const retentionCutoff = this.getRetentionCutoff();
|
||||
|
||||
if (filters.fromDate !== undefined) {
|
||||
const fromDate = new Date(filters.fromDate);
|
||||
if (fromDate < retentionCutoff) {
|
||||
throw new RetentionWindowError(
|
||||
FREE_TIER_RETENTION_DAYS,
|
||||
retentionCutoff.toISOString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (filters.fromDate !== undefined && filters.toDate !== undefined) {
|
||||
const fromDate = new Date(filters.fromDate);
|
||||
const toDate = new Date(filters.toDate);
|
||||
if (fromDate > toDate) {
|
||||
throw new ValidationError('Invalid date range.', {
|
||||
reason: 'fromDate must be before or equal to toDate.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const { events, total } = await this.auditRepository.findAll(filters, retentionCutoff);
|
||||
|
||||
return {
|
||||
data: events,
|
||||
total,
|
||||
page: filters.page,
|
||||
limit: filters.limit,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a single audit event by its UUID.
|
||||
*
|
||||
* @param eventId - The audit event UUID.
|
||||
* @returns The audit event record.
|
||||
* @throws AuditEventNotFoundError if the event does not exist.
|
||||
*/
|
||||
async getEventById(eventId: string): Promise<IAuditEvent> {
|
||||
const event = await this.auditRepository.findById(eventId);
|
||||
if (!event) {
|
||||
throw new AuditEventNotFoundError(eventId);
|
||||
}
|
||||
|
||||
// Check retention window — events older than 90 days are not accessible
|
||||
const retentionCutoff = this.getRetentionCutoff();
|
||||
if (event.timestamp < retentionCutoff) {
|
||||
throw new AuditEventNotFoundError(eventId);
|
||||
}
|
||||
|
||||
return event;
|
||||
}
|
||||
}
|
||||
226
src/services/CredentialService.ts
Normal file
226
src/services/CredentialService.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Credential Management Service for SentryAgent.ai AgentIdP.
|
||||
* Business logic for generating, listing, rotating, and revoking credentials.
|
||||
*/
|
||||
|
||||
import { CredentialRepository } from '../repositories/CredentialRepository.js';
|
||||
import { AgentRepository } from '../repositories/AgentRepository.js';
|
||||
import { AuditService } from './AuditService.js';
|
||||
import {
|
||||
ICredentialWithSecret,
|
||||
ICredentialListFilters,
|
||||
IPaginatedCredentialsResponse,
|
||||
IGenerateCredentialRequest,
|
||||
} from '../types/index.js';
|
||||
import {
|
||||
AgentNotFoundError,
|
||||
CredentialNotFoundError,
|
||||
CredentialAlreadyRevokedError,
|
||||
CredentialError,
|
||||
} from '../utils/errors.js';
|
||||
import { generateClientSecret, hashSecret } from '../utils/crypto.js';
|
||||
|
||||
/**
|
||||
* Service for credential lifecycle management.
|
||||
* The plain-text clientSecret is only returned on generation and rotation.
|
||||
*/
|
||||
export class CredentialService {
|
||||
/**
|
||||
* @param credentialRepository - The credential data repository.
|
||||
* @param agentRepository - The agent repository (for status checks).
|
||||
* @param auditService - The audit log service.
|
||||
*/
|
||||
constructor(
|
||||
private readonly credentialRepository: CredentialRepository,
|
||||
private readonly agentRepository: AgentRepository,
|
||||
private readonly auditService: AuditService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Generates a new client credential for an agent.
|
||||
* The agent must be in 'active' status.
|
||||
* Returns the plain-text clientSecret once — it is never retrievable again.
|
||||
*
|
||||
* @param agentId - The agent UUID.
|
||||
* @param data - Optional expiry date for the credential.
|
||||
* @param ipAddress - Client IP for audit logging.
|
||||
* @param userAgent - Client User-Agent for audit logging.
|
||||
* @returns The credential with the one-time plain-text clientSecret.
|
||||
* @throws AgentNotFoundError if the agent does not exist.
|
||||
* @throws CredentialError if the agent is not in 'active' status.
|
||||
*/
|
||||
async generateCredential(
|
||||
agentId: string,
|
||||
data: IGenerateCredentialRequest,
|
||||
ipAddress: string,
|
||||
userAgent: string,
|
||||
): Promise<ICredentialWithSecret> {
|
||||
const agent = await this.agentRepository.findById(agentId);
|
||||
if (!agent) {
|
||||
throw new AgentNotFoundError(agentId);
|
||||
}
|
||||
|
||||
if (agent.status !== 'active') {
|
||||
throw new CredentialError(
|
||||
'Credentials can only be generated for active agents.',
|
||||
'AGENT_NOT_ACTIVE',
|
||||
{ agentId, status: agent.status },
|
||||
);
|
||||
}
|
||||
|
||||
const expiresAt = data.expiresAt !== undefined ? new Date(data.expiresAt) : null;
|
||||
const plainSecret = generateClientSecret();
|
||||
const secretHash = await hashSecret(plainSecret);
|
||||
|
||||
const credential = await this.credentialRepository.create(agentId, secretHash, expiresAt);
|
||||
|
||||
await this.auditService.logEvent(
|
||||
agentId,
|
||||
'credential.generated',
|
||||
'success',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
{ credentialId: credential.credentialId },
|
||||
);
|
||||
|
||||
return { ...credential, clientSecret: plainSecret };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a paginated list of credentials for an agent.
|
||||
* The clientSecret is never included in list responses.
|
||||
*
|
||||
* @param agentId - The agent UUID.
|
||||
* @param filters - Pagination and optional status filter.
|
||||
* @returns Paginated credentials response.
|
||||
* @throws AgentNotFoundError if the agent does not exist.
|
||||
*/
|
||||
async listCredentials(
|
||||
agentId: string,
|
||||
filters: ICredentialListFilters,
|
||||
): Promise<IPaginatedCredentialsResponse> {
|
||||
const agent = await this.agentRepository.findById(agentId);
|
||||
if (!agent) {
|
||||
throw new AgentNotFoundError(agentId);
|
||||
}
|
||||
|
||||
const { credentials, total } = await this.credentialRepository.findByAgentId(
|
||||
agentId,
|
||||
filters,
|
||||
);
|
||||
|
||||
return {
|
||||
data: credentials,
|
||||
total,
|
||||
page: filters.page,
|
||||
limit: filters.limit,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotates a credential by generating a new secret for the same credentialId.
|
||||
* Only 'active' credentials can be rotated.
|
||||
* Returns the new plain-text clientSecret once.
|
||||
*
|
||||
* @param agentId - The agent UUID.
|
||||
* @param credentialId - The credential UUID to rotate.
|
||||
* @param data - Optional new expiry date.
|
||||
* @param ipAddress - Client IP for audit logging.
|
||||
* @param userAgent - Client User-Agent for audit logging.
|
||||
* @returns The updated credential with the new one-time clientSecret.
|
||||
* @throws AgentNotFoundError if the agent does not exist.
|
||||
* @throws CredentialNotFoundError if the credential does not exist.
|
||||
* @throws CredentialAlreadyRevokedError if the credential is already revoked.
|
||||
*/
|
||||
async rotateCredential(
|
||||
agentId: string,
|
||||
credentialId: string,
|
||||
data: IGenerateCredentialRequest,
|
||||
ipAddress: string,
|
||||
userAgent: string,
|
||||
): Promise<ICredentialWithSecret> {
|
||||
const agent = await this.agentRepository.findById(agentId);
|
||||
if (!agent) {
|
||||
throw new AgentNotFoundError(agentId);
|
||||
}
|
||||
|
||||
const existing = await this.credentialRepository.findById(credentialId);
|
||||
if (!existing || existing.clientId !== agentId) {
|
||||
throw new CredentialNotFoundError(credentialId);
|
||||
}
|
||||
|
||||
if (existing.status === 'revoked') {
|
||||
throw new CredentialAlreadyRevokedError(
|
||||
credentialId,
|
||||
existing.revokedAt?.toISOString() ?? new Date().toISOString(),
|
||||
);
|
||||
}
|
||||
|
||||
const expiresAt = data.expiresAt !== undefined ? new Date(data.expiresAt) : null;
|
||||
const plainSecret = generateClientSecret();
|
||||
const newHash = await hashSecret(plainSecret);
|
||||
|
||||
const updated = await this.credentialRepository.updateHash(credentialId, newHash, expiresAt);
|
||||
if (!updated) {
|
||||
throw new CredentialNotFoundError(credentialId);
|
||||
}
|
||||
|
||||
await this.auditService.logEvent(
|
||||
agentId,
|
||||
'credential.rotated',
|
||||
'success',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
{ credentialId },
|
||||
);
|
||||
|
||||
return { ...updated, clientSecret: plainSecret };
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently revokes a credential.
|
||||
* Revoking an already-revoked credential returns 409 Conflict.
|
||||
*
|
||||
* @param agentId - The agent UUID.
|
||||
* @param credentialId - The credential UUID to revoke.
|
||||
* @param ipAddress - Client IP for audit logging.
|
||||
* @param userAgent - Client User-Agent for audit logging.
|
||||
* @throws AgentNotFoundError if the agent does not exist.
|
||||
* @throws CredentialNotFoundError if the credential does not exist or belongs to another agent.
|
||||
* @throws CredentialAlreadyRevokedError if the credential is already revoked.
|
||||
*/
|
||||
async revokeCredential(
|
||||
agentId: string,
|
||||
credentialId: string,
|
||||
ipAddress: string,
|
||||
userAgent: string,
|
||||
): Promise<void> {
|
||||
const agent = await this.agentRepository.findById(agentId);
|
||||
if (!agent) {
|
||||
throw new AgentNotFoundError(agentId);
|
||||
}
|
||||
|
||||
const existing = await this.credentialRepository.findById(credentialId);
|
||||
if (!existing || existing.clientId !== agentId) {
|
||||
throw new CredentialNotFoundError(credentialId);
|
||||
}
|
||||
|
||||
if (existing.status === 'revoked') {
|
||||
throw new CredentialAlreadyRevokedError(
|
||||
credentialId,
|
||||
existing.revokedAt?.toISOString() ?? new Date().toISOString(),
|
||||
);
|
||||
}
|
||||
|
||||
await this.credentialRepository.revoke(credentialId);
|
||||
|
||||
await this.auditService.logEvent(
|
||||
agentId,
|
||||
'credential.revoked',
|
||||
'success',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
{ credentialId },
|
||||
);
|
||||
}
|
||||
}
|
||||
303
src/services/OAuth2Service.ts
Normal file
303
src/services/OAuth2Service.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* OAuth 2.0 Token Service for SentryAgent.ai AgentIdP.
|
||||
* Issues, introspects, and revokes RS256 JWT access tokens.
|
||||
*/
|
||||
|
||||
import { TokenRepository } from '../repositories/TokenRepository.js';
|
||||
import { CredentialRepository } from '../repositories/CredentialRepository.js';
|
||||
import { AgentRepository } from '../repositories/AgentRepository.js';
|
||||
import { AuditService } from './AuditService.js';
|
||||
import {
|
||||
ITokenPayload,
|
||||
ITokenResponse,
|
||||
IIntrospectResponse,
|
||||
IOAuth2ErrorResponse,
|
||||
} from '../types/index.js';
|
||||
import {
|
||||
AuthenticationError,
|
||||
AuthorizationError,
|
||||
FreeTierLimitError,
|
||||
InsufficientScopeError,
|
||||
} from '../utils/errors.js';
|
||||
import { signToken, verifyToken, decodeToken, getTokenExpiresIn } from '../utils/jwt.js';
|
||||
import { verifySecret } from '../utils/crypto.js';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const FREE_TIER_MAX_MONTHLY_TOKENS = 10000;
|
||||
|
||||
/** Result of a token issuance, including either a success response or OAuth2 error. */
|
||||
export interface IssueTokenResult {
|
||||
success: boolean;
|
||||
response?: ITokenResponse;
|
||||
error?: IOAuth2ErrorResponse;
|
||||
httpStatus?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for OAuth 2.0 Client Credentials token issuance, introspection, and revocation.
|
||||
*/
|
||||
export class OAuth2Service {
|
||||
/**
|
||||
* @param tokenRepository - Repository for token revocation and monthly counts.
|
||||
* @param credentialRepository - Repository for credential lookup and verification.
|
||||
* @param agentRepository - Repository for agent status lookup.
|
||||
* @param auditService - The audit log service.
|
||||
* @param privateKey - PEM-encoded RSA private key for signing tokens.
|
||||
* @param publicKey - PEM-encoded RSA public key for verifying tokens.
|
||||
*/
|
||||
constructor(
|
||||
private readonly tokenRepository: TokenRepository,
|
||||
private readonly credentialRepository: CredentialRepository,
|
||||
private readonly agentRepository: AgentRepository,
|
||||
private readonly auditService: AuditService,
|
||||
private readonly privateKey: string,
|
||||
private readonly publicKey: string,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Issues a signed RS256 JWT access token via the OAuth 2.0 Client Credentials grant.
|
||||
* Validates client credentials, checks agent status, enforces 10k monthly limit,
|
||||
* and writes an async fire-and-forget audit event.
|
||||
*
|
||||
* @param clientId - The agent UUID acting as client_id.
|
||||
* @param clientSecret - The plain-text client secret.
|
||||
* @param scope - Space-separated OAuth 2.0 scopes requested.
|
||||
* @param ipAddress - Client IP for audit logging.
|
||||
* @param userAgent - Client User-Agent for audit logging.
|
||||
* @returns The token response with access_token, token_type, expires_in, scope.
|
||||
* @throws AuthenticationError if the client credentials are invalid.
|
||||
* @throws AuthorizationError if the agent is suspended or decommissioned.
|
||||
* @throws FreeTierLimitError if the monthly token limit is reached.
|
||||
*/
|
||||
async issueToken(
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
scope: string,
|
||||
ipAddress: string,
|
||||
userAgent: string,
|
||||
): Promise<ITokenResponse> {
|
||||
// Look up the agent
|
||||
const agent = await this.agentRepository.findById(clientId);
|
||||
if (!agent) {
|
||||
void this.auditService.logEvent(
|
||||
clientId,
|
||||
'auth.failed',
|
||||
'failure',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
{ reason: 'agent_not_found', clientId },
|
||||
);
|
||||
throw new AuthenticationError('Client authentication failed. Invalid client_id or client_secret.');
|
||||
}
|
||||
|
||||
// Find active credentials for the agent and verify secret
|
||||
const { credentials } = await this.credentialRepository.findByAgentId(clientId, {
|
||||
status: 'active',
|
||||
page: 1,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
let credentialVerified = false;
|
||||
for (const cred of credentials) {
|
||||
const credRow = await this.credentialRepository.findById(cred.credentialId);
|
||||
if (credRow) {
|
||||
const matches = await verifySecret(clientSecret, credRow.secretHash);
|
||||
if (matches) {
|
||||
// Check if credential is expired
|
||||
if (credRow.expiresAt !== null && credRow.expiresAt < new Date()) {
|
||||
continue;
|
||||
}
|
||||
credentialVerified = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!credentialVerified) {
|
||||
void this.auditService.logEvent(
|
||||
clientId,
|
||||
'auth.failed',
|
||||
'failure',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
{ reason: 'invalid_client_secret', clientId },
|
||||
);
|
||||
throw new AuthenticationError('Client authentication failed. Invalid client_id or client_secret.');
|
||||
}
|
||||
|
||||
// Check agent status
|
||||
if (agent.status === 'suspended') {
|
||||
void this.auditService.logEvent(
|
||||
clientId,
|
||||
'auth.failed',
|
||||
'failure',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
{ reason: 'agent_suspended', clientId },
|
||||
);
|
||||
throw new AuthorizationError('Agent is currently suspended and cannot obtain tokens.');
|
||||
}
|
||||
|
||||
if (agent.status === 'decommissioned') {
|
||||
void this.auditService.logEvent(
|
||||
clientId,
|
||||
'auth.failed',
|
||||
'failure',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
{ reason: 'agent_decommissioned', clientId },
|
||||
);
|
||||
throw new AuthorizationError('Agent is decommissioned and cannot obtain tokens.');
|
||||
}
|
||||
|
||||
// Check monthly token limit
|
||||
const monthlyCount = await this.tokenRepository.getMonthlyCount(clientId);
|
||||
if (monthlyCount >= FREE_TIER_MAX_MONTHLY_TOKENS) {
|
||||
throw new FreeTierLimitError(
|
||||
'Free tier monthly token limit of 10,000 requests has been reached.',
|
||||
{ limit: FREE_TIER_MAX_MONTHLY_TOKENS, current: monthlyCount },
|
||||
);
|
||||
}
|
||||
|
||||
// Issue the token
|
||||
const jti = uuidv4();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const expiresIn = getTokenExpiresIn();
|
||||
|
||||
const payload: Omit<ITokenPayload, 'iat' | 'exp'> = {
|
||||
sub: clientId,
|
||||
client_id: clientId,
|
||||
scope,
|
||||
jti,
|
||||
};
|
||||
|
||||
const accessToken = signToken(payload, this.privateKey);
|
||||
|
||||
// Increment monthly count (fire-and-forget)
|
||||
void this.tokenRepository.incrementMonthlyCount(clientId);
|
||||
|
||||
// Audit event (fire-and-forget — do not await for latency)
|
||||
const expiresAtDate = new Date((now + expiresIn) * 1000);
|
||||
void this.auditService.logEvent(
|
||||
clientId,
|
||||
'token.issued',
|
||||
'success',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
{ scope, expiresAt: expiresAtDate.toISOString() },
|
||||
);
|
||||
|
||||
return {
|
||||
access_token: accessToken,
|
||||
token_type: 'Bearer',
|
||||
expires_in: expiresIn,
|
||||
scope,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Introspects a token per RFC 7662.
|
||||
* Always returns 200; check the `active` field for validity.
|
||||
* Requires the caller to hold a token with `tokens:read` scope.
|
||||
*
|
||||
* @param token - The JWT string to introspect.
|
||||
* @param callerPayload - The decoded payload of the calling agent's token (for scope check).
|
||||
* @param ipAddress - Client IP for audit logging.
|
||||
* @param userAgent - Client User-Agent for audit logging.
|
||||
* @returns The introspection response.
|
||||
* @throws InsufficientScopeError if the caller lacks `tokens:read` scope.
|
||||
*/
|
||||
async introspectToken(
|
||||
token: string,
|
||||
callerPayload: ITokenPayload,
|
||||
ipAddress: string,
|
||||
userAgent: string,
|
||||
): Promise<IIntrospectResponse> {
|
||||
// Check caller has tokens:read scope
|
||||
const callerScopes = callerPayload.scope.split(' ');
|
||||
if (!callerScopes.includes('tokens:read')) {
|
||||
throw new InsufficientScopeError('tokens:read');
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = verifyToken(token, this.publicKey);
|
||||
const revoked = await this.tokenRepository.isRevoked(payload.jti);
|
||||
|
||||
if (revoked) {
|
||||
void this.auditService.logEvent(
|
||||
callerPayload.sub,
|
||||
'token.introspected',
|
||||
'success',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
{ targetJti: payload.jti, active: false },
|
||||
);
|
||||
return { active: false };
|
||||
}
|
||||
|
||||
void this.auditService.logEvent(
|
||||
callerPayload.sub,
|
||||
'token.introspected',
|
||||
'success',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
{ targetJti: payload.jti, active: true },
|
||||
);
|
||||
|
||||
return {
|
||||
active: true,
|
||||
sub: payload.sub,
|
||||
client_id: payload.client_id,
|
||||
scope: payload.scope,
|
||||
token_type: 'Bearer',
|
||||
iat: payload.iat,
|
||||
exp: payload.exp,
|
||||
};
|
||||
} catch {
|
||||
// Token is invalid or expired — return inactive per RFC 7662
|
||||
return { active: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Revokes a token per RFC 7009.
|
||||
* Idempotent — revoking an already-revoked or expired token returns success.
|
||||
* An agent may only revoke its own tokens.
|
||||
*
|
||||
* @param token - The JWT string to revoke.
|
||||
* @param callerPayload - The decoded payload of the calling agent's token.
|
||||
* @param ipAddress - Client IP for audit logging.
|
||||
* @param userAgent - Client User-Agent for audit logging.
|
||||
* @throws AuthorizationError if the caller tries to revoke another agent's token.
|
||||
*/
|
||||
async revokeToken(
|
||||
token: string,
|
||||
callerPayload: ITokenPayload,
|
||||
ipAddress: string,
|
||||
userAgent: string,
|
||||
): Promise<void> {
|
||||
// Decode the token without verification to extract claims
|
||||
const decoded = decodeToken(token);
|
||||
|
||||
if (decoded !== null) {
|
||||
// Only the token owner can revoke their own token
|
||||
if (decoded.sub !== callerPayload.sub) {
|
||||
throw new AuthorizationError('You do not have permission to revoke this token.');
|
||||
}
|
||||
|
||||
// Add to revocation list
|
||||
const expiresAt = new Date(decoded.exp * 1000);
|
||||
await this.tokenRepository.addToRevocationList(decoded.jti, expiresAt);
|
||||
|
||||
void this.auditService.logEvent(
|
||||
callerPayload.sub,
|
||||
'token.revoked',
|
||||
'success',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
{ jti: decoded.jti },
|
||||
);
|
||||
}
|
||||
// If token is malformed/undecoded, per RFC 7009 we still return success
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user