Skip to content
Open
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
31 changes: 31 additions & 0 deletions backend/src/auth/__test__/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const mockAdminDeleteUser = vi.fn(() => ({ promise: mockCognitoPromise
const mockInitiateAuth = vi.fn(() => ({ promise: mockCognitoPromise }));
const mockGetUser = vi.fn(() => ({ promise: mockCognitoPromise }));
const mockRespondToAuthChallenge = vi.fn(() => ({ promise: mockCognitoPromise }));
const mockGlobalSignOut = vi.fn(() => ({ promise: mockCognitoPromise }));

// ─── DynamoDB mocks ───────────────────────────────────────────────────────────
const mockDynamoPromise = vi.fn();
Expand All @@ -41,6 +42,7 @@ vi.mock('aws-sdk', () => {
initiateAuth: mockInitiateAuth,
getUser: mockGetUser,
respondToAuthChallenge: mockRespondToAuthChallenge,
globalSignOut: mockGlobalSignOut,
};
});

Expand Down Expand Up @@ -86,6 +88,7 @@ describe('AuthService', () => {
mockInitiateAuth.mockReturnValue({ promise: mockCognitoPromise });
mockGetUser.mockReturnValue({ promise: mockCognitoPromise });
mockRespondToAuthChallenge.mockReturnValue({ promise: mockCognitoPromise });
mockGlobalSignOut.mockReturnValue({ promise: mockCognitoPromise });

mockDynamoGet.mockReturnValue({ promise: mockDynamoPromise });
mockDynamoPut.mockReturnValue({ promise: mockDynamoPromise });
Expand Down Expand Up @@ -474,4 +477,32 @@ describe('AuthService', () => {
await expect(service.validateSession('bad-token')).rejects.toThrow('Failed to validate session');
});
});

describe('logout', () => {
it('should successfully sign out user from Cognito', async () => {
mockCognitoPromise.mockResolvedValueOnce({});

await expect(service.logout('valid-access-token')).resolves.toBeUndefined();

expect(mockGlobalSignOut).toHaveBeenCalledWith({
AccessToken: 'valid-access-token',
});
});

it('should not throw error when Cognito sign out fails', async () => {
mockCognitoPromise.mockRejectedValueOnce(new Error('Cognito error'));

await expect(service.logout('some-token')).resolves.toBeUndefined();
expect(mockGlobalSignOut).toHaveBeenCalled();
});

it('should not throw error when token is already expired', async () => {
mockCognitoPromise.mockRejectedValueOnce({
code: 'NotAuthorizedException',
message: 'Token expired'
});

await expect(service.logout('expired-token')).resolves.toBeUndefined();
});
});
});
41 changes: 41 additions & 0 deletions backend/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,47 @@ export class AuthController {
return { message: 'User registered successfully' };
}

/**
* Logs out a user by clearing authentication cookies
*/
@Post('logout')
@ApiResponse({
status: 200,
description: "User logged out successfully"
})
@ApiResponse({
status: 500,
description: "Internal Server Error"
})
async logout(
@Res({ passthrough: true }) response: Response,
@Req() req: any
): Promise<{ message: string }> {
const cookieToken = req.cookies?.access_token;
let token: string | undefined = cookieToken;

if (!token) {
const authHeader = req.headers['authorization'] || req.headers['Authorization'];
if (authHeader && typeof authHeader === 'string') {
token = authHeader.startsWith('Bearer ')
? authHeader.substring(7)
: authHeader;
}
}

// Logout user in Cognito
if (token) {
await this.authService.logout(token);
}

// Clear all cookies
response.clearCookie('access_token', { path: '/' });
response.clearCookie('refresh_token', { path: '/auth/refresh' });
response.clearCookie('id_token', { path: '/' });

return { message: 'Logged out successfully' };
}

/**
* Logs in a user
*/
Expand Down
17 changes: 17 additions & 0 deletions backend/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,23 @@ export class AuthService {
return emailRegex.test(email);
}

// purpose statement: logs out a user by invalidating their Cognito session
async logout(accessToken: string): Promise<void> {
try {
await this.cognito
.globalSignOut({
AccessToken: accessToken,
})
.promise();

this.logger.log('User signed out successfully from Cognito');
} catch (error) {
this.logger.error('Error during Cognito sign out:', error);
// Don't throw error since we still clear cookies in controller
// This handles cases where token is already expired
}
}

// purpose statement: logs in an user via cognito and retrieves user data from dynamodb
// use case: employee is trying to access the app, needs to have an account already

Expand Down
35 changes: 34 additions & 1 deletion frontend/src/context/auth/authContext.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useContext, createContext, ReactNode } from 'react';
import { useContext, createContext, ReactNode, useEffect, useRef } from 'react';
import { getAppStore } from '../../external/bcanSatchel/store';
import { setAuthState, logoutUser } from '../../external/bcanSatchel/actions';
import { observer } from 'mobx-react-lite';
Expand Down Expand Up @@ -31,6 +31,10 @@ export const useAuthContext = () => {

export const AuthProvider = observer(({ children }: { children: ReactNode }) => {
const store = getAppStore();
const logoutTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Auto-logout timeout duration (in milliseconds)
// 8 hours = 8 * 60 * 60 * 1000
const SESSION_TIMEOUT = 8 * 60 * 60 * 1000;

/** Attempt to log in the user */
const login = async (email: string, password: string): Promise<boolean> => {
Expand Down Expand Up @@ -100,8 +104,37 @@ export const AuthProvider = observer(({ children }: { children: ReactNode }) =>
const logout = () => {
api('/auth/logout', { method: 'POST' });
logoutUser();
// Clear the logout timer when user manually logs out
if (logoutTimerRef.current) {
clearTimeout(logoutTimerRef.current);
logoutTimerRef.current = null;
}
};

/** Start the auto-logout timer (8 hours from now) */
useEffect(() => {
if (!store.isAuthenticated) {
// Clear timer if user is not authenticated
if (logoutTimerRef.current) {
clearTimeout(logoutTimerRef.current);
logoutTimerRef.current = null;
}
return;
}

// Start the 8-hour timer when user logs in
logoutTimerRef.current = setTimeout(() => {
logout();
}, SESSION_TIMEOUT);

// Cleanup: clear timer on unmount or logout
return () => {
if (logoutTimerRef.current) {
clearTimeout(logoutTimerRef.current);
}
};
}, [store.isAuthenticated]);

/** Restore user session on refresh */
// useEffect(() => {
// api('/auth/session')
Expand Down