Skip to main content

Authentication and Tenancy

Authentication and tenancy are coupled on purpose. In Lumie, most useful backend work needs both an authenticated caller and a tenant ID that can be bound into PostgreSQL RLS. Identity without tenant scope is not enough for normal reads and writes.

This page is a reference document for the request context contract.

Source Paths

PathRole
app/src/main/java/com/lumie/app/config/SecurityConfig.javaGlobal route protection and filter ordering
modules/auth/src/main/java/com/lumie/auth/adapter/in/security/JwtAuthenticationFilter.javaEnd-user authentication filter
modules/auth/src/main/java/com/lumie/auth/adapter/out/security/JwtTokenProvider.javaJWT claim shape and issuance
modules/auth/src/main/java/com/lumie/auth/adapter/in/web/AuthController.javaRegistration, login, refresh, logout, and profile endpoints
modules/auth/src/main/java/com/lumie/auth/adapter/in/web/CookieUtils.javaShared access and refresh cookie contract
app/src/main/java/com/lumie/app/config/internal/InternalHmacAuthFilter.java/internal/** authentication contract
app/src/main/resources/{application.yaml,application-dev.yml}Cookie defaults and dev-only SameSite override
libs/common/src/main/java/com/lumie/common/tenant/RequestContextFilter.javaRequest correlation, MDC, and fallback header population
modules/tenant/src/main/java/com/lumie/tenant/adapter/in/web/TenantController.javaAnonymous public tenant lookup
modules/homepage/src/main/java/com/lumie/homepage/application/service/HomepageQueryService.javaPublic homepage lookup after tenant resolution
app/src/main/resources/db/migration/public/{V2__create_users_table,V4__federated_identities,V18__rls_baseline}.sqlHistorical auth-schema artifacts that may still remain in upgraded databases after federated-login removal

Request Context Flow

End-User Authentication Contract

Main HTTP surface

FlowEndpointsNotes
Registration and loginPOST /v1/register, POST /v1/register/owner, POST /v1/loginIssue access and refresh tokens plus user payload
Session lifecyclePOST /v1/refresh, POST /v1/logout, POST /v1/logout-all, GET/DELETE /v1/me/sessions...Refresh reads the refresh token from a cookie
ProfileGET/PATCH /v1/me, POST /v1/me/password, PATCH /v1/me/avatarRequires authenticated user context

AuthController exposes no GET /v1/oauth2/{provider}/... routes. Kakao, Google, and Naver login are outside the supported runtime request-context contract.

What the JWT carries

JwtTokenProvider issues both access and refresh tokens with these load-bearing claims:

ClaimMeaning
subUser ID
nameDisplay name
tenant_slugProduct-facing tenant identifier
tenant_idDatabase-facing tenant identifier used by RLS
roleCoarse role from Role
sidSession identifier shared by the access and refresh token pair
jtiToken identifier
typeaccess or refresh

The JWT filter accepts either:

  • Authorization: Bearer <token>
  • lumie_access_token cookie

On success it sets:

  • SecurityContextHolder
  • TenantContextHolder with both slug and ID
  • UserContextHolder with user ID, name, role, and session ID

CookieUtils issues:

  • lumie_access_token
  • lumie_refresh_token

Defaults from application.yaml and CookieConfig:

  • HttpOnly=true
  • Secure=true
  • SameSite=Lax by default
  • dev override: cookie.sameSite=None in application-dev.yml

The browser session contract is the same across registration, login, refresh, and authenticated profile reads. There is no separate provider-specific session store in the supported runtime flow.

Internal Authentication Contract

/internal/** routes are protected by InternalHmacAuthFilter, not by user JWTs.

Required headers:

  • X-Tenant-Slug
  • X-Timestamp
  • X-Signature

Signature formula:

Source anchor: lumie-backend/app/src/main/java/com/lumie/app/config/internal/InternalHmacAuthFilter.java#computeSignature

HMAC-SHA256(timestamp + "\n" + tenantSlug + "\n" + body)

Other hard rules from the filter:

  • maximum timestamp skew: 300 seconds
  • maximum buffered body size: 1 MiB
  • the tenant must exist and be active
  • successful verification grants synthetic ROLE_INTERNAL

This is the contract used by internal chatbot callbacks and any worker-facing internal HTTP surface.

Public Tenant Resolution Paths

Some routes start without ambient tenant context and discover the tenant first:

  • GET /v1/tenants/public/by-custom-id/{customId}
  • GET /v1/tenants/public/by-domain?host=...
  • GET /v1/homepage/public/by-custom-id/{customId}

The homepage path is the most important boundary example:

  1. HomepageQueryService.getPublicByCustomId(...) asks the tenant module for customId -> {slug, tenantId}.
  2. It restores both values with TenantContextHolder.withinContext(...).
  3. It crosses into the proxied inner HomepageQueryService.Tx bean so the transactional boundary exists.
  4. RlsTenantContextAspect binds app.tenant_id and the read becomes database-visible.

Without the tenant ID and the proxied transaction, the homepage row would stay invisible under RLS.

Route Protection Summary

SecurityConfig permits these major unauthenticated surfaces:

  • registration, login, and refresh
  • actuator/**
  • /v3/api-docs/**, /swagger-ui/**, /swagger-ui.html
  • public tenant, homepage, and file routes
  • /internal/**, but only after the HMAC filter grants ROLE_INTERNAL

Everything else requires an authenticated user context.

Follow-Through Into Other Modules

  • OwnerRegisteredEvent from auth is consumed by staff to bootstrap the OWNER staff record.
  • StudentSelfRegisteredEvent starts a separate student self-registration path.
  • Owner login and refresh can resolve tenant state before normal request-time tenant context exists, which is why auth depends on tenant lookup data.

Failure Modes And Drift

  • Having only tenantSlug but no tenantId is not enough for RLS-backed data access.
  • Applied databases may still contain historical federated-login schema such as federated_identities or the legacy users.oauth_provider column from earlier migrations. Those artifacts are not part of the supported runtime sign-in contract and should not be treated as active request-context dependencies.
  • The runtime no longer reads or writes those historical artifacts. Leave them untouched during the code-removal deploy, then use a later forward-only contract migration to archive or drop them after deploy verification confirms the password-based login and refresh paths are healthy.
  • An unknown or inactive tenant causes /internal/** requests to fail before controller code runs.
  • Dev cookie behavior is intentionally different from prod because the standard dev setup is frontend-local and backend-cluster.
  • Contract drift exists on the homepage public-read path: the controller comment says unpublished homepages should return 404, but HomepageQueryService.Tx.findCurrent() and its tests currently return any saved config, including published=false.

Verification Commands

cd lumie-backend
./gradlew :modules:auth:test
./gradlew :modules:auth:test --tests '*AuthControllerMeTest'

Useful tests:

  • modules/auth/src/test/java/com/lumie/auth/adapter/in/web/AuthControllerMeTest.java
  • libs/common/src/test/java/com/lumie/common/tenant/RequestContextFilterTest.java