Skip to content

SSI-1205 implement Cognito auth - #581

Open
LBHTKarki wants to merge 16 commits into
developmentfrom
feat/ssi-1205-implement-cognito-auth
Open

SSI-1205 implement Cognito auth#581
LBHTKarki wants to merge 16 commits into
developmentfrom
feat/ssi-1205-implement-cognito-auth

Conversation

@LBHTKarki

@LBHTKarki LBHTKarki commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Staff auth migration: Google JWT → Cognito + NextAuth

Staff sign-in no longer uses the custom Google OAuth flow or the hackneyToken cookie. Google Workspace is still the identity provider, but it is federated through AWS Cognito. This app is a confidential OAuth client (BFF): the browser never holds the Cognito ID token.

Resident email-code login is unchanged. It still uses the housing_user cookie and HACKNEY_JWT_SECRET. Those values must not be used for staff.

This is a hard cutover. Existing hackneyToken cookies are ignored. All staff must sign in again.

Cookies

Before After
Staff session hackneyToken next-auth.session-token (HTTPS: __Secure-next-auth.session-token)
What it contains A JWT the app minted after Google login (groups, email, etc.) NextAuth encrypted JWT (JWE). Inside it: Cognito ID token, expiry, subject, email, name, groups
Readable from JS? Yes, in practice — APIs read it from the cookie and the browser could too No. HttpOnly, encrypted with NEXTAUTH_SECRET
Sent to Housing Register / Activity History Authorization: Bearer <hackneyToken> Authorization: Bearer <Cognito ID token> recovered on the server from the session cookie
Lifetime Tied to the old JWT 4 hours, or sooner if the Cognito ID token expires
Resident session housing_user housing_user (unchanged)

Large sessions are split by NextAuth into next-auth.session-token.0, .1, and so on. Sign-out clears all of those names, including the __Secure- variants. CloudFront must forward cookies (including chunked names); otherwise the session never reaches Lambda.

The browser-facing NextAuth session (/api/auth/session) only has display fields (id, name, email, groups). It does not include the ID token. That stays in the HttpOnly cookie and is only read in Node when calling downstream APIs.

Staff identity in the session is Cognito sub (session.user.id / StaffUser.sub), not email. sub is the user-pool subject: it is issued by Cognito, unique in the pool, and does not change if the Google Workspace address is renamed or the email claim is updated. Email is still stored for display and for authorization that already keys off it. Case assignment and canEditApplications still compare assignedTo to email, as they did with hackneyToken. This cutover does not rematch historic assignments onto sub.

Sign-in and sign-out

  1. Staff click “Sign in with Google” on /login.
  2. NextAuth sends them to Cognito hosted UI (authorization code + PKCE, state, nonce).
  3. Cognito federates to Google, then calls back at /api/auth/callback/cognito.
  4. The app exchanges the code using the client secret (client_secret_post). The ID token is stored in the encrypted session cookie. Access and refresh tokens are discarded.
  5. Groups come from the ID token custom:groups claim (Hackney’s pre-token Lambda). Role flags still use AUTHORISED_*_GROUP.
  6. Sign-out: NextAuth CSRF sign-out, then /api/admin/logout clears session cookies and redirects to Cognito /logout with logout_uri = {NEXTAUTH_URL}/login. That URI must match the Cognito app client exactly. Cognito does not report a mismatch: it redirects to hosted login, which then fails with Required parameters missing. This does not sign the user out of Google Workspace.

Local callbacks must use http://localhost:3000 (Cognito will not allow HTTP on other hostnames).

Sign-in sets several cookies in one response (state, nonce, pkce.code_verifier, callback-url). OpenNext’s REST (aws-apigw-v1) converter joined those Set-Cookie values into a single header, so the browser kept only the first and Cognito login failed with State cookie was missing / PKCE code_verifier cookie was missing. open-next-apigw-cookie-converter.ts splits them back onto API Gateway multiValueHeaders. That converter is required in AWS; CSRF (one cookie) worked without it.

The token exchange uses client_secret_post because Cognito’s token endpoint often returns invalid_client with HTTP Basic. Discovery and the exchange use a 10s HTTP timeout (openid-client’s default 3.5s is too short).

Why NextAuth

Cognito is the identity provider. NextAuth is the app’s OIDC client and session layer. The app still has to run a confidential authorization-code flow, keep the ID token off the browser, and attach that token only in Node when calling Housing Register and Activity History. That is a BFF, which this Pages Router app already is (getServerSideProps and API routes).

NextAuth v4 is the stable library that does that job on Pages Router:

  • Cognito provider plus openid-client for discovery, PKCE, state, nonce, ID-token validation, and client_secret_post.
  • Encrypted HttpOnly JWT cookies, including chunking when the session is large (the Cognito ID token makes it large).
  • getServerSession in page and API handlers, so authorization stays on the server.
  • CSRF-protected sign-out and a catch-all at /api/auth/*.

A hand-rolled OIDC handler would reimplement those pieces and is where CSRF, nonce, cookie size, and logout bugs usually appear. AWS Amplify and the Cognito browser SDKs keep tokens in the client, which this design rejects.

v4.24.15 is used because Auth.js v5 was still beta and is App Router–first. JWT sessions (no session table) fit Lambda. Authorization (groups, assignment, reports) stays in this app; NextAuth only authenticates.

Why NextAuth is set up this way

The goal is a confidential client, not a SPA holding tokens.

  • Authorization code + PKCE, state, and nonce — confidential client (secret stays on the server) and PKCE. Cognito and NextAuth v4 support both; PKCE binds the code to this browser even if the redirect is intercepted, and state/nonce stop login CSRF and ID-token mix-up. The client secret never goes to the browser.
  • Confidential app client only — implicit and client-credentials grants are off. Scopes are openid email profile only.
  • Encrypted HttpOnly session cookie — XSS cannot read the ID token. The cookie is a JWE, not the raw Cognito JWT.
  • No refresh token — when the ID token expires (~4 hours), the session ends. Staff sign in again. That avoids storing a long-lived refresh token and refresh races. The Cognito app-client ID-token validity must be 4 hours so it matches staffSessionMaxAgeSeconds. There is no token-refresh path to test.
  • ID token never in client sessionsession callback copies only UI fields. Downstream Authorization headers are added in server code (authenticatedHousingAxios / activityAxios).
  • Same-origin redirects only — post-login callbackUrl cannot send users off-site.
  • Auth errors are logged without tokens — the default NextAuth logger is replaced. CloudWatch gets the error code and a few scalars (oauth_error, error_description, provider). Profiles, ID tokens, cookies, and authorization codes are not passed to console.
  • /api/auth/* is NextAuth only — resident OTP moved to /api/resident-auth/* so the two systems cannot shadow each other.
  • Authenticated responses are not cacheableCache-Control: private, no-store on /applications/*, /api/auth/*, and /api/resident-auth/*.

Session helpers live in lib/auth/staff.ts. NextAuth config is lib/auth/options.ts. Operational Cognito URLs and env vars are in docs/cognito-staff-auth.md.

Downstream APIs

Housing Register writes and Activity History reads now get Authorization: Bearer <Cognito ID token> from this app’s confidential client, not the old Google-issued Hackney JWT. Those APIs’ authorizers must accept this user-pool issuer and app client. A deny policy surfaces as 403 on this app (for example the case view, which always loads activity history). Housing Register reads still use x-api-key only, so the worktray can load while a single application view fails.

Tests

Authorization still uses Hackney groups, assignment, sensitive cases, and reports. The suite shows the Cognito/NextAuth cutover did not widen those rules, and that staff write APIs now match the UI (they previously did not).

Unit (Jest)

Page and API gates use the real staff helpers, not stubs that always grant access:

  • Anonymous, no-group, read-only, and writable roles on staff pages (authorizeStaffPage).
  • canEditApplications / canViewSensitiveApplication: manager can edit any status; admin does not get that manager rule; officers follow assignment / draft / sensitive-case rules.
  • BFF write APIs now use those same helpers (getApplicationAccess loads the stored application). Previously the UI hid edit controls while PATCH / complete / note / evidence still allowed any writable staff. Jest now covers officer denied on someone else’s submitted case and on someone else’s sensitive case; manager allowed on sensitive; read-only and no-role still 403 without a GET; residents still only their own id. PATCH /api/applications/:id also has an un-mocked officer-denied case so the handler cannot skip the helper.
  • Reports getServerSideProps only loads Novalet data after the manager-group gate.

NextAuth/session tests still cover PKCE/state/nonce being configured, 4-hour maxAge, no ID token in the client session, same-origin redirects, and synthetic E2E sessions. They do not drive a live Cognito callback that fails state or nonce. There are no refresh-token tests because refresh tokens are discarded. The OpenNext cookie converter is unit-tested against a joined NextAuth Set-Cookie header that includes Expires dates.

Mocked Cypress

Synthetic NextAuth cookies (e2eStaff) are accepted only when E2E_HTTP_MOCKS=true outside Lambda. No Cognito ID token, so mocked APIs never get a fake bearer.

Specs added or extended for the migration:

  • Boundaries (authAuthorization): anonymous → /login; staff with no group → /access-denied; housing_user is not a staff session (read or write pages); staff cookie is not a resident session (GET /api/applications, /apply/overview); read-only staff get 403 on PATCH /api/applications/:id and POST /api/applications.
  • Reports (reportsAuthorization): officer and read-only cannot open /applications/reports or POST /api/reports/novalet/generate.
  • View application (viewAnApplication): officer denied on someone else’s sensitive case; officer can view an unassigned submitted case without applicant edit controls; officer can edit their assigned submitted case; admin still sees sensitive-application controls.

Roles used: admin, manager, officer, read-only, no-group, plus a resident cookie.

Local backend Cypress

Cypress Node signs in with a dedicated public Cognito client (USER_PASSWORD_AUTH, uses the same e2e user credentials as MMH Cognito based tests), verifies the ID token against JWKS, and stores that real token in the session. E2E_AUTHORISED_MANAGER_GROUP maps the test user’s group onto manager only when LOCAL_E2E=true and not in a deployment. Real AUTHORISED_MANAGER_GROUP still works for developers signing in as themselves.

Resident Cypress cookies are signed with a hardcoded dummy secret (aDummySecret), not HACKNEY_JWT_SECRET. Local/CI keep SKIP_VERIFY_TOKEN=true so those cookies are decoded, not verified.

serverless.yml does not pass E2E_* or COGNITO_E2E_* into deployments.

@LBHTKarki
LBHTKarki requested a review from a team as a code owner September 3, 2026 09:31
Comment thread lib/gateways/applications-api.ts Fixed
Comment thread lib/gateways/applications-api.ts Fixed
Comment thread lib/gateways/applications-api.ts Fixed
@sonarqubecloud

sonarqubecloud Bot commented Sep 8, 2026

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants