diff --git a/README.md b/README.md
index 707645a..95f1ed1 100644
--- a/README.md
+++ b/README.md
@@ -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.
+
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.
diff --git a/apps/hotel-management-service-admin/index.html b/apps/hotel-management-service-admin/index.html
index 14174e5..f9a6b5f 100644
--- a/apps/hotel-management-service-admin/index.html
+++ b/apps/hotel-management-service-admin/index.html
@@ -6,6 +6,8 @@
+
+
Hotel Management Service
diff --git a/apps/hotel-management-service-admin/src/__tests__/localStorage.test.ts b/apps/hotel-management-service-admin/src/__tests__/localStorage.test.ts
new file mode 100644
index 0000000..cd6e96c
--- /dev/null
+++ b/apps/hotel-management-service-admin/src/__tests__/localStorage.test.ts
@@ -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");
+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();
+ expect(setItemSpy).not.toHaveBeenCalled();
+ expect(getItemSpy).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/hotel-management-service-admin/src/auth-provider/ra-auth-http.ts b/apps/hotel-management-service-admin/src/auth-provider/ra-auth-http.ts
index c6eeba8..8c6e60e 100644
--- a/apps/hotel-management-service-admin/src/auth-provider/ra-auth-http.ts
+++ b/apps/hotel-management-service-admin/src/auth-provider/ra-auth-http.ts
@@ -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.
import { Credentials, LoginMutateResult } from "../types";
import { apolloClient } from "../data-provider/graphqlDataProvider";
@@ -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(
diff --git a/apps/hotel-management-service-admin/src/auth-provider/ra-auth-jwt.ts b/apps/hotel-management-service-admin/src/auth-provider/ra-auth-jwt.ts
index c8bcafc..a9bb691 100644
--- a/apps/hotel-management-service-admin/src/auth-provider/ra-auth-jwt.ts
+++ b/apps/hotel-management-service-admin/src/auth-provider/ra-auth-jwt.ts
@@ -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";
@@ -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
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)
+ 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 () => {
+ // 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}`;
-}
diff --git a/apps/hotel-management-service-admin/src/auth.ts b/apps/hotel-management-service-admin/src/auth.ts
deleted file mode 100644
index 498b026..0000000
--- a/apps/hotel-management-service-admin/src/auth.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import { EventEmitter } from "events";
-import { CREDENTIALS_LOCAL_STORAGE_ITEM } from "./constants";
-import { Credentials } from "./types";
-
-const eventEmitter = new EventEmitter();
-
-export function isAuthenticated(): boolean {
- return Boolean(getCredentials());
-}
-
-export function listen(listener: (authenticated: boolean) => void): void {
- eventEmitter.on("change", () => {
- listener(isAuthenticated());
- });
-}
-
-export function setCredentials(credentials: Credentials) {
- localStorage.setItem(
- CREDENTIALS_LOCAL_STORAGE_ITEM,
- JSON.stringify(credentials)
- );
-}
-
-export function getCredentials(): Credentials | null {
- const raw = localStorage.getItem(CREDENTIALS_LOCAL_STORAGE_ITEM);
- if (raw === null) {
- return null;
- }
- return JSON.parse(raw);
-}
-
-export function removeCredentials(): void {
- localStorage.removeItem(CREDENTIALS_LOCAL_STORAGE_ITEM);
-}
diff --git a/apps/hotel-management-service-admin/src/data-provider/graphqlDataProvider.ts b/apps/hotel-management-service-admin/src/data-provider/graphqlDataProvider.ts
index 2deee26..f530062 100644
--- a/apps/hotel-management-service-admin/src/data-provider/graphqlDataProvider.ts
+++ b/apps/hotel-management-service-admin/src/data-provider/graphqlDataProvider.ts
@@ -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.
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({
diff --git a/apps/hotel-management-service-server/README.md b/apps/hotel-management-service-server/README.md
index bca0445..b4b412d 100644
--- a/apps/hotel-management-service-server/README.md
+++ b/apps/hotel-management-service-server/README.md
@@ -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**
diff --git a/apps/hotel-management-service-server/package.json b/apps/hotel-management-service-server/package.json
index 06b5482..06631e3 100644
--- a/apps/hotel-management-service-server/package.json
+++ b/apps/hotel-management-service-server/package.json
@@ -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",
+ "helmet": "^7.0.0"
},
"devDependencies": {
"@nestjs/cli": "^10.1.18",
diff --git a/apps/hotel-management-service-server/src/app.module.ts b/apps/hotel-management-service-server/src/app.module.ts
index da8499c..5dfa7d2 100644
--- a/apps/hotel-management-service-server/src/app.module.ts
+++ b/apps/hotel-management-service-server/src/app.module.ts
@@ -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";
@@ -15,6 +16,7 @@ import { ApolloDriver, ApolloDriverConfig } from "@nestjs/apollo";
@Module({
controllers: [],
imports: [
+ AuthModule,
HotelModule,
RoomModule,
ReservationModule,
diff --git a/apps/hotel-management-service-server/src/auth/auth.module.ts b/apps/hotel-management-service-server/src/auth/auth.module.ts
new file mode 100644
index 0000000..8395dc8
--- /dev/null
+++ b/apps/hotel-management-service-server/src/auth/auth.module.ts
@@ -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("JWT_SECRET", "change-me"),
+ signOptions: {
+ expiresIn: config.get(
+ "JWT_EXPIRES_IN",
+ "24h"
+ ),
+ },
+ }),
+ inject: [ConfigService],
+ }),
+ ],
+ providers: [AuthService, AuthResolver],
+ exports: [AuthService],
+})
+export class AuthModule {}
diff --git a/apps/hotel-management-service-server/src/auth/auth.resolver.ts b/apps/hotel-management-service-server/src/auth/auth.resolver.ts
new file mode 100644
index 0000000..3bf8fd5
--- /dev/null
+++ b/apps/hotel-management-service-server/src/auth/auth.resolver.ts
@@ -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 {
+ 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 };
+ }
+
+ @Mutation(() => Boolean)
+ async logout(@Res({ passthrough: true }) res: Response): Promise {
+ res.clearCookie("accessToken", { path: "/" });
+ return true;
+ }
+}
diff --git a/apps/hotel-management-service-server/src/auth/auth.service.ts b/apps/hotel-management-service-server/src/auth/auth.service.ts
new file mode 100644
index 0000000..347f93c
--- /dev/null
+++ b/apps/hotel-management-service-server/src/auth/auth.service.ts
@@ -0,0 +1,26 @@
+import { Injectable, UnauthorizedException } from "@nestjs/common";
+import { JwtService } from "@nestjs/jwt";
+import * as bcrypt from "bcrypt";
+import { PrismaService } from "../prisma/prisma.service";
+
+@Injectable()
+export class AuthService {
+ constructor(private jwt: JwtService, private prisma: PrismaService) {}
+
+ async validateUser(username: string, password: string) {
+ const user = await this.prisma.user.findUnique({ where: { username } });
+ if (!user) {
+ throw new UnauthorizedException();
+ }
+ const valid = await bcrypt.compare(password, user.password);
+ if (!valid) {
+ throw new UnauthorizedException();
+ }
+ return user;
+ }
+
+ async login(username: string) {
+ const payload = { sub: username };
+ return this.jwt.sign(payload);
+ }
+}
diff --git a/apps/hotel-management-service-server/src/auth/credentials.input.ts b/apps/hotel-management-service-server/src/auth/credentials.input.ts
new file mode 100644
index 0000000..e31f0f5
--- /dev/null
+++ b/apps/hotel-management-service-server/src/auth/credentials.input.ts
@@ -0,0 +1,10 @@
+import { Field, InputType } from "@nestjs/graphql";
+
+@InputType()
+export class CredentialsInput {
+ @Field()
+ username: string;
+
+ @Field()
+ password: string;
+}
diff --git a/apps/hotel-management-service-server/src/auth/login-response.object.ts b/apps/hotel-management-service-server/src/auth/login-response.object.ts
new file mode 100644
index 0000000..30ca0e2
--- /dev/null
+++ b/apps/hotel-management-service-server/src/auth/login-response.object.ts
@@ -0,0 +1,7 @@
+import { Field, ObjectType } from "@nestjs/graphql";
+
+@ObjectType()
+export class LoginResponse {
+ @Field()
+ accessToken: string;
+}
diff --git a/apps/hotel-management-service-server/src/main.ts b/apps/hotel-management-service-server/src/main.ts
index 474eead..9914f22 100644
--- a/apps/hotel-management-service-server/src/main.ts
+++ b/apps/hotel-management-service-server/src/main.ts
@@ -13,7 +13,56 @@ import {
const { PORT = 3000 } = process.env;
async function main() {
- const app = await NestFactory.create(AppModule, { cors: true });
+ // Configure CORS to allow credentials and specific origins
+ const corsOrigins = process.env.CORS_ORIGIN
+ ? process.env.CORS_ORIGIN.split(",")
+ : true;
+
+ const app = await NestFactory.create(AppModule, {
+ cors: {
+ origin: corsOrigins,
+ credentials: true,
+ },
+ });
+
+ // Enable security headers via helmet including a strict Content Security Policy
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ const helmet = (await import("helmet")).default;
+ app.use(
+ helmet({
+ // Disable the default CSP so we can configure our own below
+ contentSecurityPolicy: false,
+ })
+ );
+ // Apply a minimal CSP allowing resources only from self
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ const { contentSecurityPolicy } = await import("helmet");
+ app.use(
+ contentSecurityPolicy({
+ directives: {
+ defaultSrc: ["'self'"],
+ objectSrc: ["'none'"],
+ baseUri: ["'self'"],
+ scriptSrc: ["'self'"]
+ },
+ })
+ );
+
+ // Enable cookie parsing for inbound requests
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ const cookieParser = (await import("cookie-parser")).default;
+ app.use(cookieParser());
+
+ // Attach Authorization header from accessToken cookie when header is missing
+ app.use((req, _res, next) => {
+ if (!req.headers["authorization"] && req.cookies?.accessToken) {
+ req.headers["authorization"] = `Bearer ${req.cookies.accessToken}`;
+ }
+ next();
+ });
app.setGlobalPrefix("api");
app.useGlobalPipes(
diff --git a/apps/hotel-management-service-server/test/auth.e2e-spec.ts b/apps/hotel-management-service-server/test/auth.e2e-spec.ts
new file mode 100644
index 0000000..8213736
--- /dev/null
+++ b/apps/hotel-management-service-server/test/auth.e2e-spec.ts
@@ -0,0 +1,27 @@
+import request from "supertest";
+import main from "../src/main";
+
+let app: any;
+
+describe("Auth cookie flow", () => {
+ beforeAll(async () => {
+ app = await main;
+ });
+
+ it("login mutation should set HttpOnly accessToken cookie", async () => {
+ // seed test user credentials or assume test db has user `admin` / `password` hashed properly.
+ const query = {
+ query: `mutation Login($username: String!, $password: String!) {\n login(credentials: { username: $username, password: $password }) {\n accessToken\n }\n }`,
+ variables: { username: "admin", password: "password" },
+ };
+
+ const res = await request(app.getHttpServer())
+ .post("/graphql")
+ .send(query)
+ .expect(200);
+
+ expect(res.headers["set-cookie"]).toBeDefined();
+ const cookie = res.headers["set-cookie"].find((c: string) => c.startsWith("accessToken"));
+ expect(cookie).toBeDefined();
+ });
+});