Skip to content

Commit 638000a

Browse files
authored
Merge pull request #470 from talktosam2003/revocation
revocation endpoints
2 parents ad9aecd + e85bdc8 commit 638000a

9 files changed

Lines changed: 613 additions & 16 deletions

File tree

docs/swagger.yaml

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -572,6 +572,95 @@ paths:
572572
application/json:
573573
schema:
574574
$ref: '#/components/schemas/ErrorResponse'
575+
/api/auth/sessions:
576+
get:
577+
summary: List active sessions
578+
description: |
579+
Returns all active session records for the authenticated user (one per device/login).
580+
Sessions are ordered newest-first.
581+
tags:
582+
- Auth
583+
security:
584+
- bearerAuth: []
585+
responses:
586+
'200':
587+
description: Session list retrieved successfully
588+
content:
589+
application/json:
590+
schema:
591+
type: object
592+
properties:
593+
success:
594+
type: boolean
595+
example: true
596+
message:
597+
type: string
598+
example: Sessions retrieved
599+
data:
600+
type: array
601+
items:
602+
$ref: '#/components/schemas/Session'
603+
'401':
604+
description: Missing or invalid authorization token
605+
content:
606+
application/json:
607+
schema:
608+
$ref: '#/components/schemas/ErrorResponse'
609+
/api/auth/sessions/{jti}:
610+
delete:
611+
summary: Revoke a session
612+
description: |
613+
Blocklists the token identified by `jti` in Redis and removes the session record.
614+
The token will be rejected immediately by `requireAuth` on subsequent requests.
615+
616+
- Returns **403** if the session belongs to a different user (cross-user revocation).
617+
- Returns **404** if no session with that `jti` exists.
618+
tags:
619+
- Auth
620+
security:
621+
- bearerAuth: []
622+
parameters:
623+
- in: path
624+
name: jti
625+
required: true
626+
schema:
627+
type: string
628+
description: The JWT ID (`jti` claim) of the session to revoke
629+
responses:
630+
'200':
631+
description: Session revoked successfully
632+
content:
633+
application/json:
634+
schema:
635+
type: object
636+
properties:
637+
success:
638+
type: boolean
639+
example: true
640+
message:
641+
type: string
642+
example: Session revoked successfully
643+
data:
644+
nullable: true
645+
example: null
646+
'401':
647+
description: Missing or invalid authorization token
648+
content:
649+
application/json:
650+
schema:
651+
$ref: '#/components/schemas/ErrorResponse'
652+
'403':
653+
description: Forbidden — cannot revoke another user's session
654+
content:
655+
application/json:
656+
schema:
657+
$ref: '#/components/schemas/ErrorResponse'
658+
'404':
659+
description: Session not found
660+
content:
661+
application/json:
662+
schema:
663+
$ref: '#/components/schemas/ErrorResponse'
575664
/api/auth/signup:
576665
post:
577666
summary: Register a new user
@@ -4214,6 +4303,33 @@ paths:
42144303

42154304
components:
42164305
schemas:
4306+
Session:
4307+
type: object
4308+
description: A per-device session record created on login or signup.
4309+
properties:
4310+
_id:
4311+
type: string
4312+
description: Session MongoDB ObjectId
4313+
userId:
4314+
type: string
4315+
description: Owner user ObjectId
4316+
jti:
4317+
type: string
4318+
description: JWT ID (UUID v4) — use this as the path param for DELETE /api/auth/sessions/:jti
4319+
ip:
4320+
type: string
4321+
description: Client IP address at time of login (optional)
4322+
userAgent:
4323+
type: string
4324+
description: HTTP User-Agent string at time of login (optional)
4325+
createdAt:
4326+
type: string
4327+
format: date-time
4328+
description: When the session was created (ISO 8601)
4329+
lastUsedAt:
4330+
type: string
4331+
format: date-time
4332+
description: When the session was last active (ISO 8601)
42174333
TelemetryUpdateEvent:
42184334
type: object
42194335
description: Real-time telemetry reading from an IoT sensor.

src/modules/auth/auth.controller.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@ import { sendResponse } from '../../shared/http/sendResponse.js';
2121
* @throws {AppError} 409 EMAIL_TAKEN — when the email is already registered.
2222
*/
2323
export const signupController: RequestHandler = async (req, res) => {
24-
const result = await signup(req.body);
24+
const ctx = {
25+
ip: req.ip,
26+
userAgent: req.headers['user-agent'],
27+
};
28+
const result = await signup(req.body, ctx);
2529
sendResponse(res, 201, true, 'Account created successfully', result);
2630
};
2731

@@ -34,7 +38,11 @@ export const signupController: RequestHandler = async (req, res) => {
3438
* @throws {AppError} 401 INVALID_CREDENTIALS — when email or password is incorrect.
3539
*/
3640
export const loginController: RequestHandler = async (req, res) => {
37-
const result = await login(req.body);
41+
const ctx = {
42+
ip: req.ip,
43+
userAgent: req.headers['user-agent'],
44+
};
45+
const result = await login(req.body, ctx);
3846
sendResponse(res, 200, true, 'Login successful', result);
3947
};
4048

src/modules/auth/auth.routes.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ import {
3131
CreateApiKeyBodySchema,
3232
OrganizationIdParamSchema,
3333
} from './apiKey.validation.js';
34+
import { listSessionsController, revokeSessionController } from './session.controller.js';
35+
import { SessionJtiParamSchema } from './session.validation.js';
3436

3537
export const authRouter = Router();
3638

@@ -91,3 +93,12 @@ authRouter.delete(
9193
validateRequest({ params: ApiKeyIdParamSchema }),
9294
asyncHandler(revokeApiKeyController)
9395
);
96+
97+
// Session management routes (protected by JWT auth)
98+
authRouter.get('/sessions', asyncHandler(requireAuth), asyncHandler(listSessionsController));
99+
authRouter.delete(
100+
'/sessions/:jti',
101+
asyncHandler(requireAuth),
102+
validateRequest({ params: SessionJtiParamSchema }),
103+
asyncHandler(revokeSessionController)
104+
);

src/modules/auth/auth.service.ts

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import { config } from '../../config/index.js';
77
import { OrganizationType } from '../../shared/constants/index.js';
88
import { UserModel, OrganizationModel, UserRole } from '../users/users.model.js';
99
import { blockToken, isTokenBlocked } from '../../infra/redis/tokenBlocklist.js';
10-
import type { SignupInput, LoginInput, RegisterCompanyInput } from './auth.validation.js';
10+
import { createSession } from './session.service.js';
11+
import type { SignupInput, LoginInput } from './auth.validation.js';
1112
import { logger } from '../../shared/logger/logger.js';
1213
import { sendEmail, resetPasswordEmailHtml } from '../../services/email.service.js';
1314

@@ -23,22 +24,23 @@ export interface TokenPayload {
2324
// SECURITY: [Token Lifecycle Compromise] — This prevents long-term token abuse by enforcing a 7-day Time-To-Live (TTL) limit on authentication tokens, bounding the window of opportunity for stolen credentials.
2425
const TOKEN_TTL_SECONDS = 7 * 24 * 60 * 60; // 7 days
2526

26-
function generateToken(payload: Omit<TokenPayload, 'jti'>): string {
27+
function generateToken(payload: Omit<TokenPayload, 'jti'>): { token: string; jti: string } {
2728
// SECURITY: [Token Replay Attack] — This prevents reuse of old or intercepted JWTs by attaching a cryptographically random, unique JWT ID (jti) to each token, allowing the middleware to track and revoke individual sessions via Redis.
2829
const jti = randomUUID();
29-
return jwt.sign({ ...payload, jti }, env.JWT_SECRET, { expiresIn: TOKEN_TTL_SECONDS });
30+
const token = jwt.sign({ ...payload, jti }, env.JWT_SECRET, { expiresIn: TOKEN_TTL_SECONDS });
31+
return { token, jti };
3032
}
3133

3234
/**
3335
* Determines the safe default role for public signup.
34-
*
36+
*
3537
* SECURITY: Always returns VIEWER role to prevent privilege escalation.
3638
* No exceptions or role overrides permitted for unauthenticated signup.
3739
* Admin/privileged roles are assigned exclusively through:
3840
* - POST /api/users (ADMIN only)
3941
* - POST /api/users/team (ADMIN only)
4042
* - Invitation acceptance flow (role determined by inviter)
41-
*
43+
*
4244
* @param {string} _email - Email (unused - role is same for all users)
4345
* @returns {UserRole} Always returns VIEWER
4446
*/
@@ -54,20 +56,26 @@ function derivePersona(role: string, organizationType?: OrganizationType): 'comp
5456
return 'company';
5557
}
5658

59+
export interface RequestContext {
60+
ip?: string;
61+
userAgent?: string;
62+
}
63+
5764
/**
5865
* Registers a new user and returns an auth token.
59-
*
66+
*
6067
* SECURITY CONTROLS:
6168
* - Public signup ALWAYS assigns VIEWER role (determined via determineUserRole)
6269
* - No role parameter accepted in request body (enforced by SignupBodySchema)
6370
* - Admin/privileged roles only assignable via authenticated admin endpoints
6471
* - Prevents privilege escalation (CWE-284) by unauthenticated users
65-
*
72+
*
6673
* @param {SignupInput} input - User signup input payload.
74+
* @param {RequestContext} [ctx] - Optional request context for session tracking.
6775
* @returns {Promise<{user: {id: string; email: string; name: string; role: string}; token: string}>} The created user and JWT token.
6876
* @throws {AppError} When the email is already in use.
6977
*/
70-
export async function signup(input: SignupInput) {
78+
export async function signup(input: SignupInput, ctx?: RequestContext) {
7179
const existing = await UserModel.findOne({ email: input.email });
7280
if (existing) {
7381
throw new AppError(409, 'Email already in use', 'EMAIL_TAKEN');
@@ -90,14 +98,16 @@ export async function signup(input: SignupInput) {
9098
organizationType = organization?.type;
9199
}
92100

93-
const token = generateToken({
101+
const { token, jti } = generateToken({
94102
userId: user._id.toString(),
95103
role: user.role as string,
96104
persona: derivePersona(user.role as string, organizationType),
97105
organizationId: user.organizationId?.toString(),
98106
organizationType,
99107
});
100108

109+
await createSession({ userId: user._id.toString(), jti, ip: ctx?.ip, userAgent: ctx?.userAgent });
110+
101111
return {
102112
user: {
103113
id: user._id,
@@ -112,10 +122,11 @@ export async function signup(input: SignupInput) {
112122
/**
113123
* Authenticates a user and returns a JWT.
114124
* @param {LoginInput} input - User login credentials.
125+
* @param {RequestContext} [ctx] - Optional request context for session tracking.
115126
* @returns {Promise<{user: {id: string; email: string; name: string; role: string}; token: string}>} Authenticated user data and token.
116127
* @throws {AppError} When credentials are invalid.
117128
*/
118-
export async function login(input: LoginInput) {
129+
export async function login(input: LoginInput, ctx?: RequestContext) {
119130
const user = await UserModel.findOne({ email: input.email });
120131
if (!user) {
121132
throw new AppError(401, 'Invalid credentials', 'INVALID_CREDENTIALS');
@@ -132,14 +143,16 @@ export async function login(input: LoginInput) {
132143
organizationType = organization?.type;
133144
}
134145

135-
const token = generateToken({
146+
const { token, jti } = generateToken({
136147
userId: user._id.toString(),
137148
role: user.role as string,
138149
persona: derivePersona(user.role as string, organizationType),
139150
organizationId: user.organizationId?.toString(),
140151
organizationType,
141152
});
142153

154+
await createSession({ userId: user._id.toString(), jti, ip: ctx?.ip, userAgent: ctx?.userAgent });
155+
143156
return {
144157
user: {
145158
id: user._id,
@@ -217,7 +230,7 @@ export async function refreshToken(token: string): Promise<{ token: string; expi
217230
organizationType,
218231
});
219232

220-
return { token: newToken, expiresIn: TOKEN_TTL_SECONDS };
233+
return { token: newToken.token, expiresIn: TOKEN_TTL_SECONDS };
221234
}
222235

223236
/**
@@ -313,7 +326,7 @@ export async function resetPassword(token: string, newPassword: string): Promise
313326
/**
314327
* Self-service company registration: creates both an Organization (type: ENTERPRISE)
315328
* and the first admin user in a single atomic operation.
316-
*
329+
*
317330
* @param {RegisterCompanyInput} input - Company and admin user details.
318331
* @returns {Promise<{user: {id: string; email: string; name: string; role: string}; token: string}>} The created admin user and JWT token.
319332
* @throws {AppError} 409 if email or organization name already exists.
@@ -384,7 +397,7 @@ export async function registerCompany(input: {
384397
const createdUser = user[0];
385398

386399
// Generate JWT token with organization context
387-
const token = generateToken({
400+
const { token } = generateToken({
388401
userId: createdUser._id.toString(),
389402
role: UserRole.ADMIN,
390403
persona: derivePersona(UserRole.ADMIN, OrganizationType.ENTERPRISE),
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import type { RequestHandler } from 'express';
2+
import { listSessions, revokeSession } from './session.service.js';
3+
import { sendResponse } from '../../shared/http/sendResponse.js';
4+
import { AppError, ErrorCodes } from '../../shared/http/errors.js';
5+
6+
/**
7+
* GET /api/auth/sessions
8+
*
9+
* Returns all active sessions for the authenticated user.
10+
*
11+
* @returns HTTP 200 with envelope `{ success, message, data: Session[] }`.
12+
* @throws {AppError} 401 ERR_AUTH_INVALID — when JWT auth fails.
13+
*/
14+
export const listSessionsController: RequestHandler = async (req, res) => {
15+
const userId = req.user?.userId;
16+
17+
if (!userId) {
18+
throw new AppError(401, 'Unauthorized', ErrorCodes.UNAUTHORIZED);
19+
}
20+
21+
const sessions = await listSessions(userId);
22+
sendResponse(res, 200, true, 'Sessions retrieved', sessions);
23+
};
24+
25+
/**
26+
* DELETE /api/auth/sessions/:jti
27+
*
28+
* Revokes a session by JTI — blocklists the token and removes the session record.
29+
* Cross-user revocation returns 403.
30+
*
31+
* @param req.params.jti - The JWT ID of the session to revoke.
32+
* @returns HTTP 200 with envelope `{ success, message, data: null }`.
33+
* @throws {AppError} 401 ERR_AUTH_INVALID — when JWT auth fails.
34+
* @throws {AppError} 403 ERR_PERMISSION_DENIED — when revoking another user's session.
35+
* @throws {AppError} 404 ERR_NOT_FOUND — when no session with that JTI exists.
36+
*/
37+
export const revokeSessionController: RequestHandler = async (req, res) => {
38+
const userId = req.user?.userId;
39+
40+
if (!userId) {
41+
throw new AppError(401, 'Unauthorized', ErrorCodes.UNAUTHORIZED);
42+
}
43+
44+
const { jti } = req.params;
45+
await revokeSession(userId, jti);
46+
sendResponse(res, 200, true, 'Session revoked successfully', null);
47+
};

src/modules/auth/session.model.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import mongoose from 'mongoose';
2+
import { isoDatePlugin } from '../../shared/plugins/isoDatePlugin.js';
3+
4+
export interface ISession {
5+
_id: string;
6+
userId: mongoose.Types.ObjectId | string;
7+
jti: string;
8+
ip?: string;
9+
userAgent?: string;
10+
createdAt: Date;
11+
lastUsedAt: Date;
12+
}
13+
14+
const SessionSchema = new mongoose.Schema(
15+
{
16+
userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true, index: true },
17+
jti: { type: String, required: true, unique: true, index: true },
18+
ip: { type: String, required: false },
19+
userAgent: { type: String, required: false },
20+
lastUsedAt: { type: Date, default: () => new Date() },
21+
},
22+
{ timestamps: true }
23+
);
24+
25+
SessionSchema.plugin(isoDatePlugin);
26+
27+
SessionSchema.index({ userId: 1, createdAt: -1 });
28+
29+
export const SessionModel = mongoose.model<ISession>('Session', SessionSchema);

0 commit comments

Comments
 (0)