Skip to content

Instantly share code, notes, and snippets.

@jordotech
Last active February 25, 2026 06:07
Show Gist options
  • Select an option

  • Save jordotech/b12effbe5e01ac8089c66a442847cdf5 to your computer and use it in GitHub Desktop.

Select an option

Save jordotech/b12effbe5e01ac8089c66a442847cdf5 to your computer and use it in GitHub Desktop.
SSO Authentication Support for agentic-backend — problem analysis and fix

SSO Authentication Support for agentic-backend

Problem

As part of the Auth0 tenant migration (COM-18), we identified that SSO users cannot authenticate with agentic-backend. This is a pre-existing gap — not a regression from the migration — but it blocks SSO rollout for EY and future enterprise clients.

Root Cause

The frontend sends tokens differently depending on auth method:

Auth Method Header Sent Token Type
OTP (email code) X-User-Token: {session_cookie} clj-pg-wrapper session token
Auth0 SSO (Azure AD) Authorization: Bearer {jwt} Auth0 JWT

agentic-backend's get_current_user() in src/api/auth.py only reads X-User-Token. When an SSO user hits any authenticated endpoint, the Auth0 Bearer token is invisible to agentic-backend, resulting in a 401.

Even if the header were read, the downstream validation would fail:

  1. agentic-backend forwards the token to platform-api's /public/auth/validate
  2. That endpoint blindly proxies to clj-pg-wrapper
  3. clj-pg-wrapper has no idea how to validate an Auth0 JWT
  4. Result: 403

Token Flow Diagrams

BEFORE (broken for SSO):

  SSO User → FE (Authorization: Bearer {auth0_jwt})
    → agentic-backend reads X-User-Token → null → 401 ❌


AFTER — SSO flow (fixed):

  SSO User → FE (Authorization: Bearer {auth0_jwt})
    → agentic-backend reads Authorization header, extracts JWT
      → platform-api /public/auth/validate (X-User-Token: {auth0_jwt})
        → Auth0 JWKS validation ✅
        → POST clj-pg-wrapper /api/v1/users/ensure {email}
            → Postgres users table (get or create)
            → returns platform UUID + name
        → returns {userId: <platform_uuid>, email, typeName, name} ✅

  * First-time SSO users are auto-provisioned in Postgres (no OTP, no email sent)
  * Existing OTP users are resolved to their existing UUID


AFTER — OTP flow (unchanged):

  OTP User → FE (X-User-Token: {session})
    → agentic-backend reads X-User-Token
      → platform-api /public/auth/validate
        → not Auth0 JWT → proxies to clj-pg-wrapper
          → Postgres session/token validation
          → returns {userId: <platform_uuid>, email, typeName, name} ✅

Data Ownership

┌─────────────────────────────────────────────────────────────────┐
│  clj-pg-wrapper + Postgres (clj-services DB)                   │
│  ─────────────────────────────────────────────                  │
│  AUTHORITATIVE source for user identity                        │
│  • users table: id (UUID PK), email, first_name, last_name     │
│  • POST /api/v1/users/ensure — get or create user by email     │
│  • All other services reference this UUID                      │
├─────────────────────────────────────────────────────────────────┤
│  platform-api + DynamoDB                                       │
│  ─────────────────────────────────────────────                  │
│  References user UUID for:                                     │
│  • Organization membership (org_members table)                 │
│  • Audit trails, collection ownership                          │
│  • SSO configuration (org sso block)                           │
├─────────────────────────────────────────────────────────────────┤
│  agentic-backend                                               │
│  ─────────────────────────────────────────────                  │
│  References user UUID for:                                     │
│  • Workflows, chats, files, MCP endpoints                      │
│  • get_or_create() in user_service — string PK                 │
├─────────────────────────────────────────────────────────────────┤
│  Auth0                                                         │
│  ─────────────────────────────────────────────                  │
│  Issues JWTs with sub claim (e.g. waad|abc123)                 │
│  • NOT the platform UUID — resolved via /users/ensure          │
│  • Post-login Action enriches JWT with org_id, email, roles    │
└─────────────────────────────────────────────────────────────────┘

Fix (3 changes, 3 repos)

1. clj-pg-wrapper: src/routes/users.py

New POST /api/v1/users/ensure endpoint — gets an existing user by email or creates one if they don't exist. Unlike the OTP get-or-create endpoint, this does not generate an OTP code or send any emails. Used by platform-api to provision users during first SSO login.

2. platform-api: src/routes/auth.py

The /public/auth/validate and /public/auth/check endpoints now try Auth0 JWT validation first before proxying to clj-pg-wrapper. After validation, they call POST /api/v1/users/ensure to resolve (or create) the user's platform UUID.

The response shape is identical to what clj-pg-wrapper returns:

{
  "userId": "d75f5ee0-d9be-11ef-9dab-52d74335e147",
  "email": "user@ey.com",
  "typeName": "user",
  "name": "Jordan Austin"
}

Identity resolution behavior:

  • Existing user found in Postgres: userId is their clj-pg-wrapper UUID, name is their real name
  • New SSO-only user (first login): User auto-provisioned in Postgres, userId is the new UUID
  • clj-pg-wrapper unreachable: Gracefully falls back to Auth0 sub with a warning log

3. agentic-backend: src/api/auth.py

get_current_user() now accepts both headers with RFC 7235-compliant parsing:

async def get_current_user(
    x_user_token: Annotated[str | None, Header(alias="X-User-Token")] = None,
    authorization: Annotated[str | None, Header()] = None,
) -> Dict[str, Any]:
    ...
    token = x_user_token
    if not token and authorization:
        parts = authorization.split(None, 1)
        if len(parts) == 2 and parts[0].casefold() == "bearer":
            token = parts[1].strip()

Impact

  • OTP users: Zero change. clj-pg-wrapper validation path is untouched.
  • SSO users: Can now authenticate with agentic-backend. All workflow, chat, file, and MCP endpoints become accessible. All users get a consistent platform UUID — existing users keep theirs, new users are auto-provisioned.
  • M2M (service-to-service): Unaffected. M2M auth uses a separate code path.

Testing

  • All 41 platform-api auth tests pass
  • agentic-backend CI pipeline passes (Ruff + Docker tests)
  • clj-pg-wrapper: unit tests for /users/ensure (existing user, new user, email normalization)
  • Staging validation: SSO login on ey-sso.vercel.app → use workflow builder → confirm agentic-backend calls succeed with correct userId

Branch

All changes are on feature/COM-18-ad-phase-1 in their respective repos.

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