feat(auth): refactor to Keycloak OIDC issuer-uri (drop static PEM)

@nstart/auth = Keycloak. Уходим от RSA PEM в Vault на OIDC discovery.

OrdinisAuthProperties:
- DROP: publicKey (PEM)
- ADD: issuerUri (https://auth.nstart.space/realms/nstart)
- ADD: jwkSetUri (optional override)
- ADD: audience (для aud валидации, когда узнаем client_id)
- RENAME: scopeClaim → rolesClaim (default: realm_access.roles)

SecurityConfig:
- JwtDecoders.fromIssuerLocation() — auto JWK Set discovery через
  /.well-known/openid-configuration. Ротация ключей не требует ручных операций.
- AudienceValidator (опц.) для проверки aud claim.
- Permissive stub когда issuer-uri пуст (dev mode).

ScopeContext:
- readClaimByPath() поддерживает nested путь "realm_access.roles".
- normalizeRoleToScope(): маппит Keycloak роли ("ordinis-public",
  "ORDINIS_INTERNAL", "x-RESTRICTED") в DataScope.
- JWT всегда побеждает query ?as_scope= (защита от scope escalation).
This commit is contained in:
Zimin A.N.
2026-05-04 02:13:08 +03:00
parent 505dbda0d8
commit 7593699990
3 changed files with 127 additions and 67 deletions
@@ -3,28 +3,44 @@ package cloud.nstart.terravault.ordinis.auth;
import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.ConfigurationProperties;
/** /**
* Конфиг для @nstart/auth-style JWT auth. * Конфиг JWT auth для интеграции с Keycloak (@nstart/auth).
* *
* <p>{@code publicKey} — RSA public key (PEM, без BEGIN/END заголовков, base64). * <p>{@code issuerUri} — OIDC issuer URL, например
* Подбрасывается через Vault: secret/ordinis/cuod/jwt-public-key.</p> * {@code https://auth.nstart.space/realms/nstart}. Spring Security сам тянет
* JWK Set через {@code .well-known/openid-configuration}, поддерживает rotation
* ключей. Если не задан — JWT validation выключен (permissive mode для dev).
*
* <p>{@code jwkSetUri} — опциональный override (если discovery недоступен).
* Обычно не нужен.
*
* <p>{@code audience} — опциональная валидация {@code aud} claim. Когда узнаем
* client_id, выставляем сюда (например {@code ordinis-cuod}).
*
* <p>{@code rolesClaim} — путь к ролям внутри JWT. Для Keycloak realm-роли
* лежат в {@code realm_access.roles} (массив). Для resource-ролей —
* {@code resource_access.{client_id}.roles}. Default: realm_access.roles.
* *
* <p>{@code requireAuthentication=true} → 401 на любой запрос без валидного JWT. * <p>{@code requireAuthentication=true} → 401 на любой запрос без валидного JWT.
* При false работает permissive режим — анонимный запрос имеет только PUBLIC scope, * При false работает permissive режим — анонимный запрос имеет только PUBLIC scope.
* что подходит для read-api на raw публичных endpoint'ах.</p>
* *
* <p>{@code allowQueryScope=true} включает legacy {@code ?as_scope=...} query param * <p>{@code allowQueryScope=true} включает legacy {@code ?as_scope=...}
* (для smoke-тестов и dev). В prod рекомендуется false.</p> * (для smoke-тестов и dev). В prod false.
*/ */
@ConfigurationProperties(prefix = "ordinis.auth") @ConfigurationProperties(prefix = "ordinis.auth")
public record OrdinisAuthProperties( public record OrdinisAuthProperties(
String publicKey, String issuerUri,
String issuer, String jwkSetUri,
String scopeClaim, String audience,
String rolesClaim,
boolean requireAuthentication, boolean requireAuthentication,
boolean allowQueryScope) { boolean allowQueryScope) {
public OrdinisAuthProperties { public OrdinisAuthProperties {
if (scopeClaim == null) scopeClaim = "scopes"; if (rolesClaim == null || rolesClaim.isBlank()) rolesClaim = "realm_access.roles";
if (issuer == null) issuer = ""; }
public boolean enabled() {
return (issuerUri != null && !issuerUri.isBlank())
|| (jwkSetUri != null && !jwkSetUri.isBlank());
} }
} }
@@ -9,19 +9,28 @@ import org.springframework.stereotype.Component;
import java.util.Collection; import java.util.Collection;
import java.util.EnumSet; import java.util.EnumSet;
import java.util.List; import java.util.Map;
import java.util.Set; import java.util.Set;
/** /**
* Резолвер активных scope для текущего запроса. * Резолвер scope для текущего запроса.
* *
* <p>Источник scope (приоритет сверху-вниз): * <p>Источник scope (приоритет сверху-вниз):
* <ol> * <ol>
* <li>JWT claim {@code scopes} (см. {@link OrdinisAuthProperties#scopeClaim()}).</li> * <li>JWT roles claim (для Keycloak — {@code realm_access.roles}, путь
* <li>Legacy query param {@code as_scope} (если {@code allowQueryScope=true}). Контроллеры * настраивается через {@link OrdinisAuthProperties#rolesClaim()}).
* сами читают и передают через {@link #fromQueryParam(String)}.</li> * Роли мапятся в {@link DataScope} по имени:
* <li>Anonymous → только {@link DataScope#PUBLIC} (если auth не требуется).</li> * <ul>
* <li>{@code ordinis-public} / {@code public} → {@link DataScope#PUBLIC}</li>
* <li>{@code ordinis-internal} / {@code internal} → {@link DataScope#INTERNAL}</li>
* <li>{@code ordinis-restricted} / {@code restricted} → {@link DataScope#RESTRICTED}</li>
* </ul>
* Любая роль с суффиксом {@code -PUBLIC|INTERNAL|RESTRICTED} тоже работает.</li>
* <li>Legacy query param {@code as_scope} (если {@code allowQueryScope=true}).</li>
* <li>Anonymous → {@link DataScope#PUBLIC}.</li>
* </ol> * </ol>
*
* <p>JWT всегда побеждает query param — защита от scope escalation через URL.
*/ */
@Component @Component
public class ScopeContext { public class ScopeContext {
@@ -32,7 +41,6 @@ public class ScopeContext {
this.props = props; this.props = props;
} }
/** Достаёт scope текущей аутентифицированной сессии (из JWT) или PUBLIC если анонимна. */
public Set<DataScope> currentScopes() { public Set<DataScope> currentScopes() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication(); Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth instanceof JwtAuthenticationToken jwtAuth) { if (auth instanceof JwtAuthenticationToken jwtAuth) {
@@ -41,15 +49,6 @@ public class ScopeContext {
return EnumSet.of(DataScope.PUBLIC); return EnumSet.of(DataScope.PUBLIC);
} }
/** Объединяет JWT-scope и query-as_scope с правильными правилами:
*
* <ul>
* <li>Если JWT присутствует — query param ИГНОРИРУЕТСЯ (защита от scope escalation
* через URL).</li>
* <li>Если JWT отсутствует и {@code allowQueryScope=true} — берём из query.</li>
* <li>Иначе — PUBLIC.</li>
* </ul>
*/
public Set<DataScope> resolveForRead(String queryAsScope) { public Set<DataScope> resolveForRead(String queryAsScope) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication(); Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth instanceof JwtAuthenticationToken jwtAuth) { if (auth instanceof JwtAuthenticationToken jwtAuth) {
@@ -65,28 +64,54 @@ public class ScopeContext {
if (csv == null || csv.isBlank()) return EnumSet.of(DataScope.PUBLIC); if (csv == null || csv.isBlank()) return EnumSet.of(DataScope.PUBLIC);
EnumSet<DataScope> result = EnumSet.noneOf(DataScope.class); EnumSet<DataScope> result = EnumSet.noneOf(DataScope.class);
for (String part : csv.split(",")) { for (String part : csv.split(",")) {
String s = part.trim(); addScope(result, part);
if (s.isEmpty()) continue;
try {
result.add(DataScope.valueOf(s.toUpperCase()));
} catch (IllegalArgumentException ignored) {
}
} }
return result.isEmpty() ? EnumSet.of(DataScope.PUBLIC) : result; return result.isEmpty() ? EnumSet.of(DataScope.PUBLIC) : result;
} }
private Set<DataScope> scopesFromJwt(Jwt token) { private Set<DataScope> scopesFromJwt(Jwt token) {
Object raw = token.getClaim(props.scopeClaim()); Object raw = readClaimByPath(token, props.rolesClaim());
if (raw == null) return EnumSet.of(DataScope.PUBLIC); if (raw == null) return EnumSet.of(DataScope.PUBLIC);
EnumSet<DataScope> result = EnumSet.noneOf(DataScope.class); EnumSet<DataScope> result = EnumSet.noneOf(DataScope.class);
if (raw instanceof Collection<?> col) { if (raw instanceof Collection<?> col) {
for (Object o : col) addScope(result, String.valueOf(o)); for (Object o : col) addScope(result, normalizeRoleToScope(String.valueOf(o)));
} else { } else {
for (String part : String.valueOf(raw).split("[,\\s]+")) addScope(result, part); for (String part : String.valueOf(raw).split("[,\\s]+")) {
addScope(result, normalizeRoleToScope(part));
}
} }
return result.isEmpty() ? EnumSet.of(DataScope.PUBLIC) : result; return result.isEmpty() ? EnumSet.of(DataScope.PUBLIC) : result;
} }
/** Поддерживает nested путь "realm_access.roles" или просто "scopes". */
private static Object readClaimByPath(Jwt token, String path) {
String[] parts = path.split("\\.");
Object current = token.getClaim(parts[0]);
for (int i = 1; i < parts.length && current != null; i++) {
if (current instanceof Map<?, ?> m) {
current = m.get(parts[i]);
} else {
return null;
}
}
return current;
}
/**
* Маппинг имени роли в scope. Поддерживает:
* "ordinis-public" / "public" → PUBLIC
* "ORDINIS_INTERNAL" → INTERNAL
* суффикс после "-" или "_": "x-RESTRICTED" → RESTRICTED
* просто "PUBLIC"|"INTERNAL"|"RESTRICTED"
*/
private static String normalizeRoleToScope(String role) {
if (role == null) return null;
String s = role.trim().toUpperCase().replace("ORDINIS-", "").replace("ORDINIS_", "");
int dash = Math.max(s.lastIndexOf('-'), s.lastIndexOf('_'));
if (dash >= 0) s = s.substring(dash + 1);
return s;
}
private static void addScope(EnumSet<DataScope> target, String s) { private static void addScope(EnumSet<DataScope> target, String s) {
if (s == null || s.isBlank()) return; if (s == null || s.isBlank()) return;
try { try {
@@ -7,19 +7,25 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer; import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtDecoders;
import org.springframework.security.oauth2.jwt.JwtValidators;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import java.security.KeyFactory;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
/** /**
* Spring Security конфиг: actuator/health и пути pre-flight открыты, остальные * Spring Security: actuator открыт, остальное — либо JWT-required, либо
* либо требуют JWT (если {@code requireAuthentication=true}), либо доступны * permissive (anonymous → PUBLIC scope в {@link ScopeContext}).
* анонимно (с автоматическим назначением PUBLIC scope в {@link ScopeContext}). *
* <p>JWT verification через OIDC issuer-uri (Keycloak): JWK Set автоматически
* discover'ится с {@code {issuer}/.well-known/openid-configuration}, ротация
* ключей бесплатна.
*/ */
@Configuration @Configuration
@EnableConfigurationProperties(OrdinisAuthProperties.class) @EnableConfigurationProperties(OrdinisAuthProperties.class)
@@ -32,50 +38,63 @@ public class SecurityConfig {
OrdinisAuthProperties props) throws Exception { OrdinisAuthProperties props) throws Exception {
http http
.csrf(csrf -> csrf.disable()) .csrf(csrf -> csrf.disable())
.sessionManagement(sm -> sm.sessionCreationPolicy( .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
org.springframework.security.config.http.SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> { .authorizeHttpRequests(auth -> {
auth.requestMatchers("/actuator/**").permitAll(); auth.requestMatchers("/actuator/**").permitAll();
auth.requestMatchers("/error").permitAll(); auth.requestMatchers("/error").permitAll();
if (props.requireAuthentication()) { if (props.requireAuthentication()) {
auth.anyRequest().authenticated(); auth.anyRequest().authenticated();
} else { } else {
// Permissive: запрос без JWT → анонимный, но scope-фильтр оставит только PUBLIC.
auth.anyRequest().permitAll(); auth.anyRequest().permitAll();
} }
}); });
// Resource server только если есть public key (иначе nimbus сломается). if (props.enabled()) {
if (props.publicKey() != null && !props.publicKey().isBlank()) {
http.oauth2ResourceServer(rs -> rs.jwt(Customizer.withDefaults())); http.oauth2ResourceServer(rs -> rs.jwt(Customizer.withDefaults()));
} else { } else {
log.warn("ordinis.auth.public-key не задан — JWT validation отключён, scope только из query/anonymous"); log.warn("ordinis.auth.issuer-uri не задан — JWT validation отключён, scope только из query/anonymous");
} }
return http.build(); return http.build();
} }
@Bean @Bean
public JwtDecoder jwtDecoder(OrdinisAuthProperties props) throws Exception { public JwtDecoder jwtDecoder(OrdinisAuthProperties props) {
if (props.publicKey() == null || props.publicKey().isBlank()) { if (!props.enabled()) {
// Nimbus требует ключ; возвращаем decoder который всегда падает — он не
// будет вызван, потому что oauth2ResourceServer не подключён.
return token -> { return token -> {
throw new org.springframework.security.oauth2.jwt.BadJwtException( throw new org.springframework.security.oauth2.jwt.BadJwtException(
"JWT validation disabled: ordinis.auth.public-key не сконфигурирован"); "JWT validation disabled: ordinis.auth.issuer-uri не сконфигурирован");
}; };
} }
String pem = props.publicKey()
.replaceAll("-----BEGIN PUBLIC KEY-----", "") NimbusJwtDecoder decoder;
.replaceAll("-----END PUBLIC KEY-----", "") if (props.jwkSetUri() != null && !props.jwkSetUri().isBlank()) {
.replaceAll("\\s+", ""); decoder = NimbusJwtDecoder.withJwkSetUri(props.jwkSetUri()).build();
byte[] der = Base64.getDecoder().decode(pem); } else {
RSAPublicKey publicKey = (RSAPublicKey) KeyFactory.getInstance("RSA") // OIDC discovery: загружает /.well-known/openid-configuration → jwks_uri.
.generatePublic(new X509EncodedKeySpec(der)); decoder = (NimbusJwtDecoder) JwtDecoders.fromIssuerLocation(props.issuerUri());
NimbusJwtDecoder decoder = NimbusJwtDecoder.withPublicKey(publicKey).build();
if (props.issuer() != null && !props.issuer().isBlank()) {
decoder.setJwtValidator(
org.springframework.security.oauth2.jwt.JwtValidators.createDefaultWithIssuer(props.issuer()));
} }
OAuth2TokenValidator<Jwt> validator = props.issuerUri() != null && !props.issuerUri().isBlank()
? JwtValidators.createDefaultWithIssuer(props.issuerUri())
: JwtValidators.createDefault();
if (props.audience() != null && !props.audience().isBlank()) {
validator = new DelegatingOAuth2TokenValidator<>(validator, new AudienceValidator(props.audience()));
}
decoder.setJwtValidator(validator);
return decoder; return decoder;
} }
/** Валидирует {@code aud} claim. Keycloak кладёт client_id туда. */
private record AudienceValidator(String expected) implements OAuth2TokenValidator<Jwt> {
@Override
public OAuth2TokenValidatorResult validate(Jwt token) {
if (token.getAudience() != null && token.getAudience().contains(expected)) {
return OAuth2TokenValidatorResult.success();
}
return OAuth2TokenValidatorResult.failure(
new OAuth2Error("invalid_audience",
"JWT aud claim не содержит ожидаемого значения " + expected, null));
}
}
} }