About this post
This post contains the complete planning document I made with Claude when introducing user accounts to Now I Get It! It shows that the job of the engineer remains the same when it comes to design, problem solving, and balancing trade-offs. The only things that have changed are that the engineer no longer writes the code and a good generalist can build amazing things when paired with AI specialists. Basically, the role of an engineer has leveled up.
Plan: Add User Accounts with Cognito Auth (#24)
Context
NowIGetIt is currently a public research tool — anyone can upload a PDF, and all completed explanations appear in the public gallery. Issue #24 transforms it into a user-first commercial application with individual accounts, personal galleries, and ownership-gated access.
A prior attempt (PR #37) validated the architecture but the branch went 213 commits stale and was closed. The design decisions from that PR inform this plan. The codebase now has 20 Lambdas, 10 DynamoDB tables, admin 2FA, feedback, takedowns, content screening, and a blog system — all of which need to coexist with user auth.
Target environment: Test account first, NOT prod.
Key Design Decisions
- Custom branded auth UI with Cognito SDK — use
@aws-sdk/client-cognito-identity-providerdirectly in the browser for sign-up, sign-in, password reset. No Cognito hosted/managed login. No Amplify. Full control over the UX. - SRP (Secure Remote Password) —
USER_SRP_AUTHflow viaInitiateAuth. Password never sent over the wire, even to Cognito. The SDK handles the SRP math. - Refresh token in httpOnly cookie — a lightweight Lambda (via API Gateway) stores the refresh token in an httpOnly, Secure, SameSite=Strict cookie scoped to the auth API path. Access/ID tokens live only in JS memory. On page refresh, the browser silently sends the cookie to a refresh endpoint to get fresh tokens. No tokens in localStorage.
- JWT authorizer on API Gateway — zero-Lambda auth overhead for protected routes. Validates the ID token at the API Gateway layer before Lambda runs.
- Cognito User Pool Domain still needed — only for social login OAuth redirects (Google, Apple, etc.) where the user is redirected to Cognito's
/oauth2/authorizewith the provider specified. Users never see a Cognito-branded page. - Admin auth stays separate — admin uses a dedicated token-based auth mechanism with MFA, NOT Cognito. The two auth systems coexist independently.
is_publicdefaults tofalse— new jobs are private by default. Users explicitly choose to share: either via direct URL or by listing in the public gallery. This is the right default for a commercial user-first app.- Legacy jobs are orphaned — existing jobs (no
user_id) appear in public gallery, never in any user's My Gallery. No data backfill required at launch. - Status endpoint stays public — job_id is a UUID (unguessable); anyone with the URL can poll status. Needed for shared links.
- Feedback stays public — anyone viewing a generated page can give feedback, regardless of ownership.
- Cognito handles auth emails — verification codes, password reset codes come from Cognito's built-in email service. Postmark remains for app emails (waitlist, takedowns, support).
- Social login is architecturally supported but not wired up in v1 — the UserPool domain and OAuth config are in place; adding Google/Apple IdPs is a follow-up config change, no code changes needed.
- Per-path CSP hardening for generated pages — add a second CloudFront cache behavior for
pages/*with a restrictive CSP (noconnect-srcto API except feedback, nounsafe-inline). This limits what XSS on generated pages can do, complementing the httpOnly cookie protection. - Two-tier sharing model — generated pages are private by default. Users can share via: (a) "anyone with the URL" — sets a
shared_via_linkflag, the page remains accessible at/pages/{job_id}.htmlbut doesn't appear in the gallery; (b) "public gallery" — setsis_public: true, page appears in the public gallery. Both require explicit user action. Unshared pages are still accessible by direct URL (job_id is an unguessable UUID) but don't appear anywhere public.
Token Architecture
Browser API Gateway + Lambda@Edge/Lambda
─────── ─────────────────────────────────
Sign In (SRP):
InitiateAuth ──────────────────► Cognito (direct SDK call from browser)
◄─── AccessToken, IdToken, RefreshToken
Store tokens:
AccessToken → JS memory (variable)
IdToken → JS memory (variable)
RefreshToken → POST /api/auth/session ──► Lambda sets httpOnly cookie
(HttpOnly; Secure; SameSite=Strict; scoped to auth path)
API calls:
fetch(/api/upload, { Authorization: Bearer <IdToken> })
──► JWT Authorizer validates IdToken ──► Lambda runs with claims
Page refresh (tokens lost from memory):
GET /api/auth/refresh (cookie sent automatically)
──► Lambda reads cookie, calls Cognito InitiateAuth(REFRESH_TOKEN_AUTH)
◄─── New AccessToken + IdToken in response body
Store in JS memory again
Sign Out:
POST /api/auth/logout
──► Lambda calls GlobalSignOut (revokes refresh token server-side)
──► Clears httpOnly cookie (Max-Age=0)
Clear JS memory tokens
This means:
- XSS cannot steal the refresh token (httpOnly cookie, not accessible to JS)
- XSS can steal the access/ID token but they expire in 1 hour and can't be refreshed without the cookie
- CSRF cannot use the access/ID token (they're in JS memory, not cookies, so they're not auto-sent)
- The auth endpoints are the only paths where the cookie is sent (scoped via
Path)
Recommended Approach
Six-phase implementation: Infrastructure (CFN) → Auth Backend (token Lambda) → App Backend (existing Lambda changes) → Frontend Auth UI → Deploy Scripts → Local Dev.
Changes
Phase 1: CloudFormation Infrastructure (aws/nowigetit.yaml)
1.1 Cognito User Pool + Client + Domain
CognitoUserPool(AWS::Cognito::UserPool)- UsernameAttributes:
email(users sign in with email) - AutoVerifiedAttributes:
email - Password policy: 8+ chars, requires lowercase, uppercase, numbers, symbols
- Schema:
email(required, mutable),name(optional display name) LambdaConfig.PostConfirmation→PostConfirmationFunction- EmailConfiguration: use Cognito default email (sufficient for verification/reset codes)
- UsernameAttributes:
CognitoUserPoolDomain(AWS::Cognito::UserPoolDomain)- Domain prefix parameterized per environment (e.g.,
{app-name}-test,{app-name}) - Needed for future social login OAuth redirects
- Domain prefix parameterized per environment (e.g.,
CognitoUserPoolClient(AWS::Cognito::UserPoolClient)- ExplicitAuthFlows:
ALLOW_USER_SRP_AUTH,ALLOW_REFRESH_TOKEN_AUTH - GenerateSecret:
false(public client — browser app) - AllowedOAuthFlows:
code(for future social login) - AllowedOAuthScopes:
openid,email,profile - CallbackURLs:
https://{DomainName}/index.html - LogoutURLs:
https://{DomainName}/index.html - SupportedIdentityProviders:
COGNITO - TokenValidity: AccessToken 1 hour, IdToken 1 hour, RefreshToken 30 days
- PreventUserExistenceErrors:
ENABLED(returns generic errors so attackers can't enumerate emails)
- ExplicitAuthFlows:
1.2 UsersTable (DynamoDB)
- PK:
user_id(Cognitosub), plus GSIs for email lookup and status filtering - PAY_PER_REQUEST, PITR enabled, DeletionProtectionEnabled
- Tracks user identity, display name, account status (active/cancelled), and timestamps
1.3 User-Date GSI on JobsTable
- Add a GSI keyed on user ID + creation date, enabling efficient per-user job queries sorted chronologically
1.4 JWT Authorizer
JwtAuthorizer:
Type: AWS::ApiGatewayV2::Authorizer
Properties:
ApiId: !Ref HttpApi
AuthorizerType: JWT
IdentitySource: $request.header.Authorization
Name: CognitoJwtAuthorizer
JwtConfiguration:
Audience:
- !Ref CognitoUserPoolClient
Issuer: !Sub 'https://cognito-idp.${Region}.amazonaws.com/${CognitoUserPool}'
1.5 Auth Token Lambda + Routes
New Lambda for managing refresh token cookies:
AuthSessionFunction— handles the three auth cookie endpoints (session store, token refresh, logout)- Session: receives refresh token, validates with Cognito, stores as httpOnly cookie
- Refresh: reads cookie, exchanges for fresh access/ID tokens via Cognito
- Logout: revokes refresh token server-side, clears cookie
- Env vars:
USER_POOL_ID,USER_POOL_CLIENT_ID - IAM:
cognito-idp:InitiateAuth,cognito-idp:GlobalSignOut - These routes have NO JWT authorizer (they're part of the auth flow itself)
- CORS must allow credentials (
AllowCredentials: true) for cookie-bearing requests on these routes
1.6 Route Authorization
Routes fall into three authorization tiers:
JWT-protected (user must be authenticated):
- Upload and confirm endpoints — user must own the job
- Personal gallery, profile CRUD, visibility toggling
- 7 routes total
Public (no authorizer):
- Gallery, library, status polling, feedback, support/waitlist
- Auth flow endpoints (session, refresh, logout — these are part of the auth flow)
- Takedown and blog routes
- 12+ routes total
Admin-gated (unchanged, separate auth):
- All admin, dashboard, and stats endpoints
- Destructive operations (job deletion, blog management)
- 8+ routes total
1.7 New App Lambda Functions (CFN resources)
PostConfirmationFunction— Cognito trigger, creates UsersTable recordMyGalleryFunction—GET /api/my-galleryhandlerUserProfileFunction—GET/PUT/DELETE /api/profilehandlerJobVisibilityFunction—PUT /api/job/{job_id}/visibilityhandler
1.8 CORS Update
- Add
AuthorizationtoAllowHeadersonHttpApi.CorsConfiguration - For
/api/auth/*routes: needAllowCredentials: truefor cookie transport. This requires theAllowOriginsto be a specific origin (not*), which it already is (https://{DomainName}).
CORS complexity note: HTTP API Gateway's built-in CORS only supports a single configuration per API. Since /api/auth/* routes need AllowCredentials: true but other routes don't, we have two options:
- (A) Enable
AllowCredentials: trueglobally — simplest, harmless sinceAllowOriginsis already a specific domain (not*) - (B) Handle CORS in the auth Lambda — disable API Gateway CORS for auth routes, return CORS headers from Lambda code
Recommendation: (A) — global AllowCredentials: true with the existing specific origin is safe and simplest.
1.9 CSP Update
In SecurityHeadersPolicy, add the Cognito service endpoint to connect-src (needed for SDK API calls from the browser).
1.10 IAM Update
Add to LambdaRole policy:
- DynamoDB CRUD on
UsersTableand its GSI indexes cognito-idp:InitiateAuthandcognito-idp:GlobalSignOut(for auth session Lambda)
1.11 Stack Outputs
Add: UserPoolId, UserPoolClientId, UserPoolDomain, UsersTableName
Phase 2: Auth Backend — Token Session Lambda
2.1 New: backend/lambda_auth_session.py
Handles all /api/auth/* routes. Single Lambda, route-dispatched:
POST /api/auth/session — Store refresh token as cookie
- Receives refresh token in request body
- Validates by calling Cognito
InitiateAuth(REFRESH_TOKEN_AUTH) - If valid, sets an httpOnly, Secure, SameSite=Strict cookie scoped to the auth API path (30-day expiry)
- Returns success (no tokens in response — the cookie IS the storage)
GET /api/auth/refresh — Exchange cookie for fresh tokens
- Reads refresh token from httpOnly cookie
- Calls Cognito
InitiateAuth(REFRESH_TOKEN_AUTH)with the cookie value - Returns new ID and access tokens (1-hour expiry)
- If cookie missing or refresh fails: returns 401
POST /api/auth/logout — Revoke and clear
- Reads refresh token from cookie
- Calls Cognito
GlobalSignOutto revoke server-side - Clears the httpOnly cookie (Max-Age=0)
- Returns success
Rate limiting: Apply IP-based rate limiting to all auth endpoints to prevent brute force.
Phase 3: App Backend — Lambda Changes
3.1 New: backend/user_auth.py
Shared utility module (mirrors admin_auth.py pattern):
def get_user_from_event(event):
"""Extract user identity from JWT authorizer claims.
Returns {'user_id': sub, 'email': email} or None."""
claims = (event.get("requestContext", {})
.get("authorizer", {})
.get("jwt", {})
.get("claims", {}))
sub = claims.get("sub")
if not sub:
return None
return {"user_id": sub, "email": claims.get("email", "")}
3.2 New: backend/lambda_post_confirmation.py
Cognito post-confirmation trigger:
- Reads
event["request"]["userAttributes"]forsub,email - PutItem into UsersTable:
user_id,email,status="active",created_at,record_entry_ts - Must
return event(Cognito trigger contract)
3.3 New: backend/lambda_my_gallery.py
- Imports
user_auth.get_user_from_event() - Queries
UserDateIndexGSI:user_id = sub, sorted bycreated_datedescending - Pagination via
LastEvaluatedKey/ExclusiveStartKey - Returns all user's jobs (public AND private) with
is_publicflag visible
3.4 New: backend/lambda_user_profile.py
- GET: fetch user record from UsersTable by
user_id - PUT: update
display_name(validate input length/content) - DELETE: set
status="cancelled",cancelled_at=now. Optionally mark all user's jobsis_public=false.
3.5 New: backend/lambda_job_visibility.py
- Extracts user from JWT, reads job from JobsTable, validates
user_idmatches - Updates
is_publicboolean on the job record
3.6 Modify: backend/lambda_upload.py
- Import
user_auth - In
handler(), callget_user_from_event(event)to extract user identity - Add
user_idandis_public: Falseto the DynamoDBput_itemcall (private by default)
3.7 Modify: backend/lambda_confirm.py
- Import
user_auth - After loading the job record, verify
job["user_id"] == user["user_id"] - Return 403 if ownership check fails
3.8 Modify: backend/lambda_gallery.py
- Add
FilterExpressionto the StatusDateIndex query:Attr("is_public").eq(True) | Attr("is_public").not_exists() - Legacy jobs (no
is_publicattr) still appear in gallery (backward compatible — they were public before accounts existed) - New jobs default to
is_public: falseso they only appear after the user explicitly shares to gallery
3.9 Modify: backend/lambda_feedback.py
- Add optional
user_idto feedback record if auth context is present - Feedback endpoint stays public (no JWT authorizer)
Phase 4: Frontend — Custom Auth UI
4.1 New: backend/static/auth.js
Core auth module using @aws-sdk/client-cognito-identity-provider (loaded from CDN: https://cdn.jsdelivr.net/npm/@aws-sdk/client-cognito-identity-provider/dist-es/index.js or bundled as an IIFE).
SDK loading strategy: Since the project has no build step (vanilla HTML/JS), we need the SDK available in the browser. Options:
- (A) CDN import via
<script type="module">— use ES module import from a CDN like esm.sh or jsDelivr - (B) Pre-bundle the SDK into a single
cognito-sdk.jsfile — run a one-time build (esbuild) to bundle the SDK, commit the output, deploy as a static file - (C) Use the Cognito REST API directly — avoid the SDK entirely, make raw HTTP calls to the Cognito endpoint with SRP calculated manually
Recommendation: (B) — pre-bundle into cognito-sdk.js. One-time build, no runtime CDN dependency, works with the existing no-build-step pattern. The bundle is ~50-80 KB gzipped.
Build step (one-time, committed to repo):
npm init -y
npm install @aws-sdk/client-cognito-identity-provider
npx esbuild --bundle --minify --format=iife --global-name=CognitoSDK \
--outfile=backend/static/cognito-sdk.js \
node_modules/@aws-sdk/client-cognito-identity-provider/dist-es/index.js
auth.js exports to window.NigiAuth:
init(config)— called on page load with{userPoolId, clientId, region}fromconfig.jssignUp(email, password)— callsSignUpcommand, returns{userConfirmed, codeDeliveryDetails}confirmSignUp(email, code)— callsConfirmSignUpcommandsignIn(email, password)— callsInitiateAuthwithUSER_SRP_AUTH, handles SRP challenge. On success: stores access/ID tokens in module-scoped variables, sends refresh token toPOST /api/auth/session(sets cookie), returns user info.signOut()— callsPOST /api/auth/logout, clears in-memory tokensforgotPassword(email)— callsForgotPasswordcommandconfirmForgotPassword(email, code, newPassword)— callsConfirmForgotPasswordgetIdToken()— returns in-memory ID token or nullgetUser()— returns{email, sub}decoded from ID token, or nullisLoggedIn()— checks if in-memory tokens exist and not expiredauthFetch(url, opts)— wraps fetch withAuthorization: Bearer {idToken}. If token expired, calls/api/auth/refreshfirst to get new tokens from cookie.tryRestore()— on page load, callsGET /api/auth/refresh(cookie sent automatically). If successful, populates in-memory tokens. If 401, user is not logged in. This is how auth survives page refresh.
Token lifecycle:
signIn()→ tokens in memory + refresh token in httpOnly cookie- API calls →
authFetch()sends ID token from memory - Token expires (1 hour) →
authFetch()detects expiry, calls/api/auth/refresh, cookie yields new tokens - Page refresh →
tryRestore()calls/api/auth/refresh, cookie yields new tokens - Sign out →
POST /api/auth/logoutrevokes refresh token + clears cookie + clears memory
4.2 New: backend/static/auth-ui.js
Custom branded auth UI components. Renders sign-up, sign-in, password reset forms in a modal or inline container. Matches the NowIGetIt dark theme (uses existing CSS variables).
UI states:
- Sign In form: email + password fields, "Sign In" button, "Forgot password?" link, "Create account" link
- Sign Up form: email + password + confirm password fields, "Create Account" button, "Already have an account?" link
- Verify Email form: 6-digit code input, "Verify" button, "Resend code" link
- Forgot Password form: email field, "Send Reset Code" button
- Reset Password form: code + new password + confirm password fields, "Reset Password" button
- Auth error display: inline error messages styled to match the app
Integration pattern: auth-ui.js creates DOM elements programmatically (same pattern as footer.js and feedback-widget.js). No HTML templates — everything is JS-generated for portability across pages.
4.3 Modify: backend/static/footer.js
- After building the footer, check
window.NigiAuth && window.NigiAuth.isLoggedIn() - If logged in and mode !== 'admin': add "Account" column with My Gallery, Profile, Sign Out links
- If not logged in and mode !== 'admin': add "Sign In" link in Resources column
- Sign Out calls
window.NigiAuth.signOut() - Footer must wait for
NigiAuth.tryRestore()to complete before rendering auth state (use a callback or promise)
4.4 Modify: backend/static/index.html
- Add
<script src="cognito-sdk.js"></script>(the pre-bundled SDK) - Add
<script src="auth.js"></script> - Add
<script src="auth-ui.js"></script> - On
DOMContentLoaded: callNigiAuth.init(config), thenNigiAuth.tryRestore()to recover session from cookie - When not logged in: show "Sign in to upload" prompt. Clicking opens the sign-in modal.
- When logged in: show upload form. Replace
fetch()calls to/api/uploadand/api/confirm/withNigiAuth.authFetch() - Add user greeting in header area: email + Sign Out link
4.5 New: backend/static/my-gallery.html
- Authenticated personal gallery page
- On load:
NigiAuth.tryRestore(), redirect to index if not logged in - Fetches
GET /api/my-galleryviaNigiAuth.authFetch() - Each card shows: title, date, mode chip, visibility toggle (eye icon)
- Visibility toggle calls
PUT /api/job/{job_id}/visibilityviaauthFetch() - Reuses gallery.html styling/layout patterns
4.6 New: backend/static/profile.html
- Shows user email (from ID token), display name (editable)
- Job statistics (total, public, private)
- Account cancellation with confirmation dialog
- Calls
GET/PUT/DELETE /api/profileviaauthFetch()
4.7 Modify: backend/static/gallery.html
- Rename heading from "Gallery" to "Public Gallery"
- If
NigiAuth.isLoggedIn(), show link to My Gallery
Phase 5: Deploy Script Updates
5.1 Modify: scripts/deploy-common.sh
- Frontend files: Add the pre-bundled Cognito SDK, auth modules, and new HTML pages to the upload list
- Lambda packaging: Add the 5 new Lambda handlers and the shared auth utility module
- Config generation: Deploy script generates a runtime config file with Cognito pool ID, client ID, and region — fetched from CloudFormation stack outputs. Social login config (domain, redirect URIs) deferred to v2.
- Lambda updates: Add the 5 new function suffixes to the update loop
5.2 Modify: environment deploy scripts
Each environment's deploy script passes its own CognitoDomainPrefix (e.g., {app-name}-test, {app-name}) to CloudFormation.
Phase 6: Local Development (backend/main.py)
6.1 Mock auth endpoints
Add routes that simulate the auth session Lambda:
POST /api/auth/session— stores refresh token in a cookie on the responseGET /api/auth/refresh— reads cookie, returns mock tokensPOST /api/auth/logout— clears cookie
For local dev, auth.js talks to the same API_BASE (localhost), so cookie-based refresh works the same way. The Cognito SDK calls (SignUp, InitiateAuth, etc.) go directly to AWS Cognito — they work from localhost since they're HTTP calls to Cognito's service endpoint, not to our backend.
6.2 Mock JWT middleware
Add FastAPI middleware that checks Authorization: Bearer {token}:
- If the token is a real Cognito JWT, decode it and extract claims
- If the token matches
mock-{user_id}pattern, inject mock claims - Populate
request.state.userfor downstream route handlers
6.3 New routes in main.py
GET /api/my-gallery— queries in-memoryjobsdict filtered byuser_idGET /api/profile,PUT /api/profile,DELETE /api/profile— CRUD on mock usersPUT /api/job/{job_id}/visibility— togglesis_publicon in-memory job
6.4 Modify existing routes
POST /api/upload— extract user_id from auth, add to job recordGET /api/gallery— filter tois_public == Trueoris_publicnot set (legacy jobs remain visible)
Critical Files
| File | Action | Purpose |
|------|--------|---------|
| aws/nowigetit.yaml | Modify | Cognito, UsersTable, UserDateIndex GSI, JWT authorizer, auth routes, CORS, CSP, IAM, outputs |
| backend/lambda_auth_session.py | Create | Refresh token cookie management (session/refresh/logout) |
| backend/user_auth.py | Create | Shared JWT claim extraction utility |
| backend/lambda_post_confirmation.py | Create | Cognito trigger: create user record |
| backend/lambda_my_gallery.py | Create | Authenticated personal gallery |
| backend/lambda_user_profile.py | Create | Profile CRUD + account cancellation |
| backend/lambda_job_visibility.py | Create | Toggle job public/private |
| backend/lambda_upload.py | Modify | Add user_id and is_public to job records |
| backend/lambda_confirm.py | Modify | Add ownership check |
| backend/lambda_gallery.py | Modify | Filter by is_public |
| backend/lambda_feedback.py | Modify | Optionally store user_id |
| backend/main.py | Modify | Mock auth, auth routes, new routes, filter changes |
| backend/static/cognito-sdk.js | Create | Pre-bundled AWS Cognito SDK (~50-80 KB gzipped) |
| backend/static/auth.js | Create | Frontend auth module (SRP, token management, authFetch) |
| backend/static/auth-ui.js | Create | Custom branded sign-in/sign-up/reset UI components |
| backend/static/index.html | Modify | Auth integration, sign-in gate, authFetch |
| backend/static/footer.js | Modify | Auth-aware links (My Gallery, Profile, Sign In/Out) |
| backend/static/gallery.html | Modify | Rename heading, add My Gallery link |
| backend/static/my-gallery.html | Create | Personal gallery with visibility toggles |
| backend/static/profile.html | Create | User profile + account management |
| scripts/deploy-common.sh | Modify | Config.js extension, new files, new outputs |
| deploy-test.sh | Modify | Cognito domain prefix parameter |
| deploy-prod.sh | Modify | Cognito domain prefix parameter |
Dependencies & Ordering
Phase 1 (CFN) ──────────────────────────────────────────────────────►
1.1 Cognito ─► 1.4 JWT Authorizer ─► 1.6 Route auth
1.2 UsersTable ─► 1.7 New Lambda CFN resources
1.3 UserDateIndex GSI
1.5 Auth session Lambda + routes
1.8 CORS (independent)
1.9 CSP (independent)
1.10 IAM (must include UsersTable + cognito-idp)
1.11 Outputs (depends on all above)
Phase 2 (Auth Backend) ─────────────────────────────────────────────►
2.1 lambda_auth_session.py (depends on Phase 1 Cognito resources)
Phase 3 (App Backend) ──────────────────────────────────────────────►
3.1 user_auth.py ─► 3.2-3.9 (all Lambdas import it)
3.2 post_confirmation (independent)
3.3-3.5 new Lambdas (can parallelize)
3.6-3.9 Lambda modifications (can parallelize)
Phase 4 (Frontend) ─────────────────────────────────────────────────►
4.1 cognito-sdk.js (one-time build, independent)
4.2 auth.js ─► 4.3-4.7 (all pages depend on auth.js)
4.3 auth-ui.js (depends on auth.js)
4.4 index.html (depends on auth.js + auth-ui.js)
4.5 my-gallery.html (depends on auth.js)
4.6 profile.html (depends on auth.js)
4.7 gallery.html, footer.js (depends on auth.js)
Phase 5 (Deploy) ───────────────────────────────────────────────────►
5.1 deploy-common.sh ─► 5.2 deploy-test.sh ─► (deploy to test)
Phase 6 (Local Dev) ────────────────────────────────────────────────►
6.1-6.4 (can happen in parallel with Phases 2-4)
Risks & Open Questions
Cognito SDK bundle size: The
@aws-sdk/client-cognito-identity-providerneeds to be pre-bundled for browser use. Estimated ~50-80 KB gzipped. This is a one-time build step that produces a committed artifact. If the bundle is too large, we could use the Cognito REST API directly with manual SRP implementation (more code, smaller bundle).SRP complexity: The SDK handles SRP math internally, but the
USER_SRP_AUTHflow involves a challenge-response (InitiateAuth→PASSWORD_VERIFIERchallenge →RespondToAuthChallenge). The SDK'sInitiateAuthwithUSER_SRP_AUTHhandles this transparently, but error handling needs to cover all challenge types.Cookie domain and path: The refresh token cookie is scoped to the auth API path so it's only sent on auth endpoints.
SameSite=Strictprevents CSRF.Securerequires HTTPS (works in prod, need to handle localhost for dev — localhost cookies can beSecurein modern browsers viahttp://localhost).API Gateway CORS with credentials: Enabling
AllowCredentials: trueglobally is safe becauseAllowOriginsis already a specific domain. But it means all CORS preflight responses includeAccess-Control-Allow-Credentials: true, which is fine but worth noting.Cognito email sending limits: Cognito's default email sending uses a shared Amazon SES sandbox. For test, this is fine. For prod with real users, we may need to configure a custom email sender (SES with verified domain) or use Cognito's advanced security features. This is a prod-readiness concern, not a blocker for test.
GSI backfill time: Adding
UserDateIndexto a live JobsTable takes time. Not a downtime risk but a deploy-time delay.Social login (future): The Cognito domain and OAuth config are in place. Adding Google/Apple requires: (a) create identity providers in Cognito, (b) add them to
SupportedIdentityProviders, (c) add social login buttons to auth-ui.js that redirect to/oauth2/authorize?identity_provider=Google. No backend code changes.
Verification
Automated
- CloudFormation deploys without errors to test account
- All existing endpoints still work (no regressions from CORS/CSP changes)
- New Lambdas respond correctly to authenticated requests
Manual (test account)
- Sign up via custom UI → receive verification email from Cognito → enter code → account created
- Sign in → see upload form, user greeting in header
- Upload PDF while logged in → verify job has
user_idandis_public: falsein DynamoDB (private by default) - Visit My Gallery → see only your own jobs
- Toggle job visibility → verify public gallery reflects change
- View public gallery → see only
is_publicjobs + legacy jobs - Visit someone else's job status URL → works (public)
- Submit feedback on another user's page → works (public)
- Refresh page → session restored from cookie (no re-login)
- Wait >1 hour → token auto-refreshes via cookie
- Sign out → upload form hidden, tokens cleared, cookie cleared
- Sign in again → works (cookie was properly cleared)
- Forgot password → receive code → reset → sign in with new password
- Profile page → see email, edit display name
- Cancel account → status set to cancelled
- Legacy jobs (no user_id) → still appear in public gallery
- Footer shows correct auth state (signed in vs signed out)
- Local dev (
./start.sh) → mock auth works, Cognito SDK calls work against real Cognito