SSI-1205 implement Cognito auth - #581
Open
LBHTKarki wants to merge 16 commits into
Open
Conversation
Implements next-auth against AWS Cognito. This is a breaking change
Improves the test coverage around auth. Fills some gaps in the existing coverage as well ensures all cognito related paths are covered.
some ui authz rules are not exforced by the API layer. This updates the paths to match the UI rules
|
sam-drumm
approved these changes
Sep 8, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Staff auth migration: Google JWT → Cognito + NextAuth
Staff sign-in no longer uses the custom Google OAuth flow or the
hackneyTokencookie. 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_usercookie andHACKNEY_JWT_SECRET. Those values must not be used for staff.This is a hard cutover. Existing
hackneyTokencookies are ignored. All staff must sign in again.Cookies
hackneyTokennext-auth.session-token(HTTPS:__Secure-next-auth.session-token)NEXTAUTH_SECRETAuthorization: Bearer <hackneyToken>Authorization: Bearer <Cognito ID token>recovered on the server from the session cookiehousing_userhousing_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.subis 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 andcanEditApplicationsstill compareassignedToto email, as they did withhackneyToken. This cutover does not rematch historic assignments ontosub.Sign-in and sign-out
/login./api/auth/callback/cognito.client_secret_post). The ID token is stored in the encrypted session cookie. Access and refresh tokens are discarded.custom:groupsclaim (Hackney’s pre-token Lambda). Role flags still useAUTHORISED_*_GROUP./api/admin/logoutclears session cookies and redirects to Cognito/logoutwithlogout_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 withRequired 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 thoseSet-Cookievalues into a single header, so the browser kept only the first and Cognito login failed withState cookie was missing/PKCE code_verifier cookie was missing.open-next-apigw-cookie-converter.tssplits them back onto API GatewaymultiValueHeaders. That converter is required in AWS; CSRF (one cookie) worked without it.The token exchange uses
client_secret_postbecause Cognito’s token endpoint often returnsinvalid_clientwith 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 (
getServerSidePropsand API routes).NextAuth v4 is the stable library that does that job on Pages Router:
openid-clientfor discovery, PKCE, state, nonce, ID-token validation, andclient_secret_post.getServerSessionin page and API handlers, so authorization stays on the server./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.
openid email profileonly.staffSessionMaxAgeSeconds. There is no token-refresh path to test.sessioncallback copies only UI fields. DownstreamAuthorizationheaders are added in server code (authenticatedHousingAxios/activityAxios).callbackUrlcannot send users off-site.oauth_error,error_description,provider). Profiles, ID tokens, cookies, and authorization codes are not passed toconsole./api/auth/*is NextAuth only — resident OTP moved to/api/resident-auth/*so the two systems cannot shadow each other.Cache-Control: private, no-storeon/applications/*,/api/auth/*, and/api/resident-auth/*.Session helpers live in
lib/auth/staff.ts. NextAuth config islib/auth/options.ts. Operational Cognito URLs and env vars are indocs/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 usex-api-keyonly, 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:
authorizeStaffPage).canEditApplications/canViewSensitiveApplication: manager can edit any status; admin does not get that manager rule; officers follow assignment / draft / sensitive-case rules.getApplicationAccessloads the stored application). Previously the UI hid edit controls whilePATCH/complete/note/evidencestill 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/:idalso has an un-mocked officer-denied case so the handler cannot skip the helper.getServerSidePropsonly 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 NextAuthSet-Cookieheader that includesExpiresdates.Mocked Cypress
Synthetic NextAuth cookies (
e2eStaff) are accepted only whenE2E_HTTP_MOCKS=trueoutside Lambda. No Cognito ID token, so mocked APIs never get a fake bearer.Specs added or extended for the migration:
authAuthorization): anonymous →/login; staff with no group →/access-denied;housing_useris 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 onPATCH /api/applications/:idandPOST /api/applications.reportsAuthorization): officer and read-only cannot open/applications/reportsorPOST /api/reports/novalet/generate.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_GROUPmaps the test user’s group onto manager only whenLOCAL_E2E=trueand not in a deployment. RealAUTHORISED_MANAGER_GROUPstill works for developers signing in as themselves.Resident Cypress cookies are signed with a hardcoded dummy secret (
aDummySecret), notHACKNEY_JWT_SECRET. Local/CI keepSKIP_VERIFY_TOKEN=trueso those cookies are decoded, not verified.serverless.ymldoes not passE2E_*orCOGNITO_E2E_*into deployments.