03-security-and-tenantswave: W1filled10 citations

JWT Authentication — Gateway Guards and Strategies

Audiences: developer, internal

JWT Authentication — Gateway Guards and Strategies

Dudoxx HMS uses a Trusted Gateway pattern: browsers validate against Next.js via JWT, and Next.js forwards requests to NestJS using a validated API key plus gateway headers — NestJS never sees raw JWT tokens from browsers in the standard flow.

Business Purpose

A healthcare platform requires rock-solid authentication: clinicians must be securely identified on every API call without friction, and the system must prevent unauthorized access to patient data. The Trusted Gateway Pattern achieves this by keeping JWT validation in the Next.js layer (where it can also enforce session freshness and cookie management) and presenting NestJS with a pre-validated identity via signed service-to-service headers.

This design means NestJS never exposes a JWT endpoint directly to browsers, reducing the attack surface and eliminating the need to manage CORS on the API server. For mobile apps or external integrations that cannot go through Next.js, a direct JWT strategy is also wired but gated behind the same GatewayAuthGuard.

Business outcome: A doctor logs in; Next.js issues a JWT access token (1-hour TTL) + refresh token (7-day TTL) stored in Session (Prisma) and returns them to the browser. On every subsequent page request the browser sends the JWT to Next.js; Next.js validates it and forwards X-Gateway-User-* headers to NestJS; NestJS trusts these headers because the accompanying X-API-Key matches a registered gateway key.

Audiences

  • Investor: The Trusted Gateway pattern is a defence-in-depth architecture: even a compromised frontend cannot forge NestJS-level access without also holding the gateway API key, which never leaves the server network.
  • Clinical buyer: Clinicians experience seamless authentication — JWT refresh is transparent, sessions are tracked in the database for audit, and stale sessions are cleaned up automatically.
  • Developer/partner: Partners building integrations send POST /auth/login to get tokens, then include Authorization: Bearer <token> on subsequent calls. The JwtAuthGuard or GatewayAuthGuard handles validation depending on the call path.
  • Internal (ops/support): Session table in ddx_api_main (prisma-main) holds token, refreshToken, status, expiresAt. cleanupExpiredSessions() is available via AuthService. Failed logins are logged via logFailedLogin().

Architecture

The authentication surface has two complementary paths:

Path 1 — Trusted Gateway (primary, all ddx-web traffic). Entry: Browser —JWT→ Next.js (ddx-web, port 6200).

#HopAction
1Next.js (ddx-web, port 6200)validates JWT locally (next-auth / ddx-web/src/auth.ts)
2Next.js (ddx-web, port 6200)forwards X-API-Key + X-Clinic-ID + X-Gateway-User-* headers
3NestJS (ddx-api, port 6100)GatewayAuthGuard validates X-API-Key (source lookup)
4NestJS (ddx-api, port 6100)builds request.user from X-Gateway-User-* headers

Path 2 — Direct JWT (mobile apps, external integrations). Entry: external client —Authorization: Bearer <token>→ NestJS.

#Component
1JwtAuthGuard (Passport 'jwt' strategy via jwt.strategy.ts)
2JwtOrApiKeyGuard handles the union case

The GatewayAuthGuard is registered as APP_GUARD in app.module.ts — it runs globally as the first step of the pipeline documented in ddx-api/CLAUDE.md §Request Pipeline. Architecture is anchored in coding_context/ddx-hms-context.md. Cross-cutting tenant isolation depends on this guard building request.user.organizationId — see tenant-isolation.md.

Tech Stack & Choices

TechnologyRole
@nestjs/jwt + @nestjs/passportJWT sign/verify + strategy framework
passport-jwtPassport strategy for Bearer token extraction
passport-headerapikeyHeader-based API key strategy (used by ApiKeyStrategy)
@nestjs/config (ConfigService)Reads JWT_SECRET, JWT_EXPIRES_IN, JWT_REFRESH_SECRET, JWT_REFRESH_EXPIRES_IN
Prisma Session model (ddx_api_main)Persists access+refresh tokens, status, expiresAt
@ddx/prisma-main UserRole enumProvides the role hierarchy used in RolesGuard

JWT payload shape (JwtPayload interface, token.service.ts:7):

ts
interface JwtPayload {
  sub: string;           // user UUID
  email: string;
  role: string;          // primary role: SUPER_ADMIN, DOCTOR, NURSE, etc.
  organizationId: string; // clinic slug (or "platform" for SUPER_ADMIN)
  dbInstanceId: string;  // Odoo-style instance lock
  jti: string;           // unique token ID prevents duplicate tokens
  iat?: number;
  exp?: number;
}

Role hierarchy (from ddx-api/CLAUDE.md): SUPER_ADMIN > ORG_ADMIN > CLINIC_ADMIN > DOCTOR/NURSE > RECEPTIONIST > ACCOUNTANT > STAFF > PATIENT

Why organizationId is a clinic slug, not UUID: the slug is stable across database migrations, URL-friendly for FHIR partition headers, and used as X-Clinic-ID throughout the system. SUPER_ADMIN tokens receive "platform" as their organizationId — this sentinel bypasses TenantIsolationInterceptor's org lookup.

Why dbInstanceId: prevents tokens from being replayed against a different database instance after a tenant migration (Odoo-style approach — database-instance.service.ts generates this on boot).

Data Flow

Login flow

Entry: POST /auth/login (credentials)AuthControllerAuthService.login().

#StageStep
1AuthenticationService.validateUser(email, password)Prisma: find user, bcrypt.compare(password, hash)
2TokenService.generateTokens(user)databaseInstanceService.getInstanceId()dbInstanceId
3TokenService.generateTokens(user)extracts role from userRoleAssignment (prisma-main)
4TokenService.generateTokens(user)resolves org UUID → slug (prisma: organization.slug)
5TokenService.generateTokens(user)jwtService.signAsync(payload, { secret: JWT_SECRET, expiresIn })
6TokenService.generateTokens(user)jwtService.signAsync(payload, { secret: JWT_REFRESH_SECRET, expiresIn: 7d })
7TokenService.generateTokens(user)prisma.session.create({ token, refreshToken, status: ACTIVE })
8returnAuthResponse { accessToken, refreshToken, user: {...} }

Per-request validation (Trusted Gateway path)

Entry: NestJS receives request from Next.js → GatewayAuthGuard.canActivate().

#CheckOutcome
1reflector: is @Public()?→ skip all checks
2validates X-API-Key against this.apiKeys maploaded from env vars
3validates X-Clinic-ID formatisOrganizationIdentifier
4resolves clinic existenceOrganizationService.getOrganizationId
5checks X-Gateway-Source matches the key's registered source
6if X-Gateway-User-ID present + source is ddx-*buildUserContext()GatewayUser { id, email, role, roles, permissions, organizationId, fhirIds... }
7if no user IDServiceAccount { role: SUPER_ADMIN, isServiceAccount: true }

Result: request.user = GatewayUser | ServiceAccount.

Token refresh flow

Entry: POST /auth/refresh { refreshToken }TokenService.refreshAccessToken(refreshToken).

#StepDetail
1jwtService.verify(token, JWT_REFRESH_SECRET)JwtPayload
2prisma.user.findUnique(payload.sub)must be ACTIVE
3prisma.userRoleAssignment.findFirst(userId)
4prisma.organizationMember.findFirst(userId)org.slug
5generateTokens(userWithRelations)→ new AuthResponse

Implicated Code

  • ddx-api/src/platform/auth/guards/gateway-auth.guard.ts:63GatewayAuthGuard implements CanActivate; loadApiKeys() at line 82 reads all 8 GATEWAY_API_KEY_* env vars; source-spoofing check at line 189 (security review 2026-04-18, Critical #2)
  • ddx-api/src/platform/auth/guards/gateway-auth.guard.ts:244buildUserContext(): trusted-frontend detection (ddx-* prefix + nextjs legacy alias), GatewayUser vs ServiceAccount construction
  • ddx-api/src/platform/auth/guards/gateway-auth.guard.ts:334validateClinicId(): isOrganizationIdentifier() format check + OrganizationService.getOrganizationId() DB existence check
  • ddx-api/src/platform/auth/services/token.service.ts:7JwtPayload interface; generateTokens() at line 72: role extraction fallback chain, slug resolution, jti unique ID
  • ddx-api/src/platform/auth/services/token.service.ts:137prisma.session.create(): persists both tokens with 30-day expiresAt
  • ddx-api/src/platform/auth/services/token.service.ts:181verifyAccessToken() / verifyRefreshToken(): JwtService.verify() with separate secrets
  • ddx-api/src/platform/auth/auth.service.ts:33AuthService orchestrator delegates to 7 sub-services: AuthenticationService, RegistrationService, PasswordService, EmailVerificationService, TokenService, FhirIntegrationService, SessionManagementService
  • ddx-api/src/platform/auth/guards/jwt-auth.guard.tsJwtAuthGuard extends AuthGuard('jwt') — used on direct-JWT endpoints
  • ddx-api/src/platform/auth/guards/roles.guard.ts:14RolesGuard: reads ROLES_KEY metadata from Reflector; service accounts must still satisfy required roles
  • ddx-api/src/platform/auth/strategies/jwt.strategy.ts — Passport JWT strategy; extracts Bearer token
  • ddx-api/src/platform/auth/decorators/public.decorator.ts@Public() sets IS_PUBLIC_KEY metadata; GatewayAuthGuard reads it at line 125

Operational Notes

Env vars (required):

VarPurpose
JWT_SECRETAccess token signing key
JWT_EXPIRES_INDefault 1h
JWT_REFRESH_SECRETRefresh token signing key (separate from access)
JWT_REFRESH_EXPIRES_INDefault 7d
GATEWAY_API_KEY_NEXTJSddx-web server → NestJS trusted gateway key
GATEWAY_API_KEY_SEEDERseeder → NestJS
GATEWAY_API_KEY_VOICELiveKit Python agent → NestJS
GATEWAY_API_KEY_VIVOXXvivoxx agent → NestJS
API_KEYLegacy primary key (kept for backward compat)

Known security posture:

  • Source-spoofing is blocked since the 2026-04-18 security review: if X-Gateway-Source is supplied in a request header, it MUST match the registered source for the API key (gateway-auth.guard.ts:189). A holder of GATEWAY_API_KEY_TEST cannot spoof ddx-vivoxx source.
  • Service accounts (isServiceAccount: true) receive role: SUPER_ADMIN by construction (buildUserContext() line 296). This is intentional for seeder/cron operations but means service accounts bypass @Roles() checks that don't explicitly list SUPER_ADMIN. Downstream: DynamicPermissionGuard blocks wildcard * on mutations.
  • @Public() completely bypasses GatewayAuthGuard. Routes marked @Public() must have an independent auth mechanism (e.g. SseController validates X-API-Key inline) or be genuinely public (health, login, email-verify).

RBAC bootstrap: on a fresh deploy the permissions and role_permissions tables must be seeded before any non-SUPER_ADMIN user can authorize. See ddx-api/CLAUDE.md §RBAC Bootstrap on Fresh Deploy.

Session cleanup: AuthService.cleanupExpiredSessions() deletes rows where expiresAt < now. Wire this to a cron job (@nestjs/schedule) for production — default session storage is in ddx_api_main.

Detailed auth flow: ddx-api/docs/GATEWAY_AUTH_ARCHITECTURE.md (full deep-dive); JWT structure: ddx-api/docs/TENANT_CREATION.md.

  • auth-api-token.md — the second authentication modality: user-facing LLM API keys (different from gateway keys)
  • rbac-roles.md — role enforcement layer that reads request.user built by this guard
  • tenant-isolation.mdTenantIsolationInterceptor reads request.user.organizationId set here
  • sse-event-engine.md — SSE endpoint opts out of GatewayAuthGuard via @Public() and validates the API key inline
    JWT Authentication — Gateway Guards and Strategies — Dudoxx Docs | Dudoxx