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
| Path | Role |
|---|---|
app/src/main/java/com/lumie/app/config/SecurityConfig.java | Global route protection and filter ordering |
modules/auth/src/main/java/com/lumie/auth/adapter/in/security/JwtAuthenticationFilter.java | End-user authentication filter |
modules/auth/src/main/java/com/lumie/auth/adapter/out/security/JwtTokenProvider.java | JWT claim shape and issuance |
modules/auth/src/main/java/com/lumie/auth/adapter/in/web/AuthController.java | Registration, login, refresh, logout, and profile endpoints |
modules/auth/src/main/java/com/lumie/auth/adapter/in/web/CookieUtils.java | Shared 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.java | Request correlation, MDC, and fallback header population |
modules/tenant/src/main/java/com/lumie/tenant/adapter/in/web/TenantController.java | Anonymous public tenant lookup |
modules/homepage/src/main/java/com/lumie/homepage/application/service/HomepageQueryService.java | Public homepage lookup after tenant resolution |
app/src/main/resources/db/migration/public/{V2__create_users_table,V4__federated_identities,V18__rls_baseline}.sql | Historical auth-schema artifacts that may still remain in upgraded databases after federated-login removal |
Request Context Flow
End-User Authentication Contract
Main HTTP surface
| Flow | Endpoints | Notes |
|---|---|---|
| Registration and login | POST /v1/register, POST /v1/register/owner, POST /v1/login | Issue access and refresh tokens plus user payload |
| Session lifecycle | POST /v1/refresh, POST /v1/logout, POST /v1/logout-all, GET/DELETE /v1/me/sessions... | Refresh reads the refresh token from a cookie |
| Profile | GET/PATCH /v1/me, POST /v1/me/password, PATCH /v1/me/avatar | Requires 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:
| Claim | Meaning |
|---|---|
sub | User ID |
name | Display name |
tenant_slug | Product-facing tenant identifier |
tenant_id | Database-facing tenant identifier used by RLS |
role | Coarse role from Role |
sid | Session identifier shared by the access and refresh token pair |
jti | Token identifier |
type | access or refresh |
The JWT filter accepts either:
Authorization: Bearer <token>lumie_access_tokencookie
On success it sets:
SecurityContextHolderTenantContextHolderwith both slug and IDUserContextHolderwith user ID, name, role, and session ID
Cookie contract
CookieUtils issues:
lumie_access_tokenlumie_refresh_token
Defaults from application.yaml and CookieConfig:
HttpOnly=trueSecure=trueSameSite=Laxby default- dev override:
cookie.sameSite=Noneinapplication-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-SlugX-TimestampX-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:
300seconds - 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:
HomepageQueryService.getPublicByCustomId(...)asks the tenant module forcustomId -> {slug, tenantId}.- It restores both values with
TenantContextHolder.withinContext(...). - It crosses into the proxied inner
HomepageQueryService.Txbean so the transactional boundary exists. RlsTenantContextAspectbindsapp.tenant_idand 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 grantsROLE_INTERNAL
Everything else requires an authenticated user context.
Follow-Through Into Other Modules
OwnerRegisteredEventfrom auth is consumed by staff to bootstrap the OWNER staff record.StudentSelfRegisteredEventstarts 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
tenantSlugbut notenantIdis not enough for RLS-backed data access. - Applied databases may still contain historical federated-login schema such as
federated_identitiesor the legacyusers.oauth_providercolumn 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, butHomepageQueryService.Tx.findCurrent()and its tests currently return any saved config, includingpublished=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.javalibs/common/src/test/java/com/lumie/common/tenant/RequestContextFilterTest.java