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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,8 @@ If you prefer running Node processes on the host machine:
npm run start
```

Authentication now relies on **HttpOnly cookies** instead of `localStorage` tokens. Make sure the Admin UI is served from an origin listed in the `CORS_ORIGIN` env variable (defaults to `http://localhost:3001` in dev). When `NODE_ENV=development`, cookies are sent over plain HTTP for local testing; in production they require HTTPS.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠 Maintainability / Correctness
The added documentation still references REACT_APP_SERVER_URL, but the Admin UI is now Vite-based and expects the variable to be named VITE_REACT_APP_SERVER_URL. Using the old name results in an undefined endpoint at runtime. Please update the env-var name here (and anywhere else in the README) to avoid misconfiguration.


Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Correctness
JWT_SECRET is now a required environment variable for both the server (JWT signing) and tests, yet it is not documented in the setup instructions. Teams cloning the repo from scratch will hit runtime errors. Consider adding a short description of JWT_SECRET, recommended length, and an example value in this section (and .env.example if applicable).

By default the Admin UI expects the server at `http://localhost:3000`.
Change `REACT_APP_SERVER_URL` in `apps/hotel-management-service-admin/.env` if needed.

Expand Down
2 changes: 2 additions & 0 deletions apps/hotel-management-service-admin/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="A hotel management backend for managing hotels, rooms, customers, and reservations." />
<!-- Content Security Policy to mitigate XSS -->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; object-src 'none'; script-src 'self'; base-uri 'self';" />
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔐 Security / Correctness
Introducing a <meta> Content-Security-Policy while Helmet also sends a server-side CSP header leads to two independent policies whose intersection is enforced by browsers. This can unintentionally block legitimate inline scripts/styles (e.g., Vite’s HMR) or external assets, and increases maintenance overhead. Prefer removing the meta tag and consolidating CSP configuration within the server-side Helmet setup (using nonces or hashes if inline scripts must remain).


<title>Hotel Management Service</title>
</head>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { render } from "@testing-library/react";
import LoginPage from "../pages/LoginPage";

// Spy on localStorage methods
const setItemSpy = jest.spyOn(window.localStorage.__proto__, "setItem");
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧪 Test Reliability
The test spies on window.localStorage.__proto__, which is brittle and couples the test to private internals of JSDOM. More importantly, the spies are never restored, so they may leak into other test files and cause false positives/negatives.

const setItemSpy = jest.spyOn(Storage.prototype, 'setItem');
...
afterEach(() => {
  jest.restoreAllMocks();
});

Please update the spy target and add teardown logic to ensure isolation.

const getItemSpy = jest.spyOn(window.localStorage.__proto__, "getItem");

// Ensure spies don't affect actual implementation
setItemSpy.mockImplementation(() => undefined);
getItemSpy.mockImplementation(() => null);

describe("No localStorage usage", () => {
it("renders login page without touching localStorage", () => {
render(<LoginPage />);
expect(setItemSpy).not.toHaveBeenCalled();
expect(getItemSpy).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import { gql } from "@apollo/client/core";
import { AuthProvider } from "react-admin";
import {
CREDENTIALS_LOCAL_STORAGE_ITEM,
USER_DATA_LOCAL_STORAGE_ITEM,
} from "../constants";
// Using Basic auth over cookies is deprecated; we keep provider but without localStorage persistence.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧼 Maintainability
ra-auth-http.ts still describes a Basic Auth flow but no longer persists credentials or injects the header. The helper createBasicAuthorizationHeader() is now unused and login()’s comment promises behavior that is not implemented. Consider either:

  1. Fully wiring Basic Auth (set header via Apollo link + error handling), or
  2. Removing this provider to avoid dead/duplicated code and confusion.

At minimum, please align comments and implementation to prevent future developers from relying on outdated behavior.

import { Credentials, LoginMutateResult } from "../types";
import { apolloClient } from "../data-provider/graphqlDataProvider";

Expand All @@ -26,48 +23,21 @@ export const httpAuthProvider: AuthProvider = {
});

if (userData && userData.data?.login.username) {
localStorage.setItem(
CREDENTIALS_LOCAL_STORAGE_ITEM,
createBasicAuthorizationHeader(
credentials.username,
credentials.password
)
);
localStorage.setItem(
USER_DATA_LOCAL_STORAGE_ITEM,
JSON.stringify(userData.data)
);
// Basic auth header will be carried only within this runtime, not persisted.
return Promise.resolve();
}
return Promise.reject();
},
logout: () => {
localStorage.removeItem(CREDENTIALS_LOCAL_STORAGE_ITEM);
return Promise.resolve();
},
logout: () => Promise.resolve(),
checkError: ({ status }: any) => {
if (status === 401 || status === 403) {
localStorage.removeItem(CREDENTIALS_LOCAL_STORAGE_ITEM);
return Promise.reject();
}
return Promise.resolve();
},
checkAuth: () => {
return localStorage.getItem(CREDENTIALS_LOCAL_STORAGE_ITEM)
? Promise.resolve()
: Promise.reject();
},
checkAuth: () => Promise.resolve(),
getPermissions: () => Promise.reject("Unknown method"),
getIdentity: () => {
const str = localStorage.getItem(USER_DATA_LOCAL_STORAGE_ITEM);
const userData: LoginMutateResult = JSON.parse(str || "");

return Promise.resolve({
id: userData.login.username,
fullName: userData.login.username,
avatar: undefined,
});
},
getIdentity: () => Promise.resolve({ id: undefined, fullName: undefined, avatar: undefined }),
};

function createBasicAuthorizationHeader(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import { gql } from "@apollo/client/core";
import { AuthProvider } from "react-admin";
import {
CREDENTIALS_LOCAL_STORAGE_ITEM,
USER_DATA_LOCAL_STORAGE_ITEM,
} from "../constants";
// Note: We no longer persist credentials in localStorage.
import { Credentials, LoginMutateResult } from "../types";
import { apolloClient } from "../data-provider/graphqlDataProvider";

Expand All @@ -26,47 +23,52 @@ export const jwtAuthProvider: AuthProvider = {
});

if (userData && userData.data?.login.username) {
localStorage.setItem(
CREDENTIALS_LOCAL_STORAGE_ITEM,
createBearerAuthorizationHeader(userData.data.login?.accessToken)
);
localStorage.setItem(
USER_DATA_LOCAL_STORAGE_ITEM,
JSON.stringify(userData.data)
);
// Cookie is automatically set by the server; nothing to persist client-side
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧼 Maintainability / Security
The LOGIN mutation still requests the accessToken field even though the client no longer consumes it (cookies are used). This exposes the token unnecessarily and increases risk of accidental logging or leakage. Please drop accessToken from the selection set and server response for minimal surface area.

return Promise.resolve();
}
return Promise.reject();
},
logout: () => {
localStorage.removeItem(CREDENTIALS_LOCAL_STORAGE_ITEM);
return Promise.resolve();
logout: async () => {
// Invoke backend mutation to clear HttpOnly cookie
const LOGOUT = gql`
mutation logout {
logout
}
`;
try {
await apolloClient.mutate({ mutation: LOGOUT });
// Apollo will include cookies automatically (credentials: include)
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧪 Correctness / UX
logout() invokes the backend mutation but never clears the Apollo cache. As a result, queries such as me may remain cached and continue to show stale user data until a full page reload. Consider adding await apolloClient.clearStore() (or resetStore()) after a successful logout to ensure client state is consistent.

return Promise.resolve();
} catch (e) {
return Promise.reject(e);
}
},
checkError: ({ status }: any) => {
if (status === 401 || status === 403) {
localStorage.removeItem(CREDENTIALS_LOCAL_STORAGE_ITEM);
return Promise.reject();
}
return Promise.resolve();
},
checkAuth: () => {
return localStorage.getItem(CREDENTIALS_LOCAL_STORAGE_ITEM)
? Promise.resolve()
: Promise.reject();
checkAuth: async () => {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠 Correctness
checkError (and related auth handling) currently inspects only HTTP status codes, but GraphQL layer returns auth failures via error.graphQLErrors with code UNAUTHENTICATED. Without handling that path, the admin will silently stay on a broken session. Please extend error processing to include GraphQL errors or add an Apollo error link to centralize auth error handling.

// Query backend `me` (lightweight) to confirm cookie validity
const ME = gql`
query me {
me {
id
}
}
`;
try {
await apolloClient.query({ query: ME, fetchPolicy: "network-only" });
return Promise.resolve();
} catch (error) {
// If request fails due to 401/403 or network error, treat as unauthenticated
return Promise.reject(error);
}
},
getPermissions: () => Promise.reject("Unknown method"),
getIdentity: () => {
const str = localStorage.getItem(USER_DATA_LOCAL_STORAGE_ITEM);
const userData: LoginMutateResult = JSON.parse(str || "");

return Promise.resolve({
id: userData.login.username,
fullName: userData.login.username,
avatar: undefined,
});
// TODO: Optionally fetch current user via a `me` query. For now, return empty.
return Promise.resolve({ id: undefined, fullName: undefined, avatar: undefined });
},
};

export function createBearerAuthorizationHeader(accessToken: string) {
return `Bearer ${accessToken}`;
}
34 changes: 0 additions & 34 deletions apps/hotel-management-service-admin/src/auth.ts

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,26 +1,28 @@
import buildGraphQLProvider from "ra-data-graphql-amplication";
import { ApolloClient, InMemoryCache, createHttpLink } from "@apollo/client";
import { setContext } from "@apollo/client/link/context";
import { CREDENTIALS_LOCAL_STORAGE_ITEM } from "../constants";
// We no longer need to manually inject an Authorization header; rely on cookies.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠 Correctness
VITE_REACT_APP_SERVER_URL is read at build time; if it is missing the resulting client points to an undefined URL and fails silently. Consider validating import.meta.env.VITE_REACT_APP_SERVER_URL at startup and throwing a clear error or providing a sensible default to aid developers.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Completely removing the Authorization header means this admin client will not work during the planned dual-auth migration window where the server still accepts headers for legacy consumers. Consider keeping a conditional authLink that sends the header only when a token exists in memory to ease rollout and fallback.


const httpLink = createHttpLink({
uri: `${import.meta.env.VITE_REACT_APP_SERVER_URL}/graphql`,
credentials: "include", // send cookies with each request
});

// eslint-disable-next-line @typescript-eslint/naming-convention
const authLink = setContext((_, { headers }) => {
const token = localStorage.getItem(CREDENTIALS_LOCAL_STORAGE_ITEM);
return {
headers: {
...headers,
authorization: token ? token : "",
},
};
});

export const apolloClient = new ApolloClient({
cache: new InMemoryCache(),
link: authLink.concat(httpLink),
link: httpLink,
// Always include credentials (cookies) in requests
defaultOptions: {
watchQuery: {
fetchPolicy: "cache-and-network",
},
query: {
fetchPolicy: "network-only",
},
mutate: {
fetchPolicy: "no-cache",
},
},
});

export default buildGraphQLProvider({
Expand Down
4 changes: 3 additions & 1 deletion apps/hotel-management-service-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ Configuration for the server component can be provided through the use of enviro
| DB_USER | the username used to connect to the database | [username] |
| DB_PASSWORD | the password used to connect to the database | [password] |
| DB_NAME | the name of the database | [service-name] / [project-name] |
| JWT_SECRET_KEY | the secret used to sign the json-web token | [secret] |
| JWT_SECRET | the secret used to sign the JSON Web Token | [secret] |
| CORS_ORIGIN | comma-separated list of allowed origins for CORS (e.g., http://localhost:3001) | http://localhost:3001 |
| NODE_ENV | set to `development` to allow non-secure cookies over HTTP | development |
| JWT_EXPIRATION | the expiration time for the json-web token | 2d |

> **Note**
Expand Down
5 changes: 4 additions & 1 deletion apps/hotel-management-service-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,10 @@
"reflect-metadata": "0.1.13",
"ts-node": "10.9.2",
"type-fest": "2.19.0",
"validator": "13.11.0"
"validator": "13.11.0",
"cookie-parser": "^1.4.6",
"@nestjs/helmet": "^11.0.1",
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nestjs/helmet@^11.0.1 does not exist on npm (current latest major is 10). Including both @nestjs/helmet and helmet will lead to install conflicts/duplicate functionality. Please remove the unused/invalid dependency (or pin to an existing version) and keep only helmet, updating imports accordingly. This otherwise breaks npm install and increases attack surface.

"helmet": "^7.0.0"
},
"devDependencies": {
"@nestjs/cli": "^10.1.18",
Expand Down
2 changes: 2 additions & 0 deletions apps/hotel-management-service-server/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { RoomModule } from "./room/room.module";
import { ReservationModule } from "./reservation/reservation.module";
import { CustomerModule } from "./customer/customer.module";
import { HealthModule } from "./health/health.module";
import { AuthModule } from "./auth/auth.module";
import { PrismaModule } from "./prisma/prisma.module";
import { SecretsManagerModule } from "./providers/secrets/secretsManager.module";
import { ServeStaticModule } from "@nestjs/serve-static";
Expand All @@ -15,6 +16,7 @@ import { ApolloDriver, ApolloDriverConfig } from "@nestjs/apollo";
@Module({
controllers: [],
imports: [
AuthModule,
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ConfigModule.forRoot() must be registered before any module that depends on configuration values (e.g. AuthModule which reads JWT_SECRET). Because AuthModule is imported earlier in the array at runtime, ConfigService may return undefined for env vars, leading to the weak fallback secret in auth.module.ts. Please move ConfigModule.forRoot() to the top of the imports list to guarantee proper initialization.

HotelModule,
RoomModule,
ReservationModule,
Expand Down
27 changes: 27 additions & 0 deletions apps/hotel-management-service-server/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Module } from "@nestjs/common";
import { JwtModule } from "@nestjs/jwt";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { AuthService } from "./auth.service";
import { AuthResolver } from "./auth.resolver";

@Module({
imports: [
ConfigModule,
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
secret: config.get<string>("JWT_SECRET", "change-me"),
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using a hard-coded fallback secret ("change-me") means the application will silently boot with an insecure JWT signing key when JWT_SECRET is missing, creating a critical security vulnerability. Prefer throwing at startup if JWT_SECRET is absent (e.g., via ConfigModule validation schema) instead of defaulting to an insecure value.

signOptions: {
expiresIn: config.get<string | number>(
"JWT_EXPIRES_IN",
"24h"
),
},
}),
inject: [ConfigService],
}),
],
providers: [AuthService, AuthResolver],
exports: [AuthService],
})
export class AuthModule {}
37 changes: 37 additions & 0 deletions apps/hotel-management-service-server/src/auth/auth.resolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Resolver, Mutation, Args } from "@nestjs/graphql";
import { CredentialsInput } from "./credentials.input";
import { LoginResponse } from "./login-response.object";
import { AuthService } from "./auth.service";
import { Response } from "express";
import { Res } from "@nestjs/common";

@Resolver()
export class AuthResolver {
constructor(private readonly authService: AuthService) {}

@Mutation(() => LoginResponse)
async login(
@Args("credentials") credentials: CredentialsInput,
@Res({ passthrough: true }) res: Response
): Promise<LoginResponse> {
const { username, password } = credentials;
const accessToken = await this.authService.validateUser(username, password).then(() => this.authService.login(username));

// Set HttpOnly cookie for access token
res.cookie("accessToken", accessToken, {
httpOnly: true,
sameSite: "strict",
secure: process.env.NODE_ENV !== "development",
path: "/",
maxAge: Number(process.env.JWT_COOKIE_MAX_AGE ?? 1000 * 60 * 60 * 24), // default 1 day
});

return { accessToken };
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Returning the accessToken in the GraphQL response undermines the goal of protecting the JWT in an HttpOnly cookie—front-end JavaScript (and potentially XSS) can still read it. Once cookie-based auth is fully adopted, remove the accessToken field from LoginResponse (and stop returning it here) to avoid re-exposing the token.

}

@Mutation(() => Boolean)
async logout(@Res({ passthrough: true }) res: Response): Promise<boolean> {
res.clearCookie("accessToken", { path: "/" });
return true;
}
}
Loading