micronaut-projects / micronaut-security

The official Micronaut security solution
Apache License 2.0
170 stars 126 forks source link

Token Propagation and OpenID "azp" claim validation issues #1543

Open ArnauAregall opened 10 months ago

ArnauAregall commented 10 months ago

Issue description

Hello,

I have a use case where two Micronaut services are secured using OpenID (idtoken) with an OAuth2 issuer (Keycloak) within the same realm.

Each service is configured to use it's own realm client.

At application level, one service calls the other using HTTP client interfaces, using JWT Token Propagation feature.

identity-service:

micronaut:
  application:
    name: identity-service
  security:
    intercept-url-map:
      - pattern: /api/**
        access:
          - isAuthenticated()
    authentication: idtoken
    oauth2:
      clients:
        keycloak:
          client-id: '${micronaut.application.name}'
          client-secret: '${OAUTH2_IDENTITY_CLIENT_SECRET}'
          issuer: 'http://localhost:8082/realms/petclinic'

pet-service (calls identity-service):

micronaut:
  application:
    name: pet-service
  http:
    services:
      identity-service:
        url: "http://identity-service"
  security:
    intercept-url-map:
      - pattern: /api/**
        access:
          - isAuthenticated()
    authentication: idtoken
    oauth2:
      clients:
        keycloak:
          client-id: '${micronaut.application.name}'
          client-secret: '${OAUTH2_PET_CLIENT_SECRET}'
          issuer: 'http://localhost:8082/realms/petclinic'
    token:
      propagation:
        enabled: true
        service-id-regex: "identity-service"
@Client(id = "identity-service")
internal fun interface IdentityServiceHttpClient {

    @Get("/api/identities/{identityId}")
    @SingleResult
    fun getIdentity(@PathVariable identityId: UUID): Mono<HttpResponse<GetIdentityResponse>>

}

The OAuth clients have configured scopes so the aud claims of the JWT token contains the two client-ids.

Example decoded JWT payload:

{
  "exp": 1703581274,
  "iat": 1703580974,
  "auth_time": 1703580974,
  "jti": "98e88f17-683f-4ecc-8e70-b29ed6a604ab",
  "iss": "http://localhost:8082/realms/petclinic",
  "aud": [
    "pet-service",
    "identity-service"
  ],
  "sub": "b40af7e9-392d-40e9-9eb7-55993c9d2a8e",
  "typ": "ID",
  "azp": "pet-service",
  "nonce": "5a647d92-97e0-4bec-ba17-d8a116e93494",
  "session_state": "7c02f73a-e92b-4187-a70a-b49464e1c4fb",
  "at_hash": "5LMA6RsrsdBKtNk21bMfMA",
  "acr": "1",
  "sid": "7c02f73a-e92b-4187-a70a-b49464e1c4fb",
  "email_verified": false,
  "preferred_username": "system_test_user"
}

The issue I'm experiencing is the following:

The Authorized Party claim (azp) of the token is the pet-service, and when pet-service performs the authenticated HTTP call to identity-service endpoints using the Token Propagation feature, identity-service runs the io.micronaut.security.oauth2.client.IdTokenClaimsValidator and fails at the validateAzp step, even though the audiences validation is successful.

I've verified that disabling openid claims validation on identity-service via configuration is a bypass to the issue.

micronaut:
  security:
    token:
      jwt:
        claims-validators:
          openid-idtoken: false

I have also noticed recent clarifications in regards to azp were added to OpenID specs, and if I got it right the azp should only be validated when using extensions beyond the scope of the spec.

As framework core committers, which approach would you recommend to solve this issue?

Do you believe IdTokenClaimsValidator#validateAzp should be revisited after OpenID spec clarifications perhaps?

Thanks a lot in advance. Arnau.

sdelamo commented 10 months ago

Thanks for the detailed issue report. I need to check it further. However, I recommend you not turn off the whole idtoken validator.

Instead, you can do a Bean Replacement and override the method causing issues.

@Singleton
@Replaces(IdTokenClaimsValidator.class)
public class IdTokenClaimsValidatorReplacement extends  IdTokenClaimsValidator {

@Override
 protected boolean validateAzp(@NonNull Claims claims,
                                  @NonNull String clientId,
                                  @NonNull List<String> audiences) {
// do custom logic
}

}
ArnauAregall commented 10 months ago

Thanks for your answer @sdelamo, I'll try the custom bean replacement approach and get back with feedback.

ArnauAregall commented 10 months ago

Hello @sdelamo, I confirm the suggested bean replacement approach suits as temporal workaround for my issue.

Here is the aforementioned example but with Kotlin that worked fine for me with the custom azp claim validation.

@Singleton
@Replaces(IdTokenClaimsValidator::class)
class CustomIdTokenClaimsValidator<T>(oauthClientConfigurations: Collection<OauthClientConfiguration>): IdTokenClaimsValidator<T>(oauthClientConfigurations) {

    override fun validateAzp(claims: Claims, clientId: String, audiences: MutableList<String>): Boolean {
        if (audiences.size < 2) {
            return true
        }
        return parseAzpClaim(claims)
            .filter { clientId.equals(it, ignoreCase = true) || audiences.containsIgnoreCase(it) }
            .isPresent
    }

}

private fun List<String>.containsIgnoreCase(element: String): Boolean {
    return this.any { it.equals(element, ignoreCase = true) }
}

Thanks again for your answer!