This guide covers the auth behavior that exists today:
- Implemented: BFF OIDC login, callback, encrypted session cookie,
GET /api/v1/auth/me,POST /api/v1/auth/logout, globalSessionGuard, globalCsrfGuardon mutating requests, transparent refresh, and an optional header bearer-token authentication path (§7). - Not implemented yet: SPA auth integration and multi-provider manual smoke beyond configured local providers.
Run all auth-related backend tests:
npm exec nx test chat-api -- src/authThat includes the cookie-session path (strategies/tests/cookie-session.strategy.spec.ts,
session/tests/session.guard.spec.ts, session/tests/optional-session.guard.spec.ts), the
header bearer-token path (strategies/tests/header-token.strategy.spec.ts), CSRF exemption
(csrf/csrf.guard.spec.ts), and the integration tests in auth.controller.spec.ts.
Run the whole API test target when you want the broader regression check:
npm exec nx run @epam/chat-api:testRun lint and build before handing off a backend auth change:
npm exec nx run @epam/chat-api:lint
npm exec nx run @epam/chat-api:buildThe frontend auth integration is currently still specified under openspec/changes/auth-frontend-integration/ and is not wired into apps/chat yet. For frontend-only smoke, verify that the app still builds and existing tests pass:
npm exec nx run @epam/chat:test
npm exec nx run @epam/chat:lint
npm exec nx run @epam/chat:buildStart both the API and the SPA dev server in separate terminals:
# Terminal 1 — API (port 5000)
npm exec nx run @epam/chat-api:serve
# Terminal 2 — SPA dev server (port 4207, proxies /api → localhost:5000)
npm exec nx run @epam/chat:serveCreate or update apps/chat-api/.env.local (or the workspace-root .env.local) with:
PORT=5000
API_PREFIX=api
CORS_ORIGIN=http://localhost:4207
AUTH_SESSION_SECRET=<64-character-hex-secret>
AUTH_CALLBACK_BASE_URL=http://localhost:4207
AUTH_POST_LOGOUT_REDIRECT_URI=http://localhost:4207
AUTH_KEYCLOAK_CLIENT_ID=your-client-id
AUTH_KEYCLOAK_SECRET=<client-secret>
AUTH_KEYCLOAK_HOST=your-idp.example.com/realms/your-realm
AUTH_KEYCLOAK_ADMIN_ROLE_NAMES=adminCallback URL vs. OIDC callback base
AUTH_CALLBACK_BASE_URLis used to build the OIDCredirect_uriregistered in the provider. The final app landing page is controlled bycallbackUrlon/api/v1/auth/login/*. In local dev,CORS_ORIGIN=http://localhost:4207is also the default app return origin whencallbackUrlis omitted.
The provider must register this redirect URI in its client configuration:
http://localhost:4207/api/v1/auth/callback/keycloak
If the provider id is not keycloak, replace the final path segment with the configured id.
Use a normal browser session and DevTools. All steps go through the SPA origin (localhost:4207) — the Vite proxy forwards API calls to the backend automatically.
- Open
http://localhost:4207/api/v1/auth/providers. - Confirm the response is a JSON array with the configured provider, for example
[{ "id": "keycloak", "label": "Keycloak" }]. - Open
http://localhost:4207/api/v1/auth/login/keycloak?callbackUrl=http%3A%2F%2Flocalhost%3A4207%2F. - Confirm the browser is redirected to the IdP.
- Complete login at the IdP.
- Confirm the callback redirects back to
http://localhost:4207/(the SPA). - In DevTools → Application → Cookies, inspect cookies for
localhost. - Confirm the session cookie exists. With secure defaults it is either
__Host-chat.sessor chunked cookies like__Host-chat.sess.0,__Host-chat.sess.1; all haveHttpOnly,Secure, resolvedSameSite, andPath=/. Normal app auth usesSameSite=Lax; secure overlay embedding usesSameSite=None; Secure. With localAUTH_COOKIE_SECURE=false, names becomechat.sess/chat.sess.0and do not haveSecure. - Confirm the tx cookie (
__Host-chat.tx, orchat.txwhenAUTH_COOKIE_SECURE=false) is cleared after callback. - In the browser console, run
document.cookieand confirm it does not expose tokens. - Open
http://localhost:4207/api/v1/auth/me. - Confirm the response is a user profile containing
sub,providerId, andclaims, with noaccess_tokenorrefresh_tokenfields.
Local HTTP smoke: for
http://localhosttesting, setAUTH_COOKIE_SECURE=falseinapps/chat-api/.env.local. Production-like HTTPS testing should keep the secure defaults.
The global SessionGuard protects non-public API routes.
In a fresh browser profile or after deleting __Host-chat.sess / chat.sess and any numbered chunks, open:
http://localhost:4207/api/themes
Expected result: 401 Unauthorized.
After completing the login flow, open the same URL again:
http://localhost:4207/api/themes
Expected auth result: the request passes the auth guard. The final HTTP status may still be 200, 404, 502, or 503 depending on THEMES_CONFIG_URL and the external themes service, but it should no longer be 401.
The global CsrfGuard (apps/chat-api/src/auth/csrf/csrf.guard.ts) protects every mutating
request (POST/PATCH/PUT/DELETE) on a non-@Public() route. It rejects the request
with 403 Forbidden ({ "code": "CSRF_INVALID", ... } when the token check fails) unless
both of these hold:
- The
Originheader (or, if absent,Referer's origin) matchesCORS_ORIGIN. - The
X-CSRF-Tokenrequest header exactly matches the CSRF secret bound to the caller's session.
Calling a mutation endpoint straight from Postman/curl with only the session cookie — as in
issue #7728 — hits this guard and returns
403 Origin check failed or 403 CSRF_INVALID before the request ever reaches the handler.
This is intended behavior, not a bug: the guard is what stops a third-party site from forging
a mutating request using a victim's browser session. To exercise a mutation endpoint (e.g.
PATCH /api/v1/user-config/toolsets) from an external HTTP client, obtain both a valid
session and a matching CSRF token first:
-
Complete the login flow (Section 3, steps 1–6) so the session cookie is set.
-
Fetch the current CSRF token from any authenticated response header — the simplest source is
GET /api/v1/auth/me, which always setsX-CSRF-Tokenon the response (apps/chat-api/src/auth/auth.controller.ts,getCurrentUser). Using curl with a cookie jar:curl -i -c cookies.txt -b cookies.txt http://localhost:4207/api/v1/auth/me # read the X-CSRF-Token response header -
Send the mutation with the same cookie jar, the captured token in
X-CSRF-Token, and anOriginheader matchingCORS_ORIGIN:curl -i -b cookies.txt \ -H "Content-Type: application/json" \ -H "X-CSRF-Token: <token from step 2>" \ -H "Origin: http://localhost:4207" \ -X PATCH http://localhost:4207/api/v1/user-config/toolsets \ -d '{"id":"toolset-abc","isInstalled":true}'
Expected:
204for a valid body,400for a body that fails DTO validation (e.g. missingid, non-booleanisInstalled) — never403, once the cookie/token/origin are all correct.
The token rotates on some responses (X-CSRF-Token may reappear with a new value); if a
mutation unexpectedly 403s with CSRF_INVALID partway through a longer manual session,
re-fetch GET /api/v1/auth/me and use its latest token.
In the browser instead of curl: the session cookie is HttpOnly, so it cannot be read
from document.cookie — but it is sent automatically. Open DevTools on the SPA origin
(http://localhost:4207) after logging in, find the X-CSRF-Token response header on any
recent Network request (or from step 2 above), and issue the request from the Console
tab so the browser attaches the session cookie and same-page Origin/Referer automatically:
fetch('/api/v1/user-config/toolsets', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': '<token from a Network tab response header>',
},
body: JSON.stringify({ id: 'toolset-abc', isInstalled: true }),
})
.then((r) => r.status)
.then(console.log);Check these directly in the browser or with an HTTP client:
GET /api/v1/auth/login/unknown
Expected: 404.
GET /api/v1/auth/login/%2e%2e
Expected: 400.
GET /api/v1/auth/me
Expected without the session cookie/chunks: 401.
Tamper with the session cookie value in DevTools, then call:
GET /api/v1/auth/me
Expected: 401.
To verify callback hardening, replay a callback URL with an issuer mismatch:
GET /api/v1/auth/callback/keycloak?code=anything&state=<real-state>&iss=https%3A%2F%2Fevil.example.com
Expected: 400 with Issuer mismatch. Use the real provider id and a real in-flight transaction cookie when checking this manually.
This path is off by default (AUTH_HEADER_TOKEN_ENABLED=false). To smoke test it locally:
-
Set in
apps/chat-api/.env.local:AUTH_HEADER_TOKEN_ENABLED=true AUTH_HEADER_TOKEN_ALLOWED_ISSUERS=https://your-idp.example.com/realms/your-realm
(use the exact
issuervalue the configured provider derives — seeapps/chat-api/README.md"Auth provider environment variables"). -
Obtain a real access token from the same provider (e.g. via a password/client-credentials grant against your IdP, or by extracting
atfrom a decrypted session during local testing) and call a protected endpoint directly with it:curl -i http://localhost:5000/api/v1/auth/me \ -H "Authorization: Bearer <access_token>"Expected:
200with the user profile and noX-CSRF-Tokenresponse header. -
Confirm precedence: repeat the call with both a valid session cookie (from Section 3) and the
Authorizationheader set — the response should reflect the header token's identity, not the cookie's. -
Confirm no silent fallback: repeat with an expired or tampered bearer token, still with a valid session cookie present. Expected:
401with a body containing"code": "AUTH_HEADER_TOKEN_EXPIRED"or"code": "AUTH_HEADER_TOKEN_INVALID"— the cookie must never be consulted. -
Confirm CSRF exemption: a mutating request authenticated via the header (e.g.
POST /api/v1/auth/logoutor, once header auth is enabled for a mutating business endpoint) succeeds with noOrigin,Referer, orX-CSRF-Tokenheader:curl -i -X POST http://localhost:5000/api/v1/auth/logout \ -H "Authorization: Bearer <access_token>"Expected:
200, noSet-Cookieheader. -
Confirm the feature stays off unless explicitly enabled: with
AUTH_HEADER_TOKEN_ENABLED=false(or unset), the sameAuthorizationheader is ignored entirely — a request with only a valid cookie behaves identically to before this feature existed, and a request with only the header (no cookie) gets401.
- The Swagger setup still needs final cookie-auth documentation cleanup.