Task 32: Auth Flow Security Remediation & Real Two-Path Sign-In¶
Status: 🟢 Code complete and verified live end-to-end (browser login → dashboard, both org and admin paths) — 2026-08-14
Priority: Critical — started as "remove a hardcoded admin bypass" and surfaced a live, unauthenticated privilege-escalation path plus a second compromised credential
Trigger: Platform lead asked for two distinct sign-in entry points (org vs Platform Admin) instead of the existing hardcoded admin@curo.com special-case; tracing exactly where to remove that special-case surfaced the rest of this.
Cross-references: M3-14 security hardening (the original defect this closes), Task 30 / Task 31 (same session, same "verify live, don't just typecheck" approach)
1. What was actually wrong (found progressively, each one bigger than the last)¶
| # | Issue | File | Severity |
|---|---|---|---|
| 1 | email === 'admin@curo.com' in POST /auth/callback minted a self-signed PLATFORM_ADMIN token with zero Auth0 involvement |
auth.controller.ts |
High — single well-known string |
| 2 | idToken/accessToken accepted from the client with no signature verification (decodeIdToken() just base64-decoded the payload) |
auth0-token.service.ts |
High — any forged token trusted outright |
| 3 | Failed Auth0 code exchanges silently replaced with fabricated tokens instead of failing | auth0-token.service.ts (exchangeCodeForTokens) |
High — masked real auth failures as success |
| 4 | POST /auth/sync had no authentication at all. Any request with org_id: "org_NY1G4Wk6bt7Bdseg" in the body created a brand-new PLATFORM_ADMIN user — the frontend already sent an x-internal-secret header for exactly this, the backend just never checked it |
auth.controller.ts |
Critical — unauthenticated, no admin-email needed at all |
| 5 | Two different Auth0 Management API credential pairs configured under different env var names (AUTH0_MGMT_CLIENT_ID vs AUTH0_MANAGEMENT_CLIENT_ID) in different services |
auth.service.ts vs auth0-user.service.ts/auth0-organization.service.ts |
Medium — inconsistent, and see #6 |
| 6 | The credential under the wrong name (AUTH0_MGMT_CLIENT_ID) in .env.development turned out to be the literal pair auth.service.ts's own code comment calls "compromised via git history" — previously inert only because the naming mismatch meant it was never actually loaded |
.env.development |
High — real, live, compromised credential sitting in a local env file |
| 7 | Auth0Provider (from @auth0/auth0-react) was defined (auth0-provider.tsx) but never mounted anywhere in _app.tsx |
pages/_app.tsx |
Would have broken the pre-existing /login page too, the first time its button was ever clicked |
| 8 | onRedirectCallback hard-navigated to /dashboard immediately, racing ahead of /authorize's own logic (the code that actually syncs the session with our backend) |
auth0-provider.tsx |
Landed users on /dashboard with no real session, which correctly bounced them to / |
| 9 | /authorize always sent the raw code to the backend even when it already had tokens; backend checked code before accessToken/idToken — but the SDK had already redeemed that one-time-use code automatically, so the backend's exchange always failed |
authorize.tsx, auth.controller.ts |
Broke every login attempt with a 401 |
| 10 | verifyToken() checked the ID token's aud against AUTH0_AUDIENCE (the API audience) instead of the Auth0 client id — per OIDC, an ID token's audience is always the client app, never the API resource |
auth0-token.service.ts |
Rejected every genuine ID token |
| 11 | /authorize never wrote the localStorage['auth_data'] blob that useAuth() (used by /dashboard, admin/dashboard.tsx, etc.) specifically requires — valid tokens existed but the app still thought the user was unauthenticated |
authorize.tsx |
Dashboard bounced to / despite a successful login |
| 12 | Backend's callback response never included organizationId, so admin/dashboard.tsx's user?.org_id gate never passed and the org-admin dashboard sat in an empty loading state forever |
auth.controller.ts, authorize.tsx |
Silent — no error, just perpetual skeleton loading |
| 13 | useCurrentUser() (which Layout uses to pick AppShell vs PublicShell) only checked NextAuth's session, never the auth_data our flow writes — so anyone logging in via /signin//signin/admin saw the public shell (wrong header, no sidebar) despite being genuinely logged in |
useCurrentUser.ts |
Cosmetic but confusing — looked like login silently failed |
| 14 | logout() never called the Auth0 SDK's own logout() — it kept its own session cache, so after "logging out," the next sign-in click would silently no-op/loop until the browser cache was cleared |
useCurrentUser.ts |
Broke re-login after logout |
| 15 | useCurrentUser().login and PublicShell's "Sign in" button still called the old next-auth signIn('auth0'), a second, inconsistent auth mechanism running alongside the new one |
useCurrentUser.ts |
Contributed directly to #14 |
Items 7–15 were not part of the original ask — they were pre-existing, dormant bugs that only surfaced because fixing 1–6 made this code path live for the first time. None of them are regressions from this session; the whole @auth0/auth0-react flow was effectively dead code before this, masked by whichever bug came first in the chain.
2. What was built (the actual new request)¶
AdminBootstrapService(auth/services/admin-bootstrap.service.ts,OnModuleInit) — on a fresh, empty DB, creates the first Platform Admin via the Auth0 Management API (Auth0UserService) and seeds the matching local row automatically. No-ops safely if a Platform Admin already exists (verified: correctly detected 2 existing admins and skipped, no duplicate Auth0 API calls). Configured viaINITIAL_ADMIN_EMAIL/_FIRST_NAME/_LAST_NAME./signin— organization sign-in. Auto-redirects to Auth0 with noorganizationparam, so Auth0's own "Prompt for organization" Login Flow shows its native org-name screen before credentials. No custom org-picker UI in this app./signin/admin— Platform Admin sign-in. Resolves the platform's own Auth0 Organization (NEXT_PUBLIC_PLATFORM_ORG_NAME, default"Curo") via a new publicGET /organizations/resolve?name=endpoint, then pre-supplies thatorganizationid to Auth0 — which makes Auth0 skip its own prompt screen, going straight to credentials. Both pages auto-redirect on load (no click required) and stop with a retry button if Auth0 sends back an error, rather than looping.- Public org-resolve endpoint (
PublicOrganizationController) — deliberately unauthenticated (needed pre-login), returns only{found, auth0OrgId, name}. /loginnow redirects to/signin(kept for old bookmarks); all internal/loginreferences updated.- Landing page redesign (
pages/index.tsx) — iterated down from a cluttered 3-CTA hero + extra sections to: one primary "Sign in to your organization" button (matching the nav's persistent "Sign in"), a small teal "Platform admin? Sign in →" link (not a competing button), and a plain-language note that org registration is admin-initiated, not public self-serve (confirmed/organizations/newhas no frontend auth guard at all — the backend does gate real creation to Platform Admins, so a public "Register" CTA would have led anonymous visitors through a multi-step form only to fail at submission).
3. Data gap found and fixed (not code)¶
The real Auth0 Organization curo (org_NY1G4Wk6bt7Bdseg) existed in Auth0 but had no corresponding row in the local organizations table at all — confirmed by querying for any row referencing that id, including inside settings JSON, and finding none. Both existing Platform Admin users had organization_id = NULL. Fixed by inserting the missing row and linking both admins to it. This needs to exist on any new environment too — added to the Task 31 AWS checklist §4.6.
4. Verification¶
Not just typecheck/lint — the actual browser OAuth round-trip was driven to completion multiple times across this session:
- /signin → Auth0 org-prompt → credentials → /authorize → /dashboard → SuperAdmin platform snapshot rendering real data (3 orgs, 8 issued credentials, live CIH/Auth0/SMTP integration health).
- /signin/admin → Auth0 (org pre-supplied, no prompt) → credentials → org-admin dashboard for TESCO LLC rendering real data (sidebar, org badge, "Recent users").
- Logout → re-login without needing to clear browser cache.
- Live curl tests against the running backend: the exact /auth/sync escalation payload (org_id: "org_NY1G4Wk6bt7Bdseg", no secret) → 403; same payload with the correct secret → 200, role always candidate regardless of org_id.
- Backend + frontend typecheck and lint clean throughout (zero new issues vs. each repo's pre-existing baseline).
5. Still open¶
| # | Item | Notes |
|---|---|---|
| 1 | Rotate the compromised Auth0 Management API credential in .env.development (marked AUTH0_MGMT_CLIENT_ID_COMPROMISED_ROTATE_ME) |
Local-only file, not committed, but the pair itself is exposed via older git history per auth.service.ts's own comment — needs rotation in the Auth0 dashboard regardless. |
| 2 | Delete the stray admin-dev-cli@curo.com user (Auth0 id off by one character from the real admin's — looks like debris from the old fake-token bypass) |
Cosmetic cleanup, not a security issue once #4 above is fixed. |
| 3 | Nothing from this entire session is committed to git yet — same standing note as Task 30/31. All of the above is uncommitted local changes. | |
| 4 | Confirm the Auth0 Application's Organizations → Login Flow is actually set to "Prompt for organization" in the dashboard (behavior observed matches this, confirmed via Management API read of organization_require_behavior: "pre_login_prompt") — just noting it's a tenant-level setting this whole design depends on, not something in our code. |