Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions resources/coverage-badge.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
36 changes: 14 additions & 22 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ import { dynamicRateLimit } from './middleware/rateLimit.js';
import { logRoute } from './middleware/routeLogger.js';
import { dynamicSlowDown } from './middleware/slowDown.js';
import { applyTrustedClientIp } from './middleware/trustedClientIp.js';
import { AuthEvent } from './models/authEvents.js';
import { generateOpenApiDocument } from './openapi/document.js';
import { AuthEventService } from './services/authEventService.js';
import getLogger from './utils/logger.js';

const logger = getLogger('app');
Expand All @@ -35,13 +35,13 @@ const corsOptions: CorsOptions = {
}

logger.warn(`Unknown CORS origin: ${origin}`);
AuthEvent.create({
user_id: null,
type: 'request_suspicious',
ip_address: origin,
user_agent: 'unknown',
metadata: { reason: 'Unknown origin request' },
});
void AuthEventService.requestSuspiciousContext(
{
ipAddress: origin,
userAgent: 'unknown',
},
{ reason: 'Unknown origin request' },
);
return callback(null, false);
},
credentials: true,
Expand Down Expand Up @@ -92,11 +92,8 @@ export async function createApp() {
await loadRoutes(app);
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
if (err.message === 'Not allowed by CORS') {
AuthEvent.create({
type: 'request_suspicous',
ip_address: req.ip,
user_agent: req.headers['user-agent'],
metadata: { reason: 'Request from an unexpected origin' },
void AuthEventService.requestSuspicious(req, {
reason: 'Request from an unexpected origin',
});
res.setHeader('Access-Control-Allow-Origin', rawOrigin[0]);
return res.status(403).json({ message: 'CORS policy does not allow this origin.' });
Expand All @@ -120,15 +117,10 @@ export async function createApp() {
logger.warn(
`[${req.ip}] ${req.method} ${req.originalUrl} did not match any route. Tracking suspicious behavior`,
);
AuthEvent.create({
type: 'request_suspicous',
ip_address: req.ip,
user_agent: req.headers['user-agent'],
metadata: {
reason: 'Request to an unknown route.',
method: req.method,
path: req.originalUrl,
},
void AuthEventService.requestSuspicious(req, {
reason: 'Request to an unknown route.',
method: req.method,
path: req.originalUrl,
});
return res.status(404).json({ error: 'Not Found' });
});
Expand Down
34 changes: 26 additions & 8 deletions src/controllers/authentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,12 +245,7 @@ export const refreshSession = async (req: Request, res: Response) => {

if (!refreshToken) {
logger.error('Refresh token provided is not of expected type for auth server configurations');
await AuthEventService.log({
userId: null,
type: 'refresh_token_failed',
req,
metadata: { reason: 'Missing refresh token' },
});
await AuthEventService.refreshTokenFailed(req, { reason: 'Missing refresh token' });
res.status(401).json({ error: 'Not allowed' });
return;
}
Expand All @@ -274,7 +269,11 @@ export const refreshSession = async (req: Request, res: Response) => {
);
}

await AuthEventService.serviceTokenInvalid(req);
await AuthEventService.refreshTokenFailed(req, {
reason: 'No refresh session found for refresh token',
legacyFallbackCandidates,
tokenFormat: looksLikeJwt ? 'jwt_like' : 'opaque',
});
return res.status(401).json({ error: 'invalid_refresh_token' });
}

Expand All @@ -291,7 +290,17 @@ export const refreshSession = async (req: Request, res: Response) => {
);
// Reuse -> revoke session chain
await revokeSessionChain(session);
// Log security event
await AuthEventService.log({
userId: session.userId,
type: 'refresh_token_suspicious',
req,
metadata: {
reason: 'Refresh token reuse detected',
sessionId: session.id,
replacedBySessionId: session.replacedBySessionId,
revokedAt: session.revokedAt?.toISOString() ?? null,
},
});
return res.status(401).json({ error: 'refresh_token_reused' });
}

Expand All @@ -301,6 +310,15 @@ export const refreshSession = async (req: Request, res: Response) => {

const user = await User.findByPk(session.userId);
if (!user) {
await AuthEventService.log({
userId: session.userId,
type: 'refresh_token_suspicious',
req,
metadata: {
reason: 'Refresh session user not found',
sessionId: session.id,
},
});
await hardRevokeSession(session, 'user_not_found');
return res.status(401).json({ error: 'invalid_session' });
}
Expand Down
9 changes: 4 additions & 5 deletions src/controllers/webauthn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,11 +156,10 @@ const verifyWebAuthnRegistration = async (req: Request, res: Response) => {
const expectedChallenge = user.challenge;
if (!expectedChallenge) {
logger.error('Unexpected user challegnge supplied.');
await AuthEvent.create({
user_id: user.id,
type: 'registration_suspicous',
ip_address: req.ip,
user_agent: req.headers['user-agent'],
await AuthEventService.log({
userId: user.id,
type: 'registration_suspicious',
req,
metadata: { reason: 'Missing challenge for registration' },
});
return res.status(403).json({ message: 'Missing challenge' });
Expand Down
17 changes: 15 additions & 2 deletions src/middleware/verifyCookieAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,10 @@ async function performSilentRefresh(req: Request, res: Response): Promise<User |
logger.warn(
`No matching session found for refresh token. legacyFallbackCandidates=${legacyFallbackCandidates}`,
);
await AuthEventService.serviceTokenInvalid(req);
await AuthEventService.refreshTokenFailed(req, {
reason: 'No matching session found for refresh token',
legacyFallbackCandidates,
});
return null;
}

Expand All @@ -142,7 +145,17 @@ async function performSilentRefresh(req: Request, res: Response): Promise<User |
if (session.replacedBySessionId || session.revokedAt) {
logger.warn('Refresh token reuse detected');
await revokeSessionChain(session);
await AuthEventService.serviceTokenInvalid(req);
await AuthEventService.log({
userId: session.userId,
type: 'refresh_token_suspicious',
req,
metadata: {
reason: 'Refresh token reuse detected',
sessionId: session.id,
replacedBySessionId: session.replacedBySessionId,
revokedAt: session.revokedAt?.toISOString() ?? null,
},
});
return null;
}

Expand Down
14 changes: 11 additions & 3 deletions src/schemas/authEvent.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,23 @@
// src/schemas/authEvent.types.ts
import { z } from 'zod';

export const AuthEventTypeEnum = z.enum([
export const AUTH_EVENT_TYPES = [
'auth_action_incremented',
'bearer_token_failed',
'bearer_token_success',
'bearer_token_suspicious',
'bootstrap_admin_check_skipped',
'bootstrap_admin_granted',
'cookie_token_failed',
'cookie_token_success',
'cookie_token_suspicious',
'credentials_deleted',
'informational',
'internal_user_updated_by_owner',
'jwks_failed',
'jwks_success',
'jwks_suspicious',
'login_challenge',
'login_failed',
'login_success',
'login_suspicious',
Expand All @@ -32,7 +36,7 @@ export const AuthEventTypeEnum = z.enum([
'mfa_otp_failed',
'mfa_otp_success',
'mfa_otp_suspicious',
'notication_sent',
'notification_sent',
'otp_failed',
'otp_success',
'otp_suspicious',
Expand All @@ -45,6 +49,7 @@ export const AuthEventTypeEnum = z.enum([
'registration_failed',
'registration_success',
'registration_suspicious',
'request_suspicious',
'service_token_failed',
'service_token_rotated',
'service_token_success',
Expand All @@ -56,6 +61,7 @@ export const AuthEventTypeEnum = z.enum([
'user_data_failed',
'user_data_success',
'user_data_suspicious',
'user_deleted',
'verify_otp_failed',
'verify_otp_success',
'verify_otp_suspicious',
Expand All @@ -65,6 +71,8 @@ export const AuthEventTypeEnum = z.enum([
'webauthn_registration_failed',
'webauthn_registration_success',
'webauthn_registration_suspicious',
]);
] as const;

export const AuthEventTypeEnum = z.enum(AUTH_EVENT_TYPES);

export type AuthEventType = z.infer<typeof AuthEventTypeEnum>;
Loading
Loading