Saltar al contenido principal
Versión: v0.1.0

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) y guardar usuarios. core-auth deliberadamente no tiene entidad User propia — el modelo de usuario de cada proyecto es distinto — así que expone dos interfaces que tu proyecto implementa contra su propia tabla.

Cómo se agrega

build.gradle.kts
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:

db/changelog/app/changelog-master.xml
<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

application.yml
echotechs:
auth:
jwt:
secret: ${JWT_SECRET} # base64, mínimo 256 bits decodificados. Sin default: la app no arranca sin esto.

Todas las propiedades disponibles:

PropiedadDefaultNotas
echotechs.auth.jwt.secretObligatoria. HMAC-SHA256 en base64
echotechs.auth.jwt.issuerechotechs-core-authSe verifica al parsear
echotechs.auth.jwt.access-token-ttl12m
echotechs.auth.jwt.user-audienceechotechs-userclaim aud de tokens de usuario
echotechs.auth.jwt.service-audienceechotechs-serviceclaim aud de service accounts
echotechs.auth.refresh-token.ttl30d
echotechs.auth.cookies.access-token-nameaccess_token
echotechs.auth.cookies.refresh-token-namerefresh_token
echotechs.auth.cookies.refresh-token-path/api/auth
echotechs.auth.cookies.securetruefalse sólo en desarrollo local por HTTP
echotechs.auth.cookies.same-siteLax
echotechs.auth.api-key.prefixsk_live_
echotechs.auth.api-key.header-nameX-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).

:::info Es un ThreadLocal TenantContextFilter 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 que tenés que 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);
}

Registrá ambas como beans. Sin ellas, CoreAuthAutoConfiguration no puede construir el AuthController y el contexto falla al arrancar.

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.

Endpoints

AuthController (/api/auth) y ServiceTokenController (/api/auth) se registran automáticamente:

MétodoRutaAuthQué hace
POST/api/auth/loginpúblicaValida credenciales, setea ambas cookies
POST/api/auth/refreshcookie refreshRota el par de tokens
POST/api/auth/logoutpúblicaRevoca la sesión del refresh token presentado
POST/api/auth/logout-allaccess tokenRevoca todas las sesiones del usuario
POST/api/auth/password-reset/requestpúblicaDelega en PasswordResetHandler → 202
POST/api/auth/password-reset/confirmpúblicaDelega en PasswordResetHandler → 200
POST/api/auth/service-tokenpúblicaClient credentials → JWT en el body

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.

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());
}

:::danger .apply(springSecurity()) no es opcional Sin 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. JwtTokenService valida con Assert.hasText(...) en el constructor. 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.

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.