CVE-2023-36820 (published 2023-10-09T14:15Z)

Micronaut Security is a security solution for applications. Prior to versions 3.1.2, 3.2.4, 3.3.2, 3.4.3, 3.5.3, 3.6.6, 3.7.4, 3.8.4, 3.9.6, 3.10.2, and 3.11.1, IdTokenClaimsValidator skips `aud` claim validation if token is issued by same identity issuer/provider. Any OIDC setup using Micronaut where multiple OIDC applications exists for the same issuer but token auth are not meant to be shared. This issue has been patched in versions 3.1.2, 3.2.4, 3.3.2, 3.4.3, 3.5.3, 3.6.6, 3.7.4, 3.8.4, 3.9.6, 3.10.2, and 3.11.1.\n

CVSS v3 base score: 6.5, MEDIUM (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N)

Weakness information:
cwe_id: CWE-284
cwe_name: Improper Access Control
description: The product does not restrict or incorrectly restricts access to a resource from an unauthorized actor.
extended_description: 
            Access control involves the use of several protection mechanisms such as:
            
               Authentication (proving the identity of an actor)
               Authorization (ensuring that a given actor can access a resource), and
               Accountability (tracking of activities that were performed)
            
            When any mechanism is not applied or otherwise fails, attackers can compromise the security of the product by gaining privileges, reading sensitive information, executing commands, evading detection, etc.
            There are two distinct behaviors that can introduce access control weaknesses:
               
                  Specification: incorrect privileges, permissions, ownership, etc. are explicitly specified for either the user or the resource (for example, setting a password file to be world-writable, or giving administrator capabilities to a guest user). This action could be performed by the program or the administrator.
                  Enforcement: the mechanism contains errors that prevent it from properly enforcing the specified access control requirements (e.g., allowing the user to specify their own privileges, or allowing a syntactically-incorrect ACL to produce insecure settings). This problem occurs within the program itself, in that it does not actually enforce the intended security policy that the administrator specifies.
               
         
		 

Fix information:
repo_url: https://github.com/micronaut-projects/micronaut-security
commit_details: repo_url: https://github.com/micronaut-projects/micronaut-security
author: Sergio del Amo
author_date: 2023-06-23 12:36:56+02:00
msg: Merge pull request from GHSA-qw22-8w9r-864h

The `aud` claim should contain the Client ID.

https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation

> Clients MUST validate the ID Token in the Token Response in the following manner:
...
> 1.2. The Issuer Identifier for the OpenID Provider (which is typically obtained during Discovery) MUST exactly match the value of the iss (issuer) Claim.
> 1.3. The Client MUST validate that the aud (audience) Claim contains its client_id value registered at the Issuer identified by the iss (issuer) Claim as an audience.

The audience claim is [validated correctly when obtained from authorisation server.](https://github.com/micronaut-projects/micronaut-security/blob/master/security-oauth2/src/main/java/io/micronaut/security/oauth2/endpoint/token/response/validation/DefaultOpenIdTokenResponseValidator.java#L122) with the [`AudienceClaimValidator`](https://github.com/micronaut-projects/micronaut-security/blob/master/security-oauth2/src/main/java/io/micronaut/security/oauth2/endpoint/token/response/validation/AudienceClaimValidator.java).

However, if the user set `micronaut.security.authentication` to `idtoken`, the token validation of a new request does not validate `aud` claim to match the Client ID if the `iss` claim matched an existing issuer in the application.

This change fixes this vulnerability.
repository: repo_url: https://github.com/micronaut-projects/micronaut-security
repo_name: micronaut-projects/micronaut-security
description: The official Micronaut security solution
repo_language: Java
owner: micronaut-projects
file_changes: filename: IdTokenClaimsValidator.java
change_type: ModificationType.MODIFY
diff: @@ -199,7 +199,7 @@ public class IdTokenClaimsValidator implements GenericJwtClaimsValidator {
             Optional<URL> issuerOptional = openIdClientConfiguration.getIssuer();
             if (issuerOptional.isPresent()) {
                 String issuer = issuerOptional.get().toString();
-                return issuer.equalsIgnoreCase(iss) ||
+                return issuer.equalsIgnoreCase(iss) &&
                         audiences.contains(clientId) &&
                                 validateAzp(claims, clientId, audiences);
             }

programming_language: Java
filename: AudienceClaimMatchesClientIdSpec.groovy
change_type: ModificationType.ADD
diff: @@ -0,0 +1,213 @@
+package io.micronaut.security.oauth2.endpoint.token.response.validation
+
+import com.nimbusds.jose.JWSAlgorithm
+import com.nimbusds.jose.jwk.JWK
+import com.nimbusds.jose.jwk.KeyUse
+import com.nimbusds.jose.jwk.RSAKey
+import com.nimbusds.jose.jwk.gen.JWKGenerator
+import com.nimbusds.jose.jwk.gen.RSAKeyGenerator
+import com.nimbusds.jwt.JWT
+import com.nimbusds.jwt.JWTParser
+import com.nimbusds.jwt.SignedJWT
+import io.micronaut.context.ApplicationContext
+import io.micronaut.context.annotation.Requires
+import io.micronaut.core.util.StringUtils
+import io.micronaut.http.HttpRequest
+import io.micronaut.http.HttpResponse
+import io.micronaut.http.HttpStatus
+import io.micronaut.http.MediaType
+import io.micronaut.http.annotation.Controller
+import io.micronaut.http.annotation.Get
+import io.micronaut.http.annotation.Produces
+import io.micronaut.http.client.BlockingHttpClient
+import io.micronaut.http.client.HttpClient
+import io.micronaut.http.client.exceptions.HttpClientResponseException
+import io.micronaut.http.server.util.HttpHostResolver
+import io.micronaut.runtime.server.EmbeddedServer
+import io.micronaut.security.annotation.Secured
+import io.micronaut.security.authentication.Authentication
+import io.micronaut.security.rules.SecurityRule
+import io.micronaut.security.token.Claims
+import io.micronaut.security.token.generator.TokenGenerator
+import io.micronaut.security.token.jwt.endpoints.JwkProvider
+import io.micronaut.security.token.jwt.generator.AccessTokenConfiguration
+import io.micronaut.security.token.jwt.generator.claims.ClaimsGenerator
+import io.micronaut.security.token.jwt.signature.rsa.RSASignatureGeneratorConfiguration
+import jakarta.inject.Named
+import jakarta.inject.Singleton
+import spock.lang.Specification
+
+import java.security.interfaces.RSAPrivateKey
+import java.security.interfaces.RSAPublicKey
+
+class AudienceClaimMatchesClientIdSpec extends Specification {
+
+    void "test same issuer two different client ids"() {
+        given:
+        EmbeddedServer authServer = ApplicationContext.run(EmbeddedServer, [
+                "micronaut.security.reject-not-found": StringUtils.FALSE,
+                "spec.name": "AudienceClaimMatchesClientIdSpecAuthServer"
+        ])
+        Map<String, Object> configuration = [
+                "micronaut.security.authentication": "idtoken",
+                "micronaut.security.reject-not-found": StringUtils.FALSE,
+                "spec.name": "AudienceClaimMatchesClientIdSpec",
+                "micronaut.security.oauth2.clients.auth.client-id": "AAA",
+                "micronaut.security.oauth2.clients.auth.client-secret": "YYY",
+                'micronaut.security.oauth2.clients.auth.openid.issuer': "http://localhost:${authServer.port}/oauth2/default"]
+        EmbeddedServer server = ApplicationContext.run(EmbeddedServer, configuration)
+        HttpClient authServerHttpClient = server.applicationContext.createBean(HttpClient, authServer.URL)
+        BlockingHttpClient authServerClient = authServerHttpClient.toBlocking()
+
+        when:
+        String keys = authServerClient.retrieve(HttpRequest.GET("/keys"))
+
+        then:
+        noExceptionThrown()
+        keys != '{"keys":[]}'
+
+        when:
+        String clientAToken = authServerClient.retrieve(HttpRequest.GET("/token/clientA").accept(MediaType.TEXT_PLAIN))
+        JWT clientAJWT = JWTParser.parse(clientAToken)
+
+        then:
+        clientAJWT instanceof SignedJWT
+        clientAJWT.getJWTClaimsSet().getClaim("aud") == ["AAA"]
+        clientAJWT.getJWTClaimsSet().getClaim("iss") == "http://localhost:${authServer.port}/oauth2/default"
+
+        when:
+        HttpClient httpClient = server.applicationContext.createBean(HttpClient, server.URL)
+        BlockingHttpClient client = httpClient.toBlocking()
+        client.retrieve(HttpRequest.GET("/hello").bearerAuth(clientAToken))
+
+        then:
+        noExceptionThrown()
+
+        when:
+        String clientBToken = authServerClient.retrieve(HttpRequest.GET("/token/clientB").accept(MediaType.TEXT_PLAIN))
+        JWT clientBJWT = JWTParser.parse(clientBToken)
+
+        then:
+        clientBJWT instanceof SignedJWT
+        clientBJWT.getJWTClaimsSet().getClaim("aud") == ["BBB"]
+        clientBJWT.getJWTClaimsSet().getClaim("iss") == "http://localhost:${authServer.port}/oauth2/default"
+
+        when:
+        client.retrieve(HttpRequest.GET("/hello").bearerAuth(clientBToken))
+
+        then:
+        HttpClientResponseException e = thrown()
+        HttpStatus.UNAUTHORIZED == e.status
+
+        cleanup:
+        authServerClient.close()
+        authServerHttpClient.close()
+        httpClient.close()
+        client.close()
+        server.close()
+        authServer.close()
+    }
+
+    @Requires(property = 'spec.name', value = 'AudienceClaimMatchesClientIdSpec')
+    @Controller("/hello")
+    static class HelloWorldController {
+        @Get
+        @Secured(SecurityRule.IS_AUTHENTICATED)
+        Map<String, String> index() {
+            [message: 'Hello World']
+        }
+    }
+
+    @Requires(property = "spec.name", value="AudienceClaimMatchesClientIdSpecAuthServer")
+    @Secured(SecurityRule.IS_ANONYMOUS)
+    @Controller
+    static class AuthServerController {
+
+        private final HttpHostResolver httpHostResolver
+        private final TokenGenerator tokenGenerator
+        private final AccessTokenConfiguration accessTokenConfiguration
+        private final ClaimsGenerator claimsGenerator
+
+        AuthServerController(HttpHostResolver httpHostResolver,
+                             TokenGenerator tokenGenerator,
+                             AccessTokenConfiguration accessTokenConfiguration,
+                             ClaimsGenerator claimsGenerator) {
+            this.httpHostResolver = httpHostResolver
+            this.tokenGenerator = tokenGenerator
+            this.accessTokenConfiguration = accessTokenConfiguration
+            this.claimsGenerator = claimsGenerator
+        }
+
+        @Produces(MediaType.TEXT_PLAIN)
+        @Get("/token/clientA")
+        HttpResponse<Object> clientA(HttpRequest<?> request) {
+            generateToken(request, "AAA")
+        }
+
+        @Produces(MediaType.TEXT_PLAIN)
+        @Get("/token/clientB")
+        HttpResponse<Object> clientB(HttpRequest<?> request) {
+            generateToken(request, "BBB")
+        }
+
+        private HttpResponse<Object> generateToken(HttpRequest<?> request, String clientId) {
+            String host = httpHostResolver.resolve(request)
+            Map<String, Object> claims = new HashMap<>(claimsGenerator.generateClaims(Authentication.build("sherlock"), accessTokenConfiguration.expiration))
+            claims[Claims.ISSUER] = "${host}/oauth2/default".toString()
+            claims[Claims.AUDIENCE] = clientId
+            tokenGenerator.generateToken(claims).map(HttpResponse::ok).orElseGet(() -> HttpResponse.serverError())
+        }
+
+        @Get("/oauth2/default/.well-known/openid-configuration")
+        String openIdConfiguration(HttpRequest<?> request) {
+            String host = httpHostResolver.resolve(request)
+            '{"issuer":"' + host + '/oauth2/default","authorization_endpoint":"' + host + '/oauth2/default/v1/authorize","token_endpoint":"' + host + '/oauth2/default/v1/token","userinfo_endpoint":"' + host + '/oauth2/default/v1/userinfo","registration_endpoint":"' + host + '/oauth2/v1/clients","jwks_uri":"' + host + '/keys","response_types_supported":["code","id_token","code id_token","code token","id_token token","code id_token token"],"response_modes_supported":["query","fragment","form_post","okta_post_message"],"grant_types_supported":["authorization_code","implicit","refresh_token","password"],"subject_types_supported":["public"],"id_token_signing_alg_values_supported":["RS256"],"scopes_supported":["openid","profile","email","address","phone","offline_access"],"token_endpoint_auth_methods_supported":["client_secret_basic","client_secret_post","client_secret_jwt","private_key_jwt","none"],"claims_supported":["iss","ver","sub","aud","iat","exp","jti","auth_time","amr","idp","nonce","name","nickname","preferred_username","given_name","middle_name","family_name","email","email_verified","profile","zoneinfo","locale","address","phone_number","picture","website","gender","birthdate","updated_at","at_hash","c_hash"],"code_challenge_methods_supported":["S256"],"introspection_endpoint":"' + host + '/oauth2/default/v1/introspect","introspection_endpoint_auth_methods_supported":["client_secret_basic","client_secret_post","client_secret_jwt","private_key_jwt","none"],"revocation_endpoint":"' + host + '/oauth2/default/v1/revoke","revocation_endpoint_auth_methods_supported":["client_secret_basic","client_secret_post","client_secret_jwt","private_key_jwt","none"],"end_session_endpoint":"' + host + '/oauth2/default/v1/logout","request_parameter_supported":true,"request_object_signing_alg_values_supported":["HS256","HS384","HS512","RS256","RS384","RS512","ES256","ES384","ES512"]}'
+        }
+    }
+
+    @Requires(property = 'spec.name', value = 'AudienceClaimMatchesClientIdSpecAuthServer')
+    @Named("generator")
+    @Singleton
+    static class AuthServerSignatureConfiguration implements RSASignatureGeneratorConfiguration, JwkProvider {
+        private final static JWSAlgorithm ALG = JWSAlgorithm.RS256
+        private List<JWK> jwks = null
+        private RSAKey rsaKey = null
+
+        List<JWK> getJwks() {
+            if (jwks == null) {
+                this.jwks = Collections.singletonList(getRsaKey().toPublicJWK())
+            }
+            return jwks
+        }
+
+        RSAKey getRsaKey() {
+            if (rsaKey == null) {
+                JWKGenerator jwkGenerator = new RSAKeyGenerator(2048)
+                        .algorithm(ALG)
+                        .keyUse(KeyUse.SIGNATURE)
+                this.rsaKey = jwkGenerator.generate()
+            }
+            return rsaKey
+        }
+
+        @Override
+        RSAPublicKey getPublicKey() {
+            getRsaKey().toRSAPublicKey()
+        }
+
+        @Override
+        RSAPrivateKey getPrivateKey() {
+            getRsaKey().toRSAPrivateKey()
+        }
+
+        @Override
+        JWSAlgorithm getJwsAlgorithm() {
+            ALG
+        }
+
+        @Override
+        List<JWK> retrieveJsonWebKeys() {
+            getJwks()
+        }
+    }
+}

programming_language: Scala