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.
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:
- agentic-backend forwards the token to platform-api's
/public/auth/validate - That endpoint blindly proxies to clj-pg-wrapper
- clj-pg-wrapper has no idea how to validate an Auth0 JWT
- Result: 403
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} ✅
┌─────────────────────────────────────────────────────────────────┐
│ 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 │
└─────────────────────────────────────────────────────────────────┘
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.
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:
userIdis their clj-pg-wrapper UUID,nameis their real name - New SSO-only user (first login): User auto-provisioned in Postgres,
userIdis the new UUID - clj-pg-wrapper unreachable: Gracefully falls back to Auth0
subwith a warning log
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()- 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.
- 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
All changes are on feature/COM-18-ad-phase-1 in their respective repos.
- platform-api PR: On existing branch, deployed to staging
- agentic-backend PR: https://github.com/Faction-V/agentic-backend/pull/362
- clj-pg-wrapper:
feature/COM-18-ad-phase-1