Commit 6ad72c7c706f3d08479292e50abf8ddfa6d37a90

Authored by zichun
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.
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 }
... ...