core-auth
Qué problema resuelve
Todo proyecto de EchoTechs necesita autenticar tres tipos de llamador distintos: personas usando el frontend, servicios internos hablándose entre sí, e integraciones de terceros consumiendo la API. Cada uno necesita un mecanismo de credencial diferente, y mezclarlos es exactamente cómo aparecen los bugs de permisos.
core-auth implementa los tres sin mezclarlos nunca — una request se autentica como exactamente un
principal — y deja resuelto lo aburrido y peligroso: rotación de refresh tokens con detección de reuso,
hasheo de credenciales opacas, cookies httpOnly, y la resolución del tenant en cada request.
Lo que no hace: autorización (eso es core-authz) ni guardar el perfil de negocio
de un usuario — el modelo de negocio de cada proyecto es distinto. Sí posee ahora una tabla propia,
UserAccount (spec 1.1, ver "Gestión de cuentas" más abajo), pero acotada a lo que hace a la
autenticación — email, password hash, lockout, OTP — nunca campos de negocio; expone dos interfaces que tu
proyecto puede seguir implementando contra su propia tabla si ya tiene una, o dejar sin implementar para
usar la cuenta batteries-included de core-auth.
Cómo se agrega
dependencies {
implementation("dev.echotechs.core:core-auth:0.1.0-SNAPSHOT")
}
Arrastra core-web, core-persistence, spring-boot-starter-security y Jackson 3. La implementación de
JJWT (jjwt-impl, jjwt-jackson) entra como runtimeOnly.
Incluí el changelog en el master changelog de tu proyecto — se resuelve desde el classpath, dentro del jar, sin copiar archivos entre repos:
<databaseChangeLog ...>
<include file="db/changelog/core-auth/changelog-master.xml"/>
<!-- tus propios changesets después -->
</databaseChangeLog>
Eso crea cuatro tablas en el esquema core: user_session, refresh_token, service_account_client y
api_key.
Configuración mínima
echotechs:
auth:
jwt:
secret: ${JWT_SECRET} # base64, mínimo 256 bits decodificados. Sin default: la app no arranca sin esto.
Todas las propiedades disponibles:
| Propiedad | Default | Notas |
|---|---|---|
echotechs.auth.jwt.secret | — | Obligatoria. HMAC-SHA256 en base64 |
echotechs.auth.jwt.issuer | echotechs-core-auth | Se verifica al parsear |
echotechs.auth.jwt.access-token-ttl | 12m | |
echotechs.auth.jwt.user-audience | echotechs-user | claim aud de tokens de usuario |
echotechs.auth.jwt.service-audience | echotechs-service | claim aud de service accounts |
echotechs.auth.refresh-token.ttl | 30d | |
echotechs.auth.cookies.access-token-name | access_token | |
echotechs.auth.cookies.refresh-token-name | refresh_token | |
echotechs.auth.cookies.refresh-token-path | /api/auth | |
echotechs.auth.cookies.secure | true | false sólo en desarrollo local por HTTP |
echotechs.auth.cookies.same-site | Lax | |
echotechs.auth.cookies.domain | — (host-only) | Dominio padre compartido para frontend y backend en subdominios siblings — ver abajo |
echotechs.auth.api-key.prefix | sk_live_ | |
echotechs.auth.api-key.header-name | X-Api-Key |
API pública
TenantContext — multi-tenancy
public final class TenantContext {
public static CorePrincipal getCurrentPrincipal();
public static UUID getCurrentOrg();
}
Acceso al principal autenticado resuelto una vez por request por TenantContextFilter. El código de
dominio lee TenantContext.getCurrentOrg() en vez de arrastrar un tenant id por cada firma de método.
getCurrentOrg() lanza IllegalStateException en dos casos: fuera de una request autenticada, o para un
principal sin organización única (un service account, que no es organization-scoped).
ThreadLocalTenantContextFilter lo limpia en un finally, así que no se filtra al siguiente request del mismo worker.
Pero no cruza a hilos nuevos: si tirás trabajo a un @Async o a un ExecutorService, capturá el valor
antes y pasalo explícitamente.
Los tres principales
public sealed interface CorePrincipal permits UserPrincipal, ServiceAccountPrincipal, ApiKeyPrincipal {
PrincipalType type();
UUID principalId();
UUID organizationId();
}
public enum PrincipalType { USER, SERVICE_ACCOUNT, API_KEY }
public record UserPrincipal(UUID userId, UUID organizationId, UUID sessionId) implements CorePrincipal {}
public record ServiceAccountPrincipal(UUID serviceAccountId, String clientId, Set<String> scopes)
implements CorePrincipal {} // organizationId() devuelve siempre null
public record ApiKeyPrincipal(UUID apiKeyId, UUID organizationId, Set<String> scopes)
implements CorePrincipal {}
ApiKeyPrincipal.principalId() devuelve el id de la API key, no el de un usuario.
Las dos interfaces — ya no son obligatorias de implementar
public interface UserCredentialAuthenticator {
Optional<AuthenticatedUser> authenticate(String username, String password);
}
public record AuthenticatedUser(UUID userId, UUID organizationId) {}
public interface PasswordResetHandler {
/** Comportate siempre como si el email existiera, aunque no exista — no filtres qué cuentas hay. */
void requestReset(String email);
void confirmReset(String resetToken, String newPassword);
}
Hasta la sección anterior a esta ("gestión de cuentas", spec 1.1) estas dos interfaces eran obligatorias:
sin un bean propio, CoreAuthAutoConfiguration no podía construir AuthController y el contexto fallaba al
arrancar. Ahora son opcionales — DefaultUserCredentialAuthenticator/DefaultPasswordResetHandler
(core-auth/.../account/) se registran @ConditionalOnMissingBean, respaldados por la tabla UserAccount
propia del módulo (ver más abajo). Un proyecto con su propia tabla de usuarios (como el
DemoUserCredentialAuthenticator de reference-app) sigue pudiendo registrar su propio bean sin cambios —
retrocompatible.
SessionVersionProvider — revocación inmediata (opcional)
public interface SessionVersionProvider {
Optional<Integer> currentVersion(UUID userId);
}
JwtAuthenticationFilter compara el claim ver del token contra esto para rechazar tokens emitidos antes
de un "cerrar sesión en todos lados" o un cambio de contraseña, aunque el token todavía no haya expirado.
Sin un bean propio se usa DefaultSessionVersionProvider, que devuelve Optional.empty() — y un empty()
se interpreta como "no enforzar". O sea: por default ver no se verifica, y un access token sigue
siendo válido hasta que expira naturalmente (12 min).
Cuando lo implementes, tiene que ser barato: corre en el hot path de cada request. Usá una cache, no una consulta a base.
Servicios
public class RefreshTokenService {
public IssuedTokens login(UUID userId, UUID organizationId, String userAgent, String ipAddress);
public IssuedTokens rotate(String rawRefreshToken);
public void logout(UUID sessionId);
public void logoutByRefreshToken(String rawRefreshToken);
public void logoutAll(UUID userId);
}
public record IssuedTokens(String accessToken, String refreshToken, UUID sessionId, UUID userId,
UUID organizationId, Instant refreshTokenExpiresAt) {}
public class ApiKeyService {
public ProvisionedApiKey issue(UUID organizationId, Set<String> scopes, String description, Instant expiresAt);
public Optional<ApiKeyPrincipal> verify(String rawKey);
public void revoke(UUID apiKeyId);
}
public record ProvisionedApiKey(UUID id, String rawKey, String keyPrefix) {}
public class ServiceAccountAuthService {
public String authenticate(String clientId, String clientSecret);
public ProvisionedServiceAccount registerClient(String clientId, String description, Set<String> scopes);
}
public record ProvisionedServiceAccount(UUID id, String clientId, String clientSecret) {}
rawKey y clientSecret se devuelven una sola vez, en el momento de emisión. Sólo se persiste el
SHA-256 (OpaqueTokens.hash), así que no hay forma de recuperarlos después.
public class JwtTokenService {
public String issueUserAccessToken(UserPrincipal principal, int sessionVersion);
public String issueServiceAccessToken(ServiceAccountPrincipal principal);
public ParsedAccessToken parse(String token); // throws JwtException
}
public record ParsedAccessToken(CorePrincipal principal, Integer sessionVersion) {}
parse verifica firma, issuer y expiración. No verifica ver — eso lo hace el filtro vía
SessionVersionProvider.
UserSessionsRevokedEvent
public record UserSessionsRevokedEvent(UUID userId, String reason) {}
Se publica cuando se revocan todas las sesiones de un usuario. reason es "logout_all" o
"refresh_token_reuse_detected". core-auth no es dueño del registro de usuario, así que no puede
incrementar ver por su cuenta: escuchá este evento para hacerlo y para invalidar la cache que respalde tu
SessionVersionProvider.
Gestión de cuentas (spec 1.1) — UserAccount y todo lo que se construye sobre ella
Paquete core-auth/src/main/java/dev/echotechs/core/auth/account/ — verificado end-to-end contra Postgres
real, SMTP real (Mailpit) y códigos TOTP reales, no sólo con H2/mocks.
UserAccount (schema core) modela sólo lo que hace a la autenticación — email, hash de password,
estado de lockout, estado de OTP, Activatable (ver core-persistence) — nunca
campos de negocio. Mismo patrón UUID-por-valor sin FK que ya usan UserSession/RefreshToken: un proyecto
con su propia tabla de usuarios de negocio la linkea por ese mismo UUID, sin acoplarse.
- Invitación —
UserAccountService.inviteUser(email, organizationId)crea la cuenta con un password hash aleatorio inutilizable (nunca viaja por email) y manda un link/código de un solo uso. - Reset self-service y por admin — comparten el mismo mecanismo (
OneTimeToken,purposedistingueINVITATION/PASSWORD_RESET). Importante, encontrado probando contra infra real: el endpoint público/password-reset/confirmno sabe si el link que está completando fue originalmente una invitación o un reset —OneTimeTokenService.consume(rawToken, Set<Purpose>)acepta ambos propósitos a propósito; una versión anterior que sólo aceptabaPASSWORD_RESETrechazaba invitaciones reales con 401. - Lockout —
echotechs.auth.lockout.max-attempts/duration-minutes(default 5/15). Un intento con password correcta sobre una cuenta bloqueada sigue devolviendo401 ACCOUNT_LOCKED, no éxito. - OTP (TOTP) —
dev.samstevens.totp(no el starter de Spring Boot, la librería sola — mismo criterio que el SDK de OpenFGA). Flujo de dos pasos:POST /api/auth/otp/setupgenera un secreto (no lo activa todavía),POST /api/auth/otp/enable {code}lo confirma con un código real y devuelve los backup codes (una sola vez).OtpAwareCredentialAuthenticator— interfaz nueva, opcional — es cómoDefaultUserCredentialAuthenticatorle agrega ese segundo paso aAuthController.loginsin tocar su contrato: si el bean deUserCredentialAuthenticatortambién implementa esta interfaz,AuthControllerla consulta después de validar la password; si no la implementa, cero cambio de comportamiento.LoginRequestahora tiene un tercer campo opcionalotpCode(compatible con clientes viejos que no lo mandan).otp.required-for-roles(default[admin]) no bloquea el login si todavía no activaste OTP — eso necesitaría un flujo de onboarding forzado que no está implementado. Lo que sí hace: un rol en esa lista no puede desactivar OTP una vez que lo activó (POST /api/auth/otp/disableresponde 403).- Tu propio controller de OTP — los endpoints
POST /api/auth/otp/setup|enable|disablelos sirve elOtpControllerdecore-auth(opera sobreUserAccount). Si tu proyecto maneja OTP sobre otra entidad (p.ej. unUserdistinto) y registra su propio controller sobre esos mismos paths, ponéechotechs.auth.otp.controller-enabled=falseen tuapplication.yml:CoreAuthAutoConfigurationno registra suOtpControllery evitás el "Ambiguous mapping" al arrancar.OtpService(la verificación en login) se sigue registrando igual, así queOtpAwareCredentialAuthenticatorsigue funcionando. Notá el guion encontroller-enabled— es la forma con la que se bindea el campoOtpProperties.controllerEnabledy la que sugiere el autocompletado del IDE.
- Sesiones activas —
GET /api/auth/sessions(propias, no las de otro usuario),DELETE /api/auth/sessions/{sessionId}revoca una sola sesión sin tocar las demás (a diferencia de/logout-all) —RefreshTokenService.logoutno valida dueño por sí mismo,UserSessionControllersí antes de llamarlo. - Desactivar, no borrar —
UserAccount implements Activatable. Una cuenta desactivada devuelve401 INVALID_CREDENTIALSal loguearse (noACCOUNT_LOCKEDni un código distinto — no distingue desactivada de contraseña incorrecta, mismo principio de no filtrar información quePasswordResetHandlerya seguía). - Autorización sobre esta gestión —
OrgAdminGuard(package-private,account/web/). No usa@RequiresPermission/OpenFGA:core-authzdepende decore-auth, así que la dependencia no puede ir al revés. Chequea el rol"admin"enUserAccount.getRoles()del propio caller contra la organización del target — resuelto enteramente con datos decore-auth, sin acoplar un módulo nuevo. - Notificaciones de seguridad — cambio de password, activar/desactivar OTP: llaman a
NotificationService.send(...)directo, mismo patrón sin bus de eventos que ya usa el resto del código. Plantillas encore-auth/src/main/resources/notifications/templates/account-*.html— con el prefijoaccount-a propósito:reference-appya tenía su propiopassword-reset-requested.html, y ambos templates conviven en el mismo classpath (ClassLoaderTemplateResolverno sabe de qué módulo vino cada uno). No implementado: notificación automática de "login desde dispositivo nuevo" — el métodonotifyNewDeviceLoginexiste pero nada lo dispara todavía; detectarlo automáticamente necesitaría extenderAuthControllercon el user-agent/IP del login, que quedó fuera de alcance.
Endpoints
AuthController (/api/auth) y ServiceTokenController (/api/auth) se registran automáticamente:
| Método | Ruta | Auth | Qué hace |
|---|---|---|---|
| POST | /api/auth/login | pública | Valida credenciales, setea ambas cookies |
| POST | /api/auth/refresh | cookie refresh | Rota el par de tokens |
| POST | /api/auth/logout | pública | Revoca la sesión del refresh token presentado |
| POST | /api/auth/logout-all | access token | Revoca todas las sesiones del usuario |
| POST | /api/auth/password-reset/request | pública | Delega en PasswordResetHandler → 202 |
| POST | /api/auth/password-reset/confirm | pública | Delega en PasswordResetHandler → 200 |
| POST | /api/auth/service-token | pública | Client credentials → JWT en el body |
| POST | /api/users | admin (OrgAdminGuard) | Invita un usuario nuevo → 202 |
| POST | /api/users/{id}/deactivate | admin | → 200 |
| POST | /api/users/{id}/reactivate | admin | → 200 |
| POST | /api/users/{id}/reset-password | admin | Reset disparado por un admin → 202 |
| POST | /api/auth/otp/setup | access token | Genera secreto (no lo activa) → 200 |
| POST | /api/auth/otp/enable | access token | Confirma con un código real → backup codes |
| POST | /api/auth/otp/disable | access token | 403 si el rol lo tiene obligatorio |
| GET | /api/auth/sessions | access token | Sesiones activas propias |
| DELETE | /api/auth/sessions/{sessionId} | access token | Revoca una sesión propia puntual |
Los tokens de usuario viajan sólo en cookies httpOnly; SessionResponse sólo lleva
{userId, organizationId, sessionId}. El token de service account sí va en el body, porque el llamador es
un backend, no un navegador.
Todas esas rutas están en permitAll salvo /logout-all; .anyRequest().authenticated() cubre el resto de
tu aplicación.
Frontend y backend en subdominios siblings — echotechs.auth.cookies.domain
Por default AuthCookieFactory no setea el atributo Domain de las cookies, lo que en RFC 6265 es
host-only: el navegador sólo devuelve la cookie al host exacto que la seteó. Eso está bien cuando
frontend y backend comparten un mismo origen, pero create-echotechs-app despliega por default frontend y
backend como dos aplicaciones Coolify separadas en subdominios siblings de un wildcard (backend
api.hotel.lab.echotechs.net, frontend hotel.lab.echotechs.net). Con cookies host-only, el POST /api/auth/login (al backend) las setea scoped a api.hotel... y el middleware del frontend
(@echotechs/auth-web, que lee access_token en hotel.lab...) nunca las ve → loop de login aunque la
sesión sea válida.
Seteá un dominio padre compartido sin punto inicial y la cookie pasa a cubrir ese dominio y todos sus subdominios (RFC 6265), así ambos subdominios la reciben:
echotechs:
auth:
cookies:
domain: lab.echotechs.net # cubre hotel.lab.echotechs.net y api.hotel.lab.echotechs.net
- Sin punto inicial:
lab.echotechs.netes la forma canónica de RFC 6265 y ya cubre el dominio + todos sus subdominios. Un punto inicial (.lab.echotechs.net) también lo aceptan los navegadores (lo ignoran per spec) pero no es canónico — pasalo tal cual, la librería no normaliza. - Dejalo unset (default) para host-only — el comportamiento de siempre, correcto cuando frontend y
backend comparten origen. Es totalmente backward compatible: sin la propiedad, ninguna cookie lleva
Domain. - Mantené
same-site: Lax(el default) para que la lectura cross-subdominio funcione en la primera navegación;Strictla bloquearía. - Esto no reemplaza proxyear
/api/*desde el frontend si necesitás que el frontend y el backend sean orígenes totalmente independientes (dominios custom, clouds distintos) — para eso la cookie cross-origin no alcanza y hace falta otro mecanismo de portación del token.domainresuelve el caso sibling-subdomain de un mismo padre, que es la topología default del tooling.
Ejemplo de uso
Implementar las dos interfaces
Adaptado de reference-app/src/main/java/.../DemoUserCredentialAuthenticator.java, reemplazando el mapa
hardcodeado por tu tabla real:
@Component
public class DatabaseUserCredentialAuthenticator implements UserCredentialAuthenticator {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public DatabaseUserCredentialAuthenticator(UserRepository userRepository, PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}
@Override
public Optional<AuthenticatedUser> authenticate(String username, String password) {
return userRepository.findByUsername(username)
.filter(user -> passwordEncoder.matches(password, user.getPasswordHash()))
.map(user -> new AuthenticatedUser(user.getId(), user.getPrimaryOrganizationId()));
}
}
@Component
public class EmailPasswordResetHandler implements PasswordResetHandler {
@Override
public void requestReset(String email) {
// Buscá el usuario y mandá el email si existe — pero devolvé siempre lo mismo,
// exista o no, para no filtrar qué cuentas están registradas.
}
@Override
public void confirmReset(String resetToken, String newPassword) {
// Validá el token contra tu propia tabla y guardá el hash nuevo.
}
}
Usar el tenant en el código de dominio
@Service
public class PurchaseRequestService {
private final PurchaseRequestRepository repository;
public List<PurchaseRequest> listForCurrentOrg() {
return repository.findByOrganizationId(TenantContext.getCurrentOrg());
}
public PurchaseRequest create(String title) {
CorePrincipal principal = TenantContext.getCurrentPrincipal();
return repository.save(new PurchaseRequest(
TenantContext.getCurrentOrg(), title, principal.principalId()));
}
}
Emitir una API key para un tercero
Extraído de AuthFlowIntegrationTest.apiKeyRoundTripsThroughIssueAndVerify:
ProvisionedApiKey provisioned = apiKeyService.issue(
organizationId,
Set.of("invoices:read"),
"integración con el ERP del cliente",
Instant.now().plus(Duration.ofDays(365)));
// provisioned.rawKey() es lo único que le das al tercero — no se puede recuperar después.
// provisioned.keyPrefix() ("sk_live_ab12cd34") es seguro de mostrar en un panel.
El tercero después manda X-Api-Key: sk_live_... y ApiKeyAuthenticationFilter resuelve el
ApiKeyPrincipal solo.
Registrar un service account
Extraído de AuthFlowIntegrationTest.serviceAccountClientCredentialsIssuesAServiceScopedToken:
ProvisionedServiceAccount provisioned = serviceAccountAuthService.registerClient(
"svc_report_worker", "worker de reportes nocturnos", Set.of("reports:generate"));
El worker después hace POST /api/auth/service-token con {"clientId": "...", "clientSecret": "..."} y
recibe {"accessToken": "...", "tokenType": "Bearer", "expiresInSeconds": 720}, que manda como
Authorization: Bearer.
Probar el flujo completo
Del AuthFlowIntegrationTest — el detalle que importa es .apply(springSecurity()):
private MockMvc mockMvc() {
return MockMvcBuilders.webAppContextSetup(webApplicationContext)
.apply(SecurityMockMvcConfigurers.springSecurity())
.build();
}
@Test
void loginThenRefreshRotatesTheRefreshTokenAndKeepsWorking() throws Exception {
MockMvc mockMvc = mockMvc();
MvcResult loginResult = mockMvc.perform(post("/api/auth/login")
.contentType("application/json")
.content("""
{"username":"alice","password":"correct-horse-battery-staple"}"""))
.andExpect(status().isOk())
.andReturn();
Cookie firstRefreshCookie = loginResult.getResponse().getCookie("refresh_token");
MvcResult refreshResult = mockMvc.perform(post("/api/auth/refresh").cookie(firstRefreshCookie))
.andExpect(status().isOk())
.andReturn();
Cookie rotatedRefreshCookie = refreshResult.getResponse().getCookie("refresh_token");
assertThat(rotatedRefreshCookie.getValue()).isNotEqualTo(firstRefreshCookie.getValue());
}
.apply(springSecurity()) no es opcionalSin eso, MockMvc despacha directo al handler y nunca corre el filter chain de Spring Security — o sea
JwtAuthenticationFilter, ApiKeyAuthenticationFilter y .anyRequest().authenticated() se saltean en
silencio. Todos los tests que no dependen del chain (login, refresh, password incorrecto) siguen pasando
igual, que es exactamente cómo esto estuvo faltando un tiempo en este repo.
Y la configuración de test que provee las dos interfaces (TestCredentialsConfig):
@TestConfiguration
public class TestCredentialsConfig {
public static final UUID KNOWN_USER_ID = UUID.fromString("11111111-1111-1111-1111-111111111111");
public static final UUID KNOWN_ORG_ID = UUID.fromString("22222222-2222-2222-2222-222222222222");
private static final Map<String, String> USERS = Map.of("alice", "correct-horse-battery-staple");
@Bean
public UserCredentialAuthenticator userCredentialAuthenticator() {
return (username, password) -> password.equals(USERS.get(username))
? Optional.of(new AuthenticatedUser(KNOWN_USER_ID, KNOWN_ORG_ID))
: Optional.empty();
}
@Bean
public PasswordResetHandler passwordResetHandler() {
return new PasswordResetHandler() {
@Override public void requestReset(String email) {}
@Override public void confirmReset(String resetToken, String newPassword) {}
};
}
}
Errores comunes
La aplicación no arranca: falta echotechs.auth.jwt.secret.
JwtProperties está anotada @Validated con @NotBlank en secret (spec 1.10) — el contexto falla al
arrancar nombrando exactamente esa propiedad, antes de que se instancie ningún bean que la use. No hay
default a propósito: un secreto hardcodeado sería un secreto compartido entre todos los deployments. Tiene
que ser base64 y decodificar a por lo menos 256 bits.
No arranca: falta un bean UserCredentialAuthenticator o PasswordResetHandler.
CoreAuthAutoConfiguration los pide para construir el AuthController. Son las dos interfaces que tu
proyecto tiene que implementar.
Los tests de otro módulo se rompen al agregar core-auth.
CoreAuthAutoConfiguration entra transitivamente y exige esos beans más las tablas de sesión. Los módulos
del core que dependen de core-auth pero no lo usan lo excluyen en sus tests:
@SpringBootApplication(exclude = CoreAuthAutoConfiguration.class)
public class TestApplication {}
Un refresh token válido devuelve 401 con REFRESH_TOKEN_REUSED.
No es un bug: presentar un token que ya fue rotado revoca toda la sesión, incluido el token vigente. Es
la detección de robo. Suele indicar que el cliente reintentó un /refresh que ya había tenido éxito, o que
dos pestañas refrescaron a la vez.
"Cerré sesión pero el token sigue funcionando."
Esperado por default. Logout mata los refresh tokens, pero un access token ya emitido sigue siendo válido
hasta expirar (12 min). Para cortarlo al instante hace falta implementar SessionVersionProvider y
escuchar UserSessionsRevokedEvent.
TenantContext.getCurrentOrg() lanza IllegalStateException.
O estás fuera de una request autenticada (por ejemplo en un @Scheduled, o en un hilo @Async), o el
principal es un ServiceAccountPrincipal, cuyo organizationId() es siempre null.
La API key se rechaza silenciosamente.
ApiKeyService.verify devuelve Optional.empty() sin distinguir causa si: la key no empieza con el prefijo
configurado, no existe, está revocada, o expiró. El filtro tampoco rechaza la request — deja el contexto
vacío y deja que las reglas de autorización decidan.
Falta CSRF.
Está deshabilitado a propósito: toda request que cambia estado acá es una llamada JSON, nunca un form post
HTML, y las cookies son SameSite=Lax. Si agregás un flujo con formularios clásicos, necesita su propia
protección CSRF — esta configuración no lo cubre.
El login va 200 pero el frontend rebota a /login en loop.
Síntoma: POST /api/auth/login responde 200 y setea las cookies, pero la UI no las ve y vuelve a login. Si
frontend y backend corren en subdominios siblings (p.ej. hotel.lab.echotechs.net y
api.hotel.lab.echotechs.net), las cookies son host-only por default y el navegador sólo las devuelve al
backend. Seteá echotechs.auth.cookies.domain al dominio padre compartido (sin punto inicial,
p.ej. lab.echotechs.net) — ver la sección "Frontend y backend en subdominios siblings" más arriba.
Notas de implementación
Diferencias entre platform-core-spec.md y lo que hace el código:
El refresh token se guarda sólo en base, no en Redis. El spec 1.1(a) dice "guardado server-side
(Postgres o Redis)". La implementación es exclusivamente JPA (RefreshTokenRepository); no hay soporte ni
configuración para Redis.
El claim ver no se enforza por default. El spec lo describe como un mecanismo activo: "cambio de
contraseña o 'cerrar sesión en todos los dispositivos' incrementa ver en el registro del usuario —
cualquier access token viejo con un ver desactualizado se rechaza". En el código, eso depende de que el
proyecto implemente SessionVersionProvider. Sin implementación, DefaultSessionVersionProvider devuelve
Optional.empty(), que JwtAuthenticationFilter interpreta como "no enforzar", y el token viejo sigue
siendo aceptado hasta expirar. core-auth no es dueño de la tabla de usuarios, así que no puede llevar ese
contador por su cuenta.
Logout revoca en vez de borrar. El spec dice "logout borra la fila de sesión". El código hace soft
delete: UserSession.revoke() setea revokedAt, la fila queda para auditoría.
Se excluye UserDetailsServiceAutoConfiguration automáticamente.
DefaultAutoConfigurationExclusionsEnvironmentPostProcessor la desactiva en todo proyecto que use
core-auth. No está en el spec; el motivo, según el código, es que si no Spring genera una "default security
password" aleatoria en cada arranque y loguea un warning sobre un mecanismo de login que en este modelo de
seguridad no existe. Si tu proyecto sí necesita UserDetailsService, tenés que volver a habilitarla
explícitamente.
El path de la cookie de refresh es /api/auth, no sólo el endpoint de refresh. El spec sugiere que la
cookie de refresh no se mande a rutas arbitrarias de la API. El default real (refresh-token-path) es
/api/auth, así que viaja a todos los endpoints de auth, no sólo a /api/auth/refresh.