Repository: gravitee-io/graviteeio-access-management (java)

Issue:
[oidc] UserInfo Endpoint access with POST and bearer body
Currently we only accept UserInfo Request with an Access Token sent using the Authorization header field.

We should handle UserInfo Endpoint access with POST and bearer body


Pull request description:
feat(oidc): UserInfo Endpoint access with POST and bearer body
Closes #317

Patch:
diff --git a/gravitee-am-gateway/gravitee-am-gateway-handler/src/main/java/io/gravitee/am/gateway/handler/vertx/handler/oidc/OIDCRouter.java b/gravitee-am-gateway/gravitee-am-gateway-handler/src/main/java/io/gravitee/am/gateway/handler/vertx/handler/oidc/OIDCRouter.java
index 367ffe1165..b3dddb76d4 100644
--- a/gravitee-am-gateway/gravitee-am-gateway-handler/src/main/java/io/gravitee/am/gateway/handler/vertx/handler/oidc/OIDCRouter.java
+++ b/gravitee-am-gateway/gravitee-am-gateway-handler/src/main/java/io/gravitee/am/gateway/handler/vertx/handler/oidc/OIDCRouter.java
@@ -23,6 +23,7 @@ import io.gravitee.am.gateway.handler.vertx.handler.oidc.endpoint.ProviderJWKSet
 import io.gravitee.am.gateway.handler.vertx.handler.oidc.endpoint.UserInfoEndpoint;
 import io.gravitee.am.gateway.handler.vertx.handler.oidc.handler.UserInfoRequestParseHandler;
 import io.gravitee.am.gateway.service.UserService;
+import io.gravitee.common.http.MediaType;
 import io.vertx.core.Handler;
 import io.vertx.core.http.HttpMethod;
 import io.vertx.reactivex.core.Vertx;
@@ -84,6 +85,7 @@ public class OIDCRouter {
                 .handler(userInfoEndpoint);
         router
                 .route(HttpMethod.POST, "/userinfo")
+                .consumes(MediaType.APPLICATION_FORM_URLENCODED)
                 .handler(userInfoRequestParseHandler)
                 .handler(userInfoEndpoint);
 
diff --git a/gravitee-am-gateway/gravitee-am-gateway-handler/src/main/java/io/gravitee/am/gateway/handler/vertx/handler/oidc/handler/UserInfoRequestParseHandler.java b/gravitee-am-gateway/gravitee-am-gateway-handler/src/main/java/io/gravitee/am/gateway/handler/vertx/handler/oidc/handler/UserInfoRequestParseHandler.java
index 14d3a556b8..666265c6c4 100644
--- a/gravitee-am-gateway/gravitee-am-gateway-handler/src/main/java/io/gravitee/am/gateway/handler/vertx/handler/oidc/handler/UserInfoRequestParseHandler.java
+++ b/gravitee-am-gateway/gravitee-am-gateway-handler/src/main/java/io/gravitee/am/gateway/handler/vertx/handler/oidc/handler/UserInfoRequestParseHandler.java
@@ -21,9 +21,12 @@ import io.gravitee.am.gateway.handler.oauth2.token.AccessToken;
 import io.gravitee.am.gateway.handler.oauth2.token.TokenService;
 import io.gravitee.common.http.HttpHeaders;
 import io.vertx.core.Handler;
+import io.vertx.core.http.HttpMethod;
 import io.vertx.reactivex.core.http.HttpServerRequest;
 import io.vertx.reactivex.ext.web.RoutingContext;
 
+import java.util.Arrays;
+
 /**
  * The Client sends the UserInfo Request using either HTTP GET or HTTP POST.
  * The Access Token obtained from an OpenID Connect Authentication Request MUST be sent as a Bearer Token, per Section 2 of OAuth 2.0 Bearer Token Usage [RFC6750].
@@ -37,6 +40,8 @@ import io.vertx.reactivex.ext.web.RoutingContext;
 public class UserInfoRequestParseHandler implements Handler<RoutingContext> {
 
     private static final String BEARER = "Bearer";
+    private static final String ACCESS_TOKEN_PARAM = "access_token";
+    private static final String OPENID_SCOPE = "openid";
     private TokenService tokenService;
 
     public UserInfoRequestParseHandler() {
@@ -49,24 +54,39 @@ public class UserInfoRequestParseHandler implements Handler<RoutingContext> {
     @Override
     public void handle(RoutingContext context) {
         final HttpServerRequest request = context.request();
-        final String authorization = request.headers().get(HttpHeaders.AUTHORIZATION);
+        String accessToken = null;
 
-        if (authorization == null) {
-            throw new InvalidRequestException("An access token is required");
+        // Try to get the access token from the body request
+        if (request.method().equals(HttpMethod.POST)) {
+            accessToken = context.request().getParam(ACCESS_TOKEN_PARAM);
         }
 
-        int idx = authorization.indexOf(' ');
-        if (idx <= 0 || !BEARER.equalsIgnoreCase(authorization.substring(0, idx))) {
-            throw new InvalidRequestException("The access token must be sent using the Authorization header field");
-        }
+        // no access token try to get one from the HTTP Authorization header
+        if (accessToken == null || accessToken.isEmpty()) {
+            final String authorization = request.headers().get(HttpHeaders.AUTHORIZATION);
+
+            if (authorization == null) {
+                throw new InvalidRequestException("An access token is required");
+            }
 
-        final String accessToken = authorization.substring(idx + 1);
+            int idx = authorization.indexOf(' ');
+            if (idx <= 0 || !BEARER.equalsIgnoreCase(authorization.substring(0, idx))) {
+                throw new InvalidRequestException("The access token must be sent using the Authorization header field");
+            }
+
+            accessToken = authorization.substring(idx + 1);
+        }
 
         tokenService.getAccessToken(accessToken)
                 .map(accessToken1 -> {
                     if (accessToken1.getExpiresIn() == 0) {
                         throw new InvalidTokenException("The access token expired");
                     }
+                    // The Access Token must be obtained from an OpenID Connect Authentication Request (i.e should have at least openid scope)
+                    // https://openid.net/specs/openid-connect-core-1_0.html#UserInfoRequest
+                    if (accessToken1.getScope() == null || !Arrays.asList(accessToken1.getScope().split("\\s+")).contains(OPENID_SCOPE)) {
+                        throw new InvalidTokenException("Invalid access token scopes. The access token should have at least 'openid' scope");
+                    }
                     return accessToken1;
                 })
                 .subscribe(


Test patch:
diff --git a/gravitee-am-gateway/gravitee-am-gateway-handler/src/test/java/io/gravitee/am/gateway/handler/vertx/endpoint/UserInfoEndpointHandlerTest.java b/gravitee-am-gateway/gravitee-am-gateway-handler/src/test/java/io/gravitee/am/gateway/handler/vertx/endpoint/UserInfoEndpointHandlerTest.java
index 5a2656bbb0..50ed40ca8c 100644
--- a/gravitee-am-gateway/gravitee-am-gateway-handler/src/test/java/io/gravitee/am/gateway/handler/vertx/endpoint/UserInfoEndpointHandlerTest.java
+++ b/gravitee-am-gateway/gravitee-am-gateway-handler/src/test/java/io/gravitee/am/gateway/handler/vertx/endpoint/UserInfoEndpointHandlerTest.java
@@ -107,6 +107,7 @@ public class UserInfoEndpointHandlerTest extends RxWebTestBase {
     public void shouldNotInvokeUserEndpoint_clientOnlyToken() throws Exception {
         AccessToken accessToken = new DefaultAccessToken("token");
         ((DefaultAccessToken) accessToken).setExpiresIn(100);
+        ((DefaultAccessToken) accessToken).setScope("openid");
 
         when(tokenService.getAccessToken(anyString())).thenReturn(Maybe.just(accessToken));
 
@@ -119,6 +120,7 @@ public class UserInfoEndpointHandlerTest extends RxWebTestBase {
     public void shouldNotInvokeUserEndpoint_userNotFound() throws Exception {
         AccessToken accessToken = new DefaultAccessToken("token");
         ((DefaultAccessToken) accessToken).setExpiresIn(100);
+        ((DefaultAccessToken) accessToken).setScope("openid");
 
         when(tokenService.getAccessToken(anyString())).thenReturn(Maybe.just(accessToken));
         when(userService.findById(anyString())).thenReturn(Maybe.empty());
@@ -128,11 +130,47 @@ public class UserInfoEndpointHandlerTest extends RxWebTestBase {
                 HttpStatusCode.BAD_REQUEST_400, "Bad Request", null);
     }
 
+    @Test
+    public void shouldInvokeUserEndpoint_noOpenIDScope_noScope() throws Exception {
+        AccessToken accessToken = new DefaultAccessToken("token");
+        ((DefaultAccessToken) accessToken).setSubject("user");
+        ((DefaultAccessToken) accessToken).setExpiresIn(100);
+
+        User user = new User();
+        user.setAdditionalInformation(Collections.singletonMap("sub", "user"));
+
+        when(tokenService.getAccessToken(anyString())).thenReturn(Maybe.just(accessToken));
+        when(userService.findById(anyString())).thenReturn(Maybe.just(user));
+
+        testRequest(
+                HttpMethod.GET, "/userinfo", req -> req.putHeader(HttpHeaders.AUTHORIZATION, "Bearer test-token"),
+                HttpStatusCode.UNAUTHORIZED_401, "Unauthorized", null);
+    }
+
+    @Test
+    public void shouldInvokeUserEndpoint_noOpenIDScope() throws Exception {
+        AccessToken accessToken = new DefaultAccessToken("token");
+        ((DefaultAccessToken) accessToken).setSubject("user");
+        ((DefaultAccessToken) accessToken).setExpiresIn(100);
+        ((DefaultAccessToken) accessToken).setScope("read write");
+
+        User user = new User();
+        user.setAdditionalInformation(Collections.singletonMap("sub", "user"));
+
+        when(tokenService.getAccessToken(anyString())).thenReturn(Maybe.just(accessToken));
+        when(userService.findById(anyString())).thenReturn(Maybe.just(user));
+
+        testRequest(
+                HttpMethod.GET, "/userinfo", req -> req.putHeader(HttpHeaders.AUTHORIZATION, "Bearer test-token"),
+                HttpStatusCode.UNAUTHORIZED_401, "Unauthorized", null);
+    }
+
     @Test
     public void shouldInvokeUserEndpoint() throws Exception {
         AccessToken accessToken = new DefaultAccessToken("token");
         ((DefaultAccessToken) accessToken).setSubject("user");
         ((DefaultAccessToken) accessToken).setExpiresIn(100);
+        ((DefaultAccessToken) accessToken).setScope("openid");
 
         User user = new User();
         user.setAdditionalInformation(Collections.singletonMap("sub", "user"));