From 6ad72c7c706f3d08479292e50abf8ddfa6d37a90 Mon Sep 17 00:00:00 2001 From: zichun Date: Fri, 10 Apr 2026 10:53:18 +0800 Subject: [PATCH] feat(security): P4.2 OIDC federation — accept Keycloak JWTs alongside built-in auth --- distribution/src/main/resources/application.yaml | 14 ++++++++++++++ platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/JwtConfiguration.kt | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------ platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/OidcProperties.kt | 34 ++++++++++++++++++++++++++++++++++ platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/PrincipalContextFilter.kt | 79 ++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------- platform/platform-security/src/test/kotlin/org/vibeerp/platform/security/JwtRoundTripTest.kt | 9 +++++---- 5 files changed, 163 insertions(+), 47 deletions(-) create mode 100644 platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/OidcProperties.kt diff --git a/distribution/src/main/resources/application.yaml b/distribution/src/main/resources/application.yaml index a946818..e41dfc4 100644 --- a/distribution/src/main/resources/application.yaml +++ b/distribution/src/main/resources/application.yaml @@ -105,6 +105,20 @@ vibeerp: issuer: vibe-erp access-token-ttl: PT15M refresh-token-ttl: P7D + # OIDC federation (P4.2). When issuer-uri is set, the framework + # accepts JWTs from both the built-in auth AND the external OIDC + # provider (Keycloak, Auth0, etc.). The JWKS endpoint is + # auto-discovered from the issuer's .well-known document. + # Leave blank to disable OIDC and use only built-in JWT auth. + oidc: + issuer-uri: ${VIBEERP_OIDC_ISSUER:} + # Claim that carries the username in OIDC tokens. + # Keycloak: preferred_username (default). Auth0: name or email. + username-claim: preferred_username + # Top-level claim name containing a roles array. Keycloak nests + # roles under realm_access: { roles: [...] }. Other providers + # may use a flat top-level roles claim — set to "roles" in that case. + roles-claim: realm_access plugins: directory: ${VIBEERP_PLUGINS_DIR:/opt/vibe-erp/plugins} auto-load: true diff --git a/platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/JwtConfiguration.kt b/platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/JwtConfiguration.kt index ed6df2e..b1a4631 100644 --- a/platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/JwtConfiguration.kt +++ b/platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/JwtConfiguration.kt @@ -1,46 +1,62 @@ package org.vibeerp.platform.security import com.nimbusds.jose.jwk.source.ImmutableSecret -import com.nimbusds.jose.proc.SecurityContext import com.nimbusds.jose.jwk.source.JWKSource +import com.nimbusds.jose.proc.SecurityContext +import org.slf4j.LoggerFactory import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.security.oauth2.jose.jws.MacAlgorithm import org.springframework.security.oauth2.jwt.JwtDecoder +import org.springframework.security.oauth2.jwt.JwtDecoders import org.springframework.security.oauth2.jwt.JwtEncoder +import org.springframework.security.oauth2.jwt.JwtException import org.springframework.security.oauth2.jwt.NimbusJwtDecoder import org.springframework.security.oauth2.jwt.NimbusJwtEncoder import javax.crypto.spec.SecretKeySpec /** - * Wires Spring Security's [JwtEncoder] and [JwtDecoder] beans against an - * HMAC-SHA256 secret read from [JwtProperties]. + * Wires Spring Security's [JwtEncoder] and [JwtDecoder] beans. + * + * **Built-in auth (always active).** The framework issues HS256 + * JWTs via [JwtIssuer] signed with `vibeerp.security.jwt.secret`. + * The encoder and the built-in decoder always exist. * - * Why HS256 and not RS256: - * - vibe_erp is single-tenant per instance and the same process both - * issues and verifies the token, so there is no need for asymmetric - * keys to support a separate verifier. - * - HS256 keeps the deployment story to "set one env var" instead of - * "manage and distribute a keypair", which matches the self-hosted- - * first goal in CLAUDE.md guardrail #5. - * - RS256 is straightforward to add later if a federated trust story - * ever appears (it would land in OIDC, P4.2). + * **OIDC federation (opt-in, P4.2).** When + * `vibeerp.security.oidc.issuer-uri` is set, the [JwtDecoder] + * becomes a composite that tries the built-in HS256 decoder first + * (cheap, no network call) and falls back to an OIDC decoder + * that validates against the provider's JWKS endpoint + * (auto-discovered from the issuer's `.well-known` document). + * + * The try-built-in-first strategy is correct because: + * - HS256 validation is a local HMAC check (~microseconds) + * - JWKS validation involves a cached-but-potentially-remote + * HTTP call to fetch the provider's public keys + * - In a mixed deployment, most traffic is SPA-to-API using + * built-in tokens; OIDC tokens are the minority case + * + * When the OIDC issuer is NOT configured, the decoder is the + * plain HS256 one — exactly the pre-P4.2 behavior. */ @Configuration class JwtConfiguration( - private val properties: JwtProperties, + private val jwtProperties: JwtProperties, + private val oidcProperties: OidcProperties, ) { + private val log = LoggerFactory.getLogger(JwtConfiguration::class.java) + init { - require(properties.secret.length >= MIN_SECRET_BYTES) { - "vibeerp.security.jwt.secret is too short (${properties.secret.length} bytes); " + + require(jwtProperties.secret.length >= MIN_SECRET_BYTES) { + "vibeerp.security.jwt.secret is too short (${jwtProperties.secret.length} bytes); " + "HS256 requires at least $MIN_SECRET_BYTES bytes. " + "Set the VIBEERP_JWT_SECRET environment variable in production." } } private val secretKey: SecretKeySpec by lazy { - SecretKeySpec(properties.secret.toByteArray(Charsets.UTF_8), "HmacSHA256") + SecretKeySpec(jwtProperties.secret.toByteArray(Charsets.UTF_8), "HmacSHA256") } @Bean @@ -50,11 +66,33 @@ class JwtConfiguration( } @Bean - fun jwtDecoder(): JwtDecoder = - NimbusJwtDecoder.withSecretKey(secretKey) + fun jwtDecoder(): JwtDecoder { + val builtInDecoder = NimbusJwtDecoder.withSecretKey(secretKey) .macAlgorithm(MacAlgorithm.HS256) .build() + val issuerUri = oidcProperties.issuerUri + if (issuerUri.isBlank()) { + log.info("OIDC federation disabled (vibeerp.security.oidc.issuer-uri not set); using built-in JWT auth only") + return builtInDecoder + } + + log.info("OIDC federation enabled: issuer-uri={}", issuerUri) + val oidcDecoder = JwtDecoders.fromIssuerLocation(issuerUri) as JwtDecoder + + // Composite decoder: try built-in first (fast), fall back + // to OIDC (potentially remote JWKS fetch). If both reject + // the token, the OIDC decoder's exception propagates and + // Spring Security returns 401. + return JwtDecoder { token -> + try { + builtInDecoder.decode(token) + } catch (_: JwtException) { + oidcDecoder.decode(token) + } + } + } + private companion object { const val MIN_SECRET_BYTES = 32 } diff --git a/platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/OidcProperties.kt b/platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/OidcProperties.kt new file mode 100644 index 0000000..7de550a --- /dev/null +++ b/platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/OidcProperties.kt @@ -0,0 +1,34 @@ +package org.vibeerp.platform.security + +import org.springframework.boot.context.properties.ConfigurationProperties + +/** + * Optional OIDC federation config bound from `vibeerp.security.oidc.*`. + * + * When [issuerUri] is set, the framework accepts JWTs from both the + * built-in issuer (HS256, `/api/v1/auth/login`) AND the configured + * external OIDC provider (RS256, JWKS auto-discovered from + * `/.well-known/openid-configuration`). + * + * When [issuerUri] is blank or absent, only the built-in auth works. + * This is the default for self-hosted deployments that don't run + * Keycloak. + * + * **Claim mapping.** Keycloak (and most OIDC providers) use different + * claim names than the built-in JwtIssuer: + * - `preferred_username` instead of `username` + * - `realm_access.roles` (nested JSON) instead of `roles` (flat array) + * + * [PrincipalContextFilter] handles both shapes transparently; the + * [rolesClaim] property lets operators override the path for + * non-Keycloak providers. + */ +@ConfigurationProperties(prefix = "vibeerp.security.oidc") +data class OidcProperties( + /** OIDC issuer URI (e.g. `https://keycloak.example/realms/vibeerp`). Blank = disabled. */ + val issuerUri: String = "", + /** Claim path for the username. Default covers Keycloak's `preferred_username`. */ + val usernameClaim: String = "preferred_username", + /** Top-level claim name for roles array. For Keycloak, roles live under `realm_access.roles`. */ + val rolesClaim: String = "realm_access", +) diff --git a/platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/PrincipalContextFilter.kt b/platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/PrincipalContextFilter.kt index 47b3135..b8e6ea7 100644 --- a/platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/PrincipalContextFilter.kt +++ b/platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/PrincipalContextFilter.kt @@ -20,32 +20,32 @@ import org.vibeerp.platform.security.authz.AuthorizedPrincipal * * 1. **`PrincipalContext`** (in `platform-persistence`) — carries * just the principal id as a plain string, for the JPA audit - * listener to write into `created_by` / `updated_by`. The audit - * listener must not depend on security types, so the id is - * stringly-typed at the persistence boundary. + * listener to write into `created_by` / `updated_by`. * 2. **`AuthorizationContext`** (in this module, P4.3) — carries the * full `AuthorizedPrincipal` (id, username, role set) for the - * `@RequirePermission` aspect and the `PermissionEvaluator` to - * consult. Roles are read from the JWT's `roles` claim populated - * by `JwtIssuer.issueAccessToken`. + * `@RequirePermission` aspect. * - * Spring Security puts the authenticated `Authentication` object on a - * thread-local of its own. The bridge solves the layering problem by - * being the one and only place that knows about Spring Security's - * type AND about both vibe_erp contexts; everything downstream uses - * just the typed-but-Spring-free abstractions. + * **Dual claim mapping (P4.2 OIDC).** The filter handles JWTs from + * two sources with different claim structures: * - * The filter is registered immediately after Spring Security's JWT - * authentication filter (`BearerTokenAuthenticationFilter`) so the - * `SecurityContext` is fully populated by the time we read it. On - * unauthenticated requests (the public allowlist) the `SecurityContext` - * is empty and we leave both contexts unset — which means the audit - * listener falls back to its `__system__` sentinel and the - * `@RequirePermission` aspect denies any protected call (correct for - * unauth callers). + * - **Built-in** (JwtIssuer, HS256): `sub` = user UUID, + * `username` = display username, `roles` = flat string array. + * - **OIDC/Keycloak** (RS256): `sub` = OIDC subject, claim + * name for username is configurable via `OidcProperties.usernameClaim` + * (default: `preferred_username`), roles live under a nested + * JSON path configurable via `OidcProperties.rolesClaim` + * (default: `realm_access` containing a `roles` array). + * + * The filter tries the built-in claim names first, then falls back + * to the OIDC claim names. This is safe because: + * - Built-in tokens always have `username` set; OIDC tokens never do + * - The fallback order means a token that satisfies both (unlikely) + * prefers the built-in interpretation, which is correct */ @Component -class PrincipalContextFilter : OncePerRequestFilter() { +class PrincipalContextFilter( + private val oidcProperties: OidcProperties, +) : OncePerRequestFilter() { override fun doFilterInternal( request: HttpServletRequest, @@ -64,11 +64,8 @@ class PrincipalContextFilter : OncePerRequestFilter() { } val subject = jwt.subject - val username = jwt.getClaimAsString("username") ?: subject - // Roles claim is optional — pre-P4.3 tokens don't have it - // (treat as empty), refresh-token-derived sessions wouldn't - // make it here anyway, and system tokens never carry it. - val roles: Set = jwt.getClaimAsStringList("roles")?.toSet().orEmpty() + val username = resolveUsername(jwt) + val roles = resolveRoles(jwt) val authorized = AuthorizedPrincipal( id = subject, @@ -82,4 +79,36 @@ class PrincipalContextFilter : OncePerRequestFilter() { } } } + + /** + * Extract username: try built-in `username` claim first, then + * the OIDC claim (default: `preferred_username`), then fall + * back to `sub`. + */ + private fun resolveUsername(jwt: Jwt): String { + jwt.getClaimAsString("username")?.let { return it } + jwt.getClaimAsString(oidcProperties.usernameClaim)?.let { return it } + return jwt.subject + } + + /** + * Extract roles: try built-in flat `roles` array first, then + * the OIDC nested structure (default: `realm_access.roles` + * for Keycloak). + */ + private fun resolveRoles(jwt: Jwt): Set { + // Built-in: flat array at top level + jwt.getClaimAsStringList("roles")?.let { return it.toSet() } + + // OIDC/Keycloak: nested JSON object, e.g. realm_access: { roles: [...] } + val container = jwt.getClaim(oidcProperties.rolesClaim) + if (container is Map<*, *>) { + val nested = container["roles"] + if (nested is List<*>) { + return nested.filterIsInstance().toSet() + } + } + + return emptySet() + } } diff --git a/platform/platform-security/src/test/kotlin/org/vibeerp/platform/security/JwtRoundTripTest.kt b/platform/platform-security/src/test/kotlin/org/vibeerp/platform/security/JwtRoundTripTest.kt index fc189b9..a5a561e 100644 --- a/platform/platform-security/src/test/kotlin/org/vibeerp/platform/security/JwtRoundTripTest.kt +++ b/platform/platform-security/src/test/kotlin/org/vibeerp/platform/security/JwtRoundTripTest.kt @@ -20,17 +20,18 @@ import java.util.UUID */ class JwtRoundTripTest { - private val properties = JwtProperties( + private val jwtProperties = JwtProperties( secret = "test-secret-with-at-least-32-chars-of-entropy", issuer = "vibe-erp-test", ) + private val oidcProperties = OidcProperties() // defaults — OIDC disabled private lateinit var issuer: JwtIssuer private lateinit var verifier: JwtVerifier @BeforeEach fun setUp() { - val cfg = JwtConfiguration(properties) - issuer = JwtIssuer(cfg.jwtEncoder(), properties) + val cfg = JwtConfiguration(jwtProperties, oidcProperties) + issuer = JwtIssuer(cfg.jwtEncoder(), jwtProperties) verifier = JwtVerifier(cfg.jwtDecoder()) } @@ -120,7 +121,7 @@ class JwtRoundTripTest { @Test fun `JwtConfiguration refuses too-short secret`() { - assertFailure { JwtConfiguration(JwtProperties(secret = "too-short")) } + assertFailure { JwtConfiguration(JwtProperties(secret = "too-short"), OidcProperties()) } .isInstanceOf(IllegalArgumentException::class) } } -- libgit2 0.22.2