Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
83a9a93
Implement server-side session invalidation using token_invalid_before
Oykunle Mar 31, 2026
53b5fd8
Implement server-side session invalidation with token_invalid_before
Oykunle Apr 7, 2026
c5eb0f7
Address review comments: fix race condition, improve auth logic, and …
Oykunle Apr 13, 2026
12f5b00
Address review comments for session invalidation auth and tests
Oykunle Apr 16, 2026
ec6dcd2
Restore non-root web container runtime user
Oykunle Apr 20, 2026
bd1e3a2
Remove unrelated Dockerfile change from session invalidation PR
Oykunle Apr 23, 2026
c50b6ef
Add explicit explanation for logout route authentication handling
Oykunle Apr 23, 2026
bfd6a48
Use generic authentication failure response in verification route
Oykunle Apr 23, 2026
78c3d32
Remove testUser2 from common test setup
Oykunle Apr 23, 2026
7e2a6a1
Create CSV test user locally in session invalidation tests
Oykunle Apr 23, 2026
c6a70cb
Use millisecond precision for token invalidation comparison
Oykunle Apr 23, 2026
faa7977
Update login route to /api/login/login across tests and API usage
Oykunle Apr 23, 2026
0b70722
Fix session invalidation test setup
Oykunle Apr 28, 2026
24e6a16
Update client login route to match /api/login/login
Oykunle Apr 28, 2026
3e15866
Rename login route file to loginLogout
Oykunle Apr 28, 2026
25ac170
Resolve merge conflicts with upstream/development and integrate sessi…
Oykunle Apr 28, 2026
242472f
Remove unused import and clean up usersTest assertions
Oykunle Apr 28, 2026
c707bfc
Update session invalidation tests to use HTTP_CODES
Oykunle Apr 28, 2026
5224d21
Merge remote-tracking branch 'origin/development' into pr/Oykunle/1593
May 19, 2026
13b7856
add missing message assertions to session invalidation tests
Better-Code-Saul-E Jul 15, 2026
d9aeabb
rename login variable to loginLogout for consistency
Better-Code-Saul-E Jul 16, 2026
287e6c8
rename /api/login route prefix to /api/loginLogout
Better-Code-Saul-E Jul 16, 2026
dc0c3b1
merge upstream/development into session-invalidation
Better-Code-Saul-E Jul 18, 2026
f3ee421
add missing MPL 2.0 license header
Better-Code-Saul-E Jul 21, 2026
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
23 changes: 15 additions & 8 deletions src/client/app/redux/api/authApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export const authApi = baseApi.injectEndpoints({
endpoints: builder => ({
login: builder.mutation<LoginResponse, { username: string, password: string }>({
query: loginArgs => ({
url: 'api/login',
url: 'api/loginLogout/login',
method: 'POST',
body: loginArgs
}),
Expand All @@ -42,17 +42,24 @@ export const authApi = baseApi.injectEndpoints({
return { data: null };
}
}),
logout: builder.mutation<null, void>({
queryFn: (_, { dispatch }) => {
// Opt to use a RTK mutation instead of manually writing a thunk to take advantage mutation invalidations
deleteToken();
dispatch(currentUserSlice.actions.clearCurrentUser());
return { data: null };
logout: builder.mutation<{ success: boolean, message: string }, void>({
query: () => ({
url: 'api/loginLogout/logout',
method: 'POST',
body: { token: getToken() }
}),
async onQueryStarted(_arg, { dispatch, queryFulfilled }) {
try {
await queryFulfilled;
} finally {
deleteToken();
dispatch(currentUserSlice.actions.clearCurrentUser());
}
},
invalidatesTags: ['MeterData', 'GroupData']
})
})
});

// Poll interval in milliseconds (1 minute)
export const authPollInterval = 60000;
export const authPollInterval = 60000;
4 changes: 2 additions & 2 deletions src/server/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const users = require('./routes/users');
const readings = require('./routes/readings');
const meters = require('./routes/meters');
const preferences = require('./routes/preferences');
const login = require('./routes/login');
const loginLogout = require('./routes/loginLogout');
const verification = require('./routes/verification');
const groups = require('./routes/groups');
const version = require('./routes/version');
Expand Down Expand Up @@ -132,7 +132,7 @@ app.use('/api/users', users);
app.use('/api/meters', meters);
app.use('/api/readings', readings);
app.use('/api/preferences', preferences);
app.use('/api/login', login);
app.use('/api/loginLogout', loginLogout);
app.use('/api/groups', groups);
app.use('/api/verification', verification);
app.use('/api/version', version);
Expand Down
1 change: 1 addition & 0 deletions src/server/migrations/1.0.0-2.0.0/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ module.exports = {
await db.none(sqlFile('../migrations/1.0.0-2.0.0/sql/preferences/add_graph_type.sql'));
await db.none(sqlFile('../migrations/1.0.0-2.0.0/sql/preferences/add_preferences_help_url.sql'));
await db.none(sqlFile('../migrations/1.0.0-2.0.0/sql/users/add_users_note.sql'));
await db.none(sqlFile('../migrations/1.0.0-2.0.0/sql/users/add_token_invalid_before.sql'));
await db.none(sqlFile('../migrations/1.0.0-2.0.0/sql/users/alter_users_table.sql'));
// It should not matter but first rename cik and then do units.
await db.none(sqlFile('../migrations/1.0.0-2.0.0/sql/cik/alter_cik_table.sql'));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

ALTER TABLE users
ADD COLUMN IF NOT EXISTS token_invalid_before TIMESTAMP NOT NULL DEFAULT TIMESTAMP 'epoch';
44 changes: 38 additions & 6 deletions src/server/models/User.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,35 @@ class User {
* @param passwordHash The user's passwordHash
* @param role The user's role
* @param note The user note
* @param tokenInvalidBefore Timestamp before which issued tokens are invalid
*/
constructor(id, username, passwordHash, role, note = '') {
constructor(id, username, passwordHash, role, note = '', tokenInvalidBefore = null) {
this.id = id;
this.username = username;
this.passwordHash = passwordHash;
this.role = role;
this.note = note;
this.tokenInvalidBefore = tokenInvalidBefore;
}

/**
* Maps a database row to a User model.
* @param row
* @returns {User}
*/
static mapRow(row) {
if (row === null) {
return null;
}

return new User(
row.id,
row.username,
row.password_hash,
row.role,
row.note,
row.token_invalid_before
);
}

/**
Expand All @@ -41,7 +63,7 @@ class User {
*/
static async getByID(id, conn) {
const row = await conn.one(sqlFile('user/get_user_by_id.sql'), { id: id });
return new User(row.id, row.username, row.password_hash, row.role, row.note);
return User.mapRow(row);
}

/**
Expand All @@ -54,7 +76,7 @@ class User {
*/
static async getByUsername(username, conn) {
const row = await conn.oneOrNone(sqlFile('user/get_user_by_username.sql'), { username: username });
return row === null ? null : new User(row.id, row.username, row.password_hash, row.role, row.note);
return User.mapRow(row);
}

/**
Expand All @@ -73,15 +95,15 @@ class User {
*/
static async getAll(conn) {
const rows = await conn.any(sqlFile('user/get_all_users.sql'));
return rows.map(row => new User(row.id, row.username, undefined, row.role, row.note));
return rows.map(User.mapRow);
}

/**
* Returns a promise to update a user's password
* @param id the id of the user whose password is to be updated
* @param passwordHash the new password's hash
* @param conn is the connection to use.
* @returns {Promise.<array.<User>>}
* @returns {Promise.<void>}
*/
static async updateUserPassword(id, passwordHash, conn) {
return conn.none(sqlFile('user/update_user_password.sql'), { id: id, password_hash: passwordHash });
Expand Down Expand Up @@ -132,6 +154,16 @@ class User {
return conn.none(sqlFile('user/update_user.sql'), { id: id, username: username, role: role, note: note });
}

/**
* Returns a promise to invalidate all tokens issued before now for a user.
* @param id the id of the user whose tokens are to be invalidated
* @param conn is the connection to use.
* @returns {Promise<void>}
*/
static async invalidateTokensBeforeNow(id, conn) {
return conn.none(sqlFile('user/update_token_invalid_before.sql'), { id: id });
}

/**
* Returns a promise to delete a user
* @param username the username of the user
Expand Down Expand Up @@ -168,7 +200,7 @@ class User {

/**
* Enum of roles.
* This enum needs to be kept in sync with the src/server/sql/create_user_types_enum.sql and the UserRoles enum in src/client/types/items.ts
* This enum needs to be kept in sync with the src/server/sql/create_user_types_enum.sql and the UserRoles enum in src/client/types/items.ts
* @enum {string}
*/
User.role = Object.freeze({
Expand Down
115 changes: 79 additions & 36 deletions src/server/routes/authenticator.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,41 +16,36 @@ const { PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH, TOKEN_MAX_LENGTH, USERNAME_MIN
= require('../util/validationConstants');

/**
* Middleware function to force a route to require authentication
* Verifies the request's token against the server's secret token
* It is used within this file but not by other parts of OED.
* Middleware function to require authentication on protected routes.
* Verifies the request's token, ensures the user exists, and checks that
* the token has not been invalidated.
* This middleware was created to only be used within this file.
*/
authMiddleware = (req, res, next) => {
const authMiddleware = (req, res, next) => {
const token = req.headers.token || req.body.token || req.query.token;
const validParams = {
type: 'string',
maxLength: TOKEN_MAX_LENGTH
};

if (!validate(token, validParams).valid) {
res.status(HTTP_CODES.FORBIDDEN).json({ success: false, message: 'No token provided or JSON was invalid.' });
} else if (token) {
jwt.verify(token, secretToken, async (err, decoded) => {
if (err) {
verifyActiveTokenAndGetUser(token)
.then(({ decoded }) => {
req.decoded = decoded;
next();
})
.catch(() => {
res.status(HTTP_CODES.UNAUTHORIZED).json({ success: false, message: 'Failed to authenticate token.' });
} else {
try {
const conn = getConnection();
// checks if user exists in the database in case it was deleted
await User.getByID(decoded.data, conn);
req.decoded = decoded;
next();
} catch (error) {
res.status(HTTP_CODES.UNAUTHORIZED).json({ success: false, message: 'User does not exist in database.' });
}
}
});
});
} else {
res.status(HTTP_CODES.FORBIDDEN).send({ success: false, message: 'No token provided.' });
}
};

/**
* Middleware that checks the request body for the username and password parameters. If the body contains the username and password parameters, then next
* Middleware that checks the request body for the username and password parameters. If the body contains the username and password parameters, then next
* is executed. Otherwise, the server responds with a 400 error.
*/
function credentialsRequestValidationMiddleware(req, res, next) {
Expand Down Expand Up @@ -82,9 +77,9 @@ function credentialsRequestValidationMiddleware(req, res, next) {

/**
* Verifies the username and password of a user.
* @param {string} username
* @param {string} password
* @param {boolean} returnUser
* @param {string} username
* @param {string} password
* @param {boolean} returnUser
* @returns true if the user exists in the database. False otherwise. Returns the user itself if returnUser is set to true and user is verified.
*/
async function verifyCredentials(username, password, returnUser = false) {
Expand All @@ -104,14 +99,60 @@ async function verifyCredentials(username, password, returnUser = false) {
}
}

/**
* Verifies a JWT, ensures the user exists, and rejects tokens that were invalidated.
* Returns the decoded token and matching user when successful.
* @param {string} token
* @returns {Promise<{decoded: object, user: User}>}
*/
async function verifyActiveTokenAndGetUser(token) {
const decoded = await new Promise((resolve, reject) => {
jwt.verify(token, secretToken, (err, payload) => {
if (err) {
reject(err);
} else {
resolve(payload);
}
});
});

// jwt.verify confirms the token signature is valid, but it does not guarantee
// the referenced user still exists in the database. The user may have been
// deleted after the token was issued, so OED must still verify the user record.
const conn = getConnection();
const user = await User.getByID(decoded.data, conn);

// Compare timestamps at millisecond precision to avoid edge cases caused by
// JWT iat being stored in seconds while the database timestamp is more precise.
const tokenIssuedAtMs = decoded.iat * 1000;
let invalidBeforeMs = 0;

if (user.tokenInvalidBefore) {
const parsedDate = new Date(user.tokenInvalidBefore);
if (!isNaN(parsedDate.getTime())) {
invalidBeforeMs = parsedDate.getTime();
} else {
log.error(`Invalid tokenInvalidBefore value for user ${user.id}`);
}
}

if (tokenIssuedAtMs <= invalidBeforeMs) {
const error = new Error('Token invalidated');
error.code = 'TOKEN_INVALIDATED';
throw error;
}

return { decoded, user };
}

/**
* Returns middleware that verifies the requested token and only proceeds if the requestor is a particular user role or is Admin.
* @param {string} role
* @param action
* @param {string} role
* @param action
*/
function roleTokenAuthMiddleware(role, action) {
return function (req, res, next) {
this.authMiddleware(req, res, async () => {
authMiddleware(req, res, async () => {
const token = req.headers.token || req.body.token || req.query.token;
if (await isTokenAuthorized(token, role)) {
next();
Expand All @@ -120,8 +161,8 @@ function roleTokenAuthMiddleware(role, action) {
res.status(HTTP_CODES.FORBIDDEN)
.json({ message: `Invalid credentials supplied. Only ${role.toUpperCase()} can ${action}.` });
}
})
}
});
};
}

/**
Expand Down Expand Up @@ -179,15 +220,15 @@ function obviusUsernameAndPasswordAuthMiddleware(action) {
}
}
});
}
};
}

/**
* Middleware function to force a route to provide optional authentication
* Verifies the request's token against the server's secret token
* Sets the req field hasValidAuthToken to true or false
*/
optionalAuthMiddleware = (req, res, next) => {
const optionalAuthMiddleware = (req, res, next) => {
// Set auth token to false initially.
req.hasValidAuthToken = false;

Expand All @@ -201,26 +242,28 @@ optionalAuthMiddleware = (req, res, next) => {
if (!validate(token, validParams).valid) {
next();
} else if (token) {
jwt.verify(token, secretToken, (err, decoded) => {
if (err) {
// do nothing. Could log here if need be
} else {
verifyActiveTokenAndGetUser(token)
.then(({ decoded }) => {
req.decoded = decoded;
req.hasValidAuthToken = true;
}
next();
});
next();
})
.catch(() => {
next();
});
} else {
next();
}
};

module.exports = {
authMiddleware,
adminAuthMiddleware,
csvAuthMiddleware,
exportAuthMiddleware,
obviusUsernameAndPasswordAuthMiddleware,
optionalAuthMiddleware,
verifyCredentials,
verifyActiveTokenAndGetUser,
credentialsRequestValidationMiddleware
};
Loading
Loading