Skip to content

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 via INITIAL_ADMIN_EMAIL/_FIRST_NAME/_LAST_NAME.
  • /signin — organization sign-in. Auto-redirects to Auth0 with no organization param, 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 public GET /organizations/resolve?name= endpoint, then pre-supplies that organization id 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}.
  • /login now redirects to /signin (kept for old bookmarks); all internal /login references 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/new has 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.