fix: switch auth tokens to httpOnly cookies#117
Conversation
There was a problem hiding this comment.
Pull request overview
This PR migrates authentication from returning JWTs in JSON responses to using httpOnly cookies, updates JWT extraction to read from cookies, and simplifies refresh-token handling by removing the bearer strategy dependency.
Changes:
- Set
access_tokenandrefresh_tokenashttpOnlycookies on login/refresh; clear cookies on logout. - Update
JwtStrategyto extract the access token fromreq.cookies.access_token; replace refresh-token Passport strategy/guard with a cookie-readingCanActivateguard. - Register
cookie-parserand enable CORS withcredentials: true; removepassport-http-bearerdependency.
Reviewed changes
Copilot reviewed 8 out of 9 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| pnpm-lock.yaml | Adds cookie-parser (+ types) and removes passport-http-bearer from the lockfile. |
| backend/package.json | Adds cookie-parser (+ types) and removes passport-http-bearer (+ types). |
| backend/src/main.ts | Registers cookie-parser, configures credentialed CORS, and updates Swagger auth configuration (partially). |
| backend/src/modules/auth/jwt.strategy.ts | Switches JWT extraction from bearer header to cookie extractor. |
| backend/src/modules/auth/refresh-token-auth.guard.ts | Rewrites refresh guard to read refresh_token from cookies and attach it to request.user. |
| backend/src/modules/auth/auth.controller.ts | Sets/rotates/clears auth cookies in login/refresh/logout handlers. |
| backend/src/modules/auth/auth.service.ts | Renames returned token fields to { accessToken, refreshToken } for internal cookie-setting use. |
| backend/src/modules/auth/auth.module.ts | Removes the deleted refresh-token strategy from providers. |
| backend/src/modules/auth/refresh-token.strategy.ts | Deletes the old passport-http-bearer refresh token strategy. |
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Moves access and refresh tokens out of the response body and into httpOnly cookies to prevent XSS-based token theft. Changes: - Register cookie-parser middleware in main.ts - CORS configured with credentials: true for cross-origin cookie support - JwtStrategy extracts access_token from req.cookies - login sets access_token (15m) and refresh_token (7d) as httpOnly cookies - refresh rotates both cookies; logout clears them - RefreshTokenAuthGuard rewritten as CanActivate reading refresh_token cookie - Remove passport-http-bearer dependency (no longer needed) Closes #93
83afdec to
616c33c
Compare
Controller:
- Extract cookie options into private cookieOptions() to avoid duplication
- Switch sameSite from 'lax' to 'strict' to minimize CSRF surface
- clearCookie now passes full options (httpOnly, secure, sameSite) to
ensure consistent removal across deployments
- login returns { message, username } so clients know who is logged in
JwtStrategy:
- Add Authorization: Bearer as fallback extractor so Swagger and API
clients continue to work alongside cookie-based browser clients
E2E tests:
- Register cookie-parser middleware in all test app instances
- Extract access_token from Set-Cookie response header after login
- Replace Authorization: Bearer with Cookie header across all specs
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 13 changed files in this pull request and generated 2 comments.
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
supertest types headers as { [key: string]: string } so a direct
cast to string[] fails TS2352. Going through unknown is the correct
escape hatch for this known library type gap.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 13 changed files in this pull request and generated 2 comments.
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Derive clearCookie options from cookieOptions() helper by destructuring out maxAge, eliminating duplicated security-critical flags in logout - Align @ApiBearerAuth decorator with Swagger scheme name 'access-token' so Swagger UI sends the Authorization header on secured endpoints - Capture userId from /auth/register response instead of login body since login no longer returns userId (tokens moved to httpOnly cookies)
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 13 changed files in this pull request and generated 6 comments.
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Read PORT as number via configService.get<number>() so app.listen() receives a proper TCP port; Joi schema already coerces it to a number - Add .expect(201) to login calls in roles and organizations e2e tests so cookie-extraction failures are caught at the right step - Add .expect(201) + expect(id).toBeDefined() to register call in user-organization-roles e2e test to catch registration failures early - Add .expect(201) to org and role creation in beforeAll so auth regressions surface at the setup step rather than as undefined IDs
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 13 changed files in this pull request and generated 3 comments.
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
Comments suppressed due to low confidence (1)
backend/src/modules/auth/auth.controller.ts:80
- This PR is marked as closing #93, but the acceptance criteria for #93 includes adding a
GET /auth/meendpoint and removing frontendlocalStoragetoken usage. I couldn’t find an/auth/mehandler in this controller, and the frontend still referenceslocalStorage.getItem('access_token')(e.g.,frontend/src/components/ProtectedRoute.tsx). Either implement the remaining parts or update the PR description/linked issue status to reflect the actual scope.
@ApiOperation({ summary: 'Register new user' })
@ApiResponse({ status: 201, description: 'User successfully registered' })
@ApiResponse({ status: 400, description: 'Invalid input data' })
// Registration Route: No guards required
@Post('register')
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Configure Helmet with explicit CSP directives that allow Swagger UI's inline scripts and styles (unsafe-inline on scriptSrc/styleSrc); default Helmet CSP blocks Swagger and leaves the docs page blank - Extract ALLOWED_ORIGIN into a variable with a localhost fallback so CORS never silently falls back to permissive defaults when the env var is unset; origin validation is enforced at startup in PR #118 - Remove cookie-parser, @types/cookie-parser, and joi from package.json as they are unused on this branch; those deps are introduced in the cookie auth (#117) and env validation (#118) PRs respectively
- Add @httpcode(HttpStatus.OK) to all auth POST endpoints that document 200 (login, refresh, logout, forgot/reset/change-password) so Swagger docs match actual HTTP status codes; update e2e expectations - Add explicit set-cookie header and access_token cookie assertions in roles and organizations e2e beforeAll to surface cookie auth failures early with clear messages rather than silent empty authCookie
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 13 changed files in this pull request and generated 3 comments.
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
Comments suppressed due to low confidence (2)
backend/test/user-organization-roles.e2e-spec.ts:149
- This role-creation request is awaited but doesn’t assert a successful status. If role creation fails (e.g., validation/authorization),
role2Response.body.idcan be undefined and the test will fail later in a less obvious place. Add.expect(201)(and optionally assertrole2Response.body.idis defined) here.
const role2Response = await request(app.getHttpServer())
.post('/roles')
.set('Cookie', authCookie)
.send({
name: 'Developer Role',
permissions: { canDeploy: true },
});
backend/test/user-organization-roles.e2e-spec.ts:157
- This second role-creation request is also missing a status assertion. Add
.expect(201)(and optionally verify the response body contains anid) so failures surface at the correct step before usingrole3Response.body.id.
const role3Response = await request(app.getHttpServer())
.post('/roles')
.set('Cookie', authCookie)
.send({
name: 'Viewer Role',
permissions: { canView: true },
});
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Fix LocalStrategy.validate() return type from Omit<UserDto, 'password'> to Omit<User, 'password'> — validateUser() strips password from a User entity, not a UserDto; the wrong annotation caused the unsafe cast in the controller - Replace the unsafe cast in AuthController.login() with Omit<User, 'password'> now that the type flows correctly from LocalStrategy - Add .expect(200) and explicit set-cookie/access_token assertions to user-organization-roles.e2e-spec.ts beforeAll (consistent with the pattern already applied to roles and organizations specs) - Add .expect(201) to role assignment setup in permissions test
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 14 changed files in this pull request and generated 1 comment.
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
With httpOnly cookie auth the frontend cannot decode the JWT to read user
info. GET /auth/me returns { id, username } from the validated access
token so the frontend can populate session state on load or page refresh.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 14 changed files in this pull request and generated no new comments.
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- main.ts: keep both cookieParser (httpOnly cookies) and helmet (security headers); adopt main's NODE_ENV validation and isProduction flag - auth.service.ts: keep camelCase return type on refreshAccessToken — controller already references tokens.accessToken / tokens.refreshToken
Summary
httpOnlycookies to protect against XSS-based token theftJwtStrategynow extracts the access token fromreq.cookies.access_tokenRefreshTokenAuthGuardrewritten as a simpleCanActivateguard readingrefresh_tokenfrom cookies — removes thepassport-http-bearerdependency entirelyloginsets both cookies (access: 15m, refresh: 7d);refreshrotates both;logoutclears bothcookie-parserregistered inmain.ts; CORS configured withcredentials: trueTest plan
POST /auth/login— response body no longer contains tokens; cookiesaccess_tokenandrefresh_tokenare set withhttpOnlyflagaccess_tokencookie (noAuthorizationheader needed)POST /auth/refresh— rotates both cookies; old refresh token is revokedPOST /auth/logout— both cookies are cleared; refresh token revoked in DBpnpm typecheckpasses with zero errorspnpm lintpasses cleanCloses #93