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:
SentryAgent.ai Developer
2026-03-28 09:14:41 +00:00
parent 245f8df427
commit d3530285b9
78 changed files with 20590 additions and 1 deletions

View 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,
{},
);
}
}

View 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;
}
}

View 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 },
);
}
}

View 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
}
}