Commit 6ad72c7c706f3d08479292e50abf8ddfa6d37a90
1 parent
c2fab13b
feat(security): P4.2 OIDC federation — accept Keycloak JWTs alongside built-in auth
The framework now supports federated authentication: operators can
configure an external OIDC provider (Keycloak, Auth0, or any
OIDC-compliant issuer) and the API accepts JWTs from both the
built-in auth (/api/v1/auth/login, HS256) and the OIDC provider
(RS256, JWKS auto-discovered).
Opt-in via vibeerp.security.oidc.issuer-uri. When blank (default),
only built-in auth works — exactly the pre-P4.2 behavior. When
set, the JwtDecoder becomes a composite: tries the built-in HS256
decoder first (cheap, local HMAC), falls back to the OIDC decoder
(RS256, cached JWKS fetch from the provider's .well-known endpoint).
Claim mapping: PrincipalContextFilter now handles both formats:
- Built-in: sub=UUID, username=<claim>, roles=<flat array>
- OIDC/Keycloak: sub=OIDC subject, preferred_username=<claim>,
realm_access.roles=<nested array>
Claim names are configurable via vibeerp.security.oidc.username-claim
and roles-claim for non-Keycloak providers.
New files:
- OidcProperties.kt: config properties class for the OIDC block
Modified files:
- JwtConfiguration.kt: composite decoder, now takes OidcProperties
- PrincipalContextFilter.kt: dual claim resolution (built-in first,
OIDC fallback), now takes OidcProperties
- JwtRoundTripTest.kt: updated to pass OidcProperties (defaults)
- application.yaml: OIDC config block with env-var interpolation
No new dependencies — uses Spring Security's existing
JwtDecoders.fromIssuerLocation() which is already on the classpath
via spring-boot-starter-oauth2-resource-server.
Showing
5 changed files
with
163 additions
and
47 deletions
distribution/src/main/resources/application.yaml
| ... | ... | @@ -105,6 +105,20 @@ vibeerp: |
| 105 | 105 | issuer: vibe-erp |
| 106 | 106 | access-token-ttl: PT15M |
| 107 | 107 | refresh-token-ttl: P7D |
| 108 | + # OIDC federation (P4.2). When issuer-uri is set, the framework | |
| 109 | + # accepts JWTs from both the built-in auth AND the external OIDC | |
| 110 | + # provider (Keycloak, Auth0, etc.). The JWKS endpoint is | |
| 111 | + # auto-discovered from the issuer's .well-known document. | |
| 112 | + # Leave blank to disable OIDC and use only built-in JWT auth. | |
| 113 | + oidc: | |
| 114 | + issuer-uri: ${VIBEERP_OIDC_ISSUER:} | |
| 115 | + # Claim that carries the username in OIDC tokens. | |
| 116 | + # Keycloak: preferred_username (default). Auth0: name or email. | |
| 117 | + username-claim: preferred_username | |
| 118 | + # Top-level claim name containing a roles array. Keycloak nests | |
| 119 | + # roles under realm_access: { roles: [...] }. Other providers | |
| 120 | + # may use a flat top-level roles claim — set to "roles" in that case. | |
| 121 | + roles-claim: realm_access | |
| 108 | 122 | plugins: |
| 109 | 123 | directory: ${VIBEERP_PLUGINS_DIR:/opt/vibe-erp/plugins} |
| 110 | 124 | auto-load: true | ... | ... |
platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/JwtConfiguration.kt
| 1 | 1 | package org.vibeerp.platform.security |
| 2 | 2 | |
| 3 | 3 | import com.nimbusds.jose.jwk.source.ImmutableSecret |
| 4 | -import com.nimbusds.jose.proc.SecurityContext | |
| 5 | 4 | import com.nimbusds.jose.jwk.source.JWKSource |
| 5 | +import com.nimbusds.jose.proc.SecurityContext | |
| 6 | +import org.slf4j.LoggerFactory | |
| 6 | 7 | import org.springframework.context.annotation.Bean |
| 7 | 8 | import org.springframework.context.annotation.Configuration |
| 8 | 9 | import org.springframework.security.oauth2.jose.jws.MacAlgorithm |
| 9 | 10 | import org.springframework.security.oauth2.jwt.JwtDecoder |
| 11 | +import org.springframework.security.oauth2.jwt.JwtDecoders | |
| 10 | 12 | import org.springframework.security.oauth2.jwt.JwtEncoder |
| 13 | +import org.springframework.security.oauth2.jwt.JwtException | |
| 11 | 14 | import org.springframework.security.oauth2.jwt.NimbusJwtDecoder |
| 12 | 15 | import org.springframework.security.oauth2.jwt.NimbusJwtEncoder |
| 13 | 16 | import javax.crypto.spec.SecretKeySpec |
| 14 | 17 | |
| 15 | 18 | /** |
| 16 | - * Wires Spring Security's [JwtEncoder] and [JwtDecoder] beans against an | |
| 17 | - * HMAC-SHA256 secret read from [JwtProperties]. | |
| 19 | + * Wires Spring Security's [JwtEncoder] and [JwtDecoder] beans. | |
| 20 | + * | |
| 21 | + * **Built-in auth (always active).** The framework issues HS256 | |
| 22 | + * JWTs via [JwtIssuer] signed with `vibeerp.security.jwt.secret`. | |
| 23 | + * The encoder and the built-in decoder always exist. | |
| 18 | 24 | * |
| 19 | - * Why HS256 and not RS256: | |
| 20 | - * - vibe_erp is single-tenant per instance and the same process both | |
| 21 | - * issues and verifies the token, so there is no need for asymmetric | |
| 22 | - * keys to support a separate verifier. | |
| 23 | - * - HS256 keeps the deployment story to "set one env var" instead of | |
| 24 | - * "manage and distribute a keypair", which matches the self-hosted- | |
| 25 | - * first goal in CLAUDE.md guardrail #5. | |
| 26 | - * - RS256 is straightforward to add later if a federated trust story | |
| 27 | - * ever appears (it would land in OIDC, P4.2). | |
| 25 | + * **OIDC federation (opt-in, P4.2).** When | |
| 26 | + * `vibeerp.security.oidc.issuer-uri` is set, the [JwtDecoder] | |
| 27 | + * becomes a composite that tries the built-in HS256 decoder first | |
| 28 | + * (cheap, no network call) and falls back to an OIDC decoder | |
| 29 | + * that validates against the provider's JWKS endpoint | |
| 30 | + * (auto-discovered from the issuer's `.well-known` document). | |
| 31 | + * | |
| 32 | + * The try-built-in-first strategy is correct because: | |
| 33 | + * - HS256 validation is a local HMAC check (~microseconds) | |
| 34 | + * - JWKS validation involves a cached-but-potentially-remote | |
| 35 | + * HTTP call to fetch the provider's public keys | |
| 36 | + * - In a mixed deployment, most traffic is SPA-to-API using | |
| 37 | + * built-in tokens; OIDC tokens are the minority case | |
| 38 | + * | |
| 39 | + * When the OIDC issuer is NOT configured, the decoder is the | |
| 40 | + * plain HS256 one — exactly the pre-P4.2 behavior. | |
| 28 | 41 | */ |
| 29 | 42 | @Configuration |
| 30 | 43 | class JwtConfiguration( |
| 31 | - private val properties: JwtProperties, | |
| 44 | + private val jwtProperties: JwtProperties, | |
| 45 | + private val oidcProperties: OidcProperties, | |
| 32 | 46 | ) { |
| 33 | 47 | |
| 48 | + private val log = LoggerFactory.getLogger(JwtConfiguration::class.java) | |
| 49 | + | |
| 34 | 50 | init { |
| 35 | - require(properties.secret.length >= MIN_SECRET_BYTES) { | |
| 36 | - "vibeerp.security.jwt.secret is too short (${properties.secret.length} bytes); " + | |
| 51 | + require(jwtProperties.secret.length >= MIN_SECRET_BYTES) { | |
| 52 | + "vibeerp.security.jwt.secret is too short (${jwtProperties.secret.length} bytes); " + | |
| 37 | 53 | "HS256 requires at least $MIN_SECRET_BYTES bytes. " + |
| 38 | 54 | "Set the VIBEERP_JWT_SECRET environment variable in production." |
| 39 | 55 | } |
| 40 | 56 | } |
| 41 | 57 | |
| 42 | 58 | private val secretKey: SecretKeySpec by lazy { |
| 43 | - SecretKeySpec(properties.secret.toByteArray(Charsets.UTF_8), "HmacSHA256") | |
| 59 | + SecretKeySpec(jwtProperties.secret.toByteArray(Charsets.UTF_8), "HmacSHA256") | |
| 44 | 60 | } |
| 45 | 61 | |
| 46 | 62 | @Bean |
| ... | ... | @@ -50,11 +66,33 @@ class JwtConfiguration( |
| 50 | 66 | } |
| 51 | 67 | |
| 52 | 68 | @Bean |
| 53 | - fun jwtDecoder(): JwtDecoder = | |
| 54 | - NimbusJwtDecoder.withSecretKey(secretKey) | |
| 69 | + fun jwtDecoder(): JwtDecoder { | |
| 70 | + val builtInDecoder = NimbusJwtDecoder.withSecretKey(secretKey) | |
| 55 | 71 | .macAlgorithm(MacAlgorithm.HS256) |
| 56 | 72 | .build() |
| 57 | 73 | |
| 74 | + val issuerUri = oidcProperties.issuerUri | |
| 75 | + if (issuerUri.isBlank()) { | |
| 76 | + log.info("OIDC federation disabled (vibeerp.security.oidc.issuer-uri not set); using built-in JWT auth only") | |
| 77 | + return builtInDecoder | |
| 78 | + } | |
| 79 | + | |
| 80 | + log.info("OIDC federation enabled: issuer-uri={}", issuerUri) | |
| 81 | + val oidcDecoder = JwtDecoders.fromIssuerLocation(issuerUri) as JwtDecoder | |
| 82 | + | |
| 83 | + // Composite decoder: try built-in first (fast), fall back | |
| 84 | + // to OIDC (potentially remote JWKS fetch). If both reject | |
| 85 | + // the token, the OIDC decoder's exception propagates and | |
| 86 | + // Spring Security returns 401. | |
| 87 | + return JwtDecoder { token -> | |
| 88 | + try { | |
| 89 | + builtInDecoder.decode(token) | |
| 90 | + } catch (_: JwtException) { | |
| 91 | + oidcDecoder.decode(token) | |
| 92 | + } | |
| 93 | + } | |
| 94 | + } | |
| 95 | + | |
| 58 | 96 | private companion object { |
| 59 | 97 | const val MIN_SECRET_BYTES = 32 |
| 60 | 98 | } | ... | ... |
platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/OidcProperties.kt
0 → 100644
| 1 | +package org.vibeerp.platform.security | |
| 2 | + | |
| 3 | +import org.springframework.boot.context.properties.ConfigurationProperties | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * Optional OIDC federation config bound from `vibeerp.security.oidc.*`. | |
| 7 | + * | |
| 8 | + * When [issuerUri] is set, the framework accepts JWTs from both the | |
| 9 | + * built-in issuer (HS256, `/api/v1/auth/login`) AND the configured | |
| 10 | + * external OIDC provider (RS256, JWKS auto-discovered from | |
| 11 | + * `<issuerUri>/.well-known/openid-configuration`). | |
| 12 | + * | |
| 13 | + * When [issuerUri] is blank or absent, only the built-in auth works. | |
| 14 | + * This is the default for self-hosted deployments that don't run | |
| 15 | + * Keycloak. | |
| 16 | + * | |
| 17 | + * **Claim mapping.** Keycloak (and most OIDC providers) use different | |
| 18 | + * claim names than the built-in JwtIssuer: | |
| 19 | + * - `preferred_username` instead of `username` | |
| 20 | + * - `realm_access.roles` (nested JSON) instead of `roles` (flat array) | |
| 21 | + * | |
| 22 | + * [PrincipalContextFilter] handles both shapes transparently; the | |
| 23 | + * [rolesClaim] property lets operators override the path for | |
| 24 | + * non-Keycloak providers. | |
| 25 | + */ | |
| 26 | +@ConfigurationProperties(prefix = "vibeerp.security.oidc") | |
| 27 | +data class OidcProperties( | |
| 28 | + /** OIDC issuer URI (e.g. `https://keycloak.example/realms/vibeerp`). Blank = disabled. */ | |
| 29 | + val issuerUri: String = "", | |
| 30 | + /** Claim path for the username. Default covers Keycloak's `preferred_username`. */ | |
| 31 | + val usernameClaim: String = "preferred_username", | |
| 32 | + /** Top-level claim name for roles array. For Keycloak, roles live under `realm_access.roles`. */ | |
| 33 | + val rolesClaim: String = "realm_access", | |
| 34 | +) | ... | ... |
platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/PrincipalContextFilter.kt
| ... | ... | @@ -20,32 +20,32 @@ import org.vibeerp.platform.security.authz.AuthorizedPrincipal |
| 20 | 20 | * |
| 21 | 21 | * 1. **`PrincipalContext`** (in `platform-persistence`) — carries |
| 22 | 22 | * just the principal id as a plain string, for the JPA audit |
| 23 | - * listener to write into `created_by` / `updated_by`. The audit | |
| 24 | - * listener must not depend on security types, so the id is | |
| 25 | - * stringly-typed at the persistence boundary. | |
| 23 | + * listener to write into `created_by` / `updated_by`. | |
| 26 | 24 | * 2. **`AuthorizationContext`** (in this module, P4.3) — carries the |
| 27 | 25 | * full `AuthorizedPrincipal` (id, username, role set) for the |
| 28 | - * `@RequirePermission` aspect and the `PermissionEvaluator` to | |
| 29 | - * consult. Roles are read from the JWT's `roles` claim populated | |
| 30 | - * by `JwtIssuer.issueAccessToken`. | |
| 26 | + * `@RequirePermission` aspect. | |
| 31 | 27 | * |
| 32 | - * Spring Security puts the authenticated `Authentication` object on a | |
| 33 | - * thread-local of its own. The bridge solves the layering problem by | |
| 34 | - * being the one and only place that knows about Spring Security's | |
| 35 | - * type AND about both vibe_erp contexts; everything downstream uses | |
| 36 | - * just the typed-but-Spring-free abstractions. | |
| 28 | + * **Dual claim mapping (P4.2 OIDC).** The filter handles JWTs from | |
| 29 | + * two sources with different claim structures: | |
| 37 | 30 | * |
| 38 | - * The filter is registered immediately after Spring Security's JWT | |
| 39 | - * authentication filter (`BearerTokenAuthenticationFilter`) so the | |
| 40 | - * `SecurityContext` is fully populated by the time we read it. On | |
| 41 | - * unauthenticated requests (the public allowlist) the `SecurityContext` | |
| 42 | - * is empty and we leave both contexts unset — which means the audit | |
| 43 | - * listener falls back to its `__system__` sentinel and the | |
| 44 | - * `@RequirePermission` aspect denies any protected call (correct for | |
| 45 | - * unauth callers). | |
| 31 | + * - **Built-in** (JwtIssuer, HS256): `sub` = user UUID, | |
| 32 | + * `username` = display username, `roles` = flat string array. | |
| 33 | + * - **OIDC/Keycloak** (RS256): `sub` = OIDC subject, claim | |
| 34 | + * name for username is configurable via `OidcProperties.usernameClaim` | |
| 35 | + * (default: `preferred_username`), roles live under a nested | |
| 36 | + * JSON path configurable via `OidcProperties.rolesClaim` | |
| 37 | + * (default: `realm_access` containing a `roles` array). | |
| 38 | + * | |
| 39 | + * The filter tries the built-in claim names first, then falls back | |
| 40 | + * to the OIDC claim names. This is safe because: | |
| 41 | + * - Built-in tokens always have `username` set; OIDC tokens never do | |
| 42 | + * - The fallback order means a token that satisfies both (unlikely) | |
| 43 | + * prefers the built-in interpretation, which is correct | |
| 46 | 44 | */ |
| 47 | 45 | @Component |
| 48 | -class PrincipalContextFilter : OncePerRequestFilter() { | |
| 46 | +class PrincipalContextFilter( | |
| 47 | + private val oidcProperties: OidcProperties, | |
| 48 | +) : OncePerRequestFilter() { | |
| 49 | 49 | |
| 50 | 50 | override fun doFilterInternal( |
| 51 | 51 | request: HttpServletRequest, |
| ... | ... | @@ -64,11 +64,8 @@ class PrincipalContextFilter : OncePerRequestFilter() { |
| 64 | 64 | } |
| 65 | 65 | |
| 66 | 66 | val subject = jwt.subject |
| 67 | - val username = jwt.getClaimAsString("username") ?: subject | |
| 68 | - // Roles claim is optional — pre-P4.3 tokens don't have it | |
| 69 | - // (treat as empty), refresh-token-derived sessions wouldn't | |
| 70 | - // make it here anyway, and system tokens never carry it. | |
| 71 | - val roles: Set<String> = jwt.getClaimAsStringList("roles")?.toSet().orEmpty() | |
| 67 | + val username = resolveUsername(jwt) | |
| 68 | + val roles = resolveRoles(jwt) | |
| 72 | 69 | |
| 73 | 70 | val authorized = AuthorizedPrincipal( |
| 74 | 71 | id = subject, |
| ... | ... | @@ -82,4 +79,36 @@ class PrincipalContextFilter : OncePerRequestFilter() { |
| 82 | 79 | } |
| 83 | 80 | } |
| 84 | 81 | } |
| 82 | + | |
| 83 | + /** | |
| 84 | + * Extract username: try built-in `username` claim first, then | |
| 85 | + * the OIDC claim (default: `preferred_username`), then fall | |
| 86 | + * back to `sub`. | |
| 87 | + */ | |
| 88 | + private fun resolveUsername(jwt: Jwt): String { | |
| 89 | + jwt.getClaimAsString("username")?.let { return it } | |
| 90 | + jwt.getClaimAsString(oidcProperties.usernameClaim)?.let { return it } | |
| 91 | + return jwt.subject | |
| 92 | + } | |
| 93 | + | |
| 94 | + /** | |
| 95 | + * Extract roles: try built-in flat `roles` array first, then | |
| 96 | + * the OIDC nested structure (default: `realm_access.roles` | |
| 97 | + * for Keycloak). | |
| 98 | + */ | |
| 99 | + private fun resolveRoles(jwt: Jwt): Set<String> { | |
| 100 | + // Built-in: flat array at top level | |
| 101 | + jwt.getClaimAsStringList("roles")?.let { return it.toSet() } | |
| 102 | + | |
| 103 | + // OIDC/Keycloak: nested JSON object, e.g. realm_access: { roles: [...] } | |
| 104 | + val container = jwt.getClaim<Any>(oidcProperties.rolesClaim) | |
| 105 | + if (container is Map<*, *>) { | |
| 106 | + val nested = container["roles"] | |
| 107 | + if (nested is List<*>) { | |
| 108 | + return nested.filterIsInstance<String>().toSet() | |
| 109 | + } | |
| 110 | + } | |
| 111 | + | |
| 112 | + return emptySet() | |
| 113 | + } | |
| 85 | 114 | } | ... | ... |
platform/platform-security/src/test/kotlin/org/vibeerp/platform/security/JwtRoundTripTest.kt
| ... | ... | @@ -20,17 +20,18 @@ import java.util.UUID |
| 20 | 20 | */ |
| 21 | 21 | class JwtRoundTripTest { |
| 22 | 22 | |
| 23 | - private val properties = JwtProperties( | |
| 23 | + private val jwtProperties = JwtProperties( | |
| 24 | 24 | secret = "test-secret-with-at-least-32-chars-of-entropy", |
| 25 | 25 | issuer = "vibe-erp-test", |
| 26 | 26 | ) |
| 27 | + private val oidcProperties = OidcProperties() // defaults — OIDC disabled | |
| 27 | 28 | private lateinit var issuer: JwtIssuer |
| 28 | 29 | private lateinit var verifier: JwtVerifier |
| 29 | 30 | |
| 30 | 31 | @BeforeEach |
| 31 | 32 | fun setUp() { |
| 32 | - val cfg = JwtConfiguration(properties) | |
| 33 | - issuer = JwtIssuer(cfg.jwtEncoder(), properties) | |
| 33 | + val cfg = JwtConfiguration(jwtProperties, oidcProperties) | |
| 34 | + issuer = JwtIssuer(cfg.jwtEncoder(), jwtProperties) | |
| 34 | 35 | verifier = JwtVerifier(cfg.jwtDecoder()) |
| 35 | 36 | } |
| 36 | 37 | |
| ... | ... | @@ -120,7 +121,7 @@ class JwtRoundTripTest { |
| 120 | 121 | |
| 121 | 122 | @Test |
| 122 | 123 | fun `JwtConfiguration refuses too-short secret`() { |
| 123 | - assertFailure { JwtConfiguration(JwtProperties(secret = "too-short")) } | |
| 124 | + assertFailure { JwtConfiguration(JwtProperties(secret = "too-short"), OidcProperties()) } | |
| 124 | 125 | .isInstanceOf(IllegalArgumentException::class) |
| 125 | 126 | } |
| 126 | 127 | } | ... | ... |
-
mentioned in commit 34c8c92f