Repository: vert-x3/vertx-web
Issue: OAuth2AuthHandler.withScope returns new instance rather than updating current instance

### Version

4.3.1

### Context

I upgraded from 4.0.0. There were a few changes I needed to make, which went fine. But when testing OAuth with Webex, I got a message saying I was passing an invalid scope. I checked the URL, and no scope was being passed although I called `withScope()`

Investigating further, I found OAuth2AuthImpl.withScope() and .withScopes both return a new instance rather than the current instance https://github.com/vert-x3/vertx-web/blob/4.3.1/vertx-web/src/main/java/io/vertx/ext/web/handler/impl/OAuth2AuthHandlerImpl.java#L240. Other fluent methods return the same instance.

### Do you have a reproducer?

I don't have a reproducer that calls an external provider because Webex APIs only work with a paid account But you can review by looking at the example in the documentation, https://github.com/vert-x3/vertx-web/blob/master/vertx-web/src/main/java/examples/WebExamples.java#L1386. From what I've stepped through in my code, this won't work as expected, because on line 1402 it will pass a scope to add. But the method called doesn't update the current `oauth2` instance, instead is creates a new instance of the class and returns that. If you can debug the code, you should see at line 1405 `oauth2` does not have the scopes passed in line 1402. In order for the example to work, it would need to be changed to be `oauth2 = oauth2.withScope("profile");`.

### Steps to reproduce

1. If you can debug the example, you will see it calls `withScope()` at line 1402
2. After the method has been called, the `oauth2` instance at line 1405 won't have any scopes.

### Extra

I'm not sure which is your preferred solution. I can work around it by reassigning my OAuth2AuthHandler. I just want to make sure the code I have is best for the future and that the documentation and code align.


Changed files:
vertx-web/src/test/java/io/vertx/ext/web/handler/JWTAuthHandlerTest.java, vertx-web/src/main/java/io/vertx/ext/web/handler/impl/ScopedAuthentication.java, vertx-web/src/main/java/io/vertx/ext/web/handler/impl/OAuth2AuthHandlerImpl.java, vertx-web/src/main/java/io/vertx/ext/web/handler/OAuth2AuthHandler.java, vertx-web/src/test/java/io/vertx/ext/web/handler/OAuth2AuthHandlerTest.java, vertx-web/src/main/java/io/vertx/ext/web/handler/JWTAuthHandler.java, vertx-web/src/main/java/io/vertx/ext/web/handler/impl/JWTAuthHandlerImpl.java

diff --git a/vertx-web/src/main/java/io/vertx/ext/web/handler/JWTAuthHandler.java b/vertx-web/src/main/java/io/vertx/ext/web/handler/JWTAuthHandler.java
index 9af0816ac..70cc1172c 100644
--- a/vertx-web/src/main/java/io/vertx/ext/web/handler/JWTAuthHandler.java
+++ b/vertx-web/src/main/java/io/vertx/ext/web/handler/JWTAuthHandler.java
@@ -32,7 +32,8 @@ import java.util.List;
 public interface JWTAuthHandler extends AuthenticationHandler {
 
   /**
-   * Create a JWT auth handler
+   * Create a JWT auth handler. When no scopes are explicit declared, the default scopes will be looked up from the
+   * route metadata.
    *
    * @param authProvider  the auth provider to use
    * @return the auth handler
@@ -42,7 +43,8 @@ public interface JWTAuthHandler extends AuthenticationHandler {
   }
 
   /**
-   * Create a JWT auth handler
+   * Create a JWT auth handler. When no scopes are explicit declared, the default scopes will be looked up from the
+   * route metadata.
    *
    * @param authProvider  the auth provider to use
    * @return the auth handler
@@ -52,32 +54,31 @@ public interface JWTAuthHandler extends AuthenticationHandler {
   }
 
   /**
-   * Return a new instance with the internal state copied from the caller but the scopes delimiter set
-   * to be unique to the instance.
+   * Set the scope delimiter. By default this is a space character.
    *
    * @param delimiter scope delimiter.
-   * @return new instance of this interface.
+   * @return fluent self.
    */
   @Fluent
   JWTAuthHandler scopeDelimiter(String delimiter);
 
   /**
    * Return a new instance with the internal state copied from the caller but the scopes to be requested during a token
-   * request are unique to the instance.
+   * request are unique to the instance. When scopes are applied to the handler, the default scopes from the route
+   * metadata will be ignored.
    *
    * @param scope scope.
    * @return new instance of this interface.
    */
-  @Fluent
   JWTAuthHandler withScope(String scope);
 
   /**
    * Return a new instance with the internal state copied from the caller but the scopes to be requested during a token
-   * request are unique to the instance.
+   * request are unique to the instance. When scopes are applied to the handler, the default scopes from the route
+   * metadata will be ignored.
    *
    * @param scopes scopes.
    * @return new instance of this interface.
    */
-  @Fluent
   JWTAuthHandler withScopes(List<String> scopes);
 }
diff --git a/vertx-web/src/main/java/io/vertx/ext/web/handler/OAuth2AuthHandler.java b/vertx-web/src/main/java/io/vertx/ext/web/handler/OAuth2AuthHandler.java
index 5b9f1df31..2f8439444 100644
--- a/vertx-web/src/main/java/io/vertx/ext/web/handler/OAuth2AuthHandler.java
+++ b/vertx-web/src/main/java/io/vertx/ext/web/handler/OAuth2AuthHandler.java
@@ -35,11 +35,14 @@ import java.util.List;
 public interface OAuth2AuthHandler extends AuthenticationHandler {
 
   /**
-   * Create a OAuth2 auth handler with host pinning
+   * Create a OAuth2 auth handler with host pinning. When no scopes are explicit declared, the default scopes will be
+   * looked up from the route metadata under the key {@code scopes} which can either be a single {@link String} or a
+   * {@link List<String>}.
    *
    * @param vertx  the vertx instance
    * @param authProvider  the auth provider to use
-   * @param callbackURL the callback URL you entered in your provider admin console, usually it should be something like: `https://myserver:8888/callback`
+   * @param callbackURL the callback URL you entered in your provider admin console, usually it should be something
+   *                    like: {@code https://myserver:8888/callback}
    * @return the auth handler
    */
   static OAuth2AuthHandler create(Vertx vertx, OAuth2Auth authProvider, String callbackURL) {
@@ -50,9 +53,10 @@ public interface OAuth2AuthHandler extends AuthenticationHandler {
   }
 
   /**
-   * Create a OAuth2 auth handler without host pinning.
-   * Most providers will not look to the redirect url but always redirect to
-   * the preconfigured callback. So this factory does not provide a callback url.
+   * Create a OAuth2 auth handler without host pinning.Most providers will not look to the redirect url but always
+   * redirect to the preconfigured callback. So this factory does not provide a callback url. When no scopes are
+   * explicit declared, the default scopes will be looked up from the route metadata under the key {@code scopes}
+   * which can either be a single {@link String} or a {@link List<String>}.
    *
    * @param vertx  the vertx instance
    * @param authProvider  the auth provider to use
@@ -72,8 +76,9 @@ public interface OAuth2AuthHandler extends AuthenticationHandler {
   OAuth2AuthHandler extraParams(JsonObject extraParams);
 
   /**
-   * Return a new instance with the internal state copied from the caller but the scopes to be requested during a token
-   * request are unique to the instance.
+   * Return a <b>new instance</b> with the internal state copied from the caller but the scopes to be requested during
+   * a token request are unique to the instance. When scopes are applied to the handler, the default scopes from the
+   * route metadata will be ignored.
    *
    * @param scope scope.
    * @return new instance of this interface.
@@ -82,8 +87,9 @@ public interface OAuth2AuthHandler extends AuthenticationHandler {
   OAuth2AuthHandler withScope(String scope);
 
   /**
-   * Return a new instance with the internal state copied from the caller but the scopes to be requested during a token
-   * request are unique to the instance.
+   * Return a <b>new instance</b> with the internal state copied from the caller but the scopes to be requested during
+   * a token request are unique to the instance. When scopes are applied to the handler, the default scopes from the
+   * route metadata will be ignored.
    *
    * @param scopes scopes.
    * @return new instance of this interface.
@@ -98,9 +104,14 @@ public interface OAuth2AuthHandler extends AuthenticationHandler {
    *
    * <ul>
    *   <li><b>login</b> will force the user to enter their credentials on that request, negating single-sign on.</li>
-   *   <li><b>none</b> is the opposite - it will ensure that the user isn't presented with any interactive prompt whatsoever. If the request can't be completed silently via single-sign on, the Microsoft identity platform endpoint will return an interaction_required error.</li>
-   *   <li><b>consent</b> will trigger the OAuth consent dialog after the user signs in, asking the user to grant permissions to the app.</li>
-   *   <li><b>select_account</b> will interrupt single sign-on providing account selection experience listing all the accounts either in session or any remembered account or an option to choose to use a different account altogether.</li>
+   *   <li><b>none</b> is the opposite - it will ensure that the user isn't presented with any interactive prompt
+   *      whatsoever. If the request can't be completed silently via single-sign on, the Microsoft identity platform
+   *      endpoint will return an interaction_required error.</li>
+   *   <li><b>consent</b> will trigger the OAuth consent dialog after the user signs in, asking the user to grant
+   *      permissions to the app.</li>
+   *   <li><b>select_account</b> will interrupt single sign-on providing account selection experience listing all the
+   *      accounts either in session or any remembered account or an option to choose to use a different account
+   *      altogether.</li>
    *   <li><b></b></li>
    * </ul>
    *
@@ -125,7 +136,7 @@ public interface OAuth2AuthHandler extends AuthenticationHandler {
 
   /**
    * add the callback handler to a given route.
-   * @param route a given route e.g.: `/callback`
+   * @param route a given route e.g.: {@code /callback}
    * @return self
    */
   @Fluent
diff --git a/vertx-web/src/main/java/io/vertx/ext/web/handler/impl/JWTAuthHandlerImpl.java b/vertx-web/src/main/java/io/vertx/ext/web/handler/impl/JWTAuthHandlerImpl.java
index a3f47bacd..5550c8501 100644
--- a/vertx-web/src/main/java/io/vertx/ext/web/handler/impl/JWTAuthHandlerImpl.java
+++ b/vertx-web/src/main/java/io/vertx/ext/web/handler/impl/JWTAuthHandlerImpl.java
@@ -29,7 +29,9 @@ import io.vertx.ext.web.handler.JWTAuthHandler;
 import io.vertx.ext.web.impl.RoutingContextInternal;
 
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.List;
+import java.util.Objects;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
 
@@ -39,17 +41,19 @@ import java.util.stream.Stream;
 public class JWTAuthHandlerImpl extends HTTPAuthorizationHandler<JWTAuth> implements JWTAuthHandler, ScopedAuthentication<JWTAuthHandler> {
 
   private final List<String> scopes;
-  private final String delimiter;
+  private String delimiter;
 
   public JWTAuthHandlerImpl(JWTAuth authProvider, String realm) {
     super(authProvider, Type.BEARER, realm);
-    scopes = new ArrayList<>();
+    scopes = Collections.emptyList();
     this.delimiter = " ";
   }
 
   private JWTAuthHandlerImpl(JWTAuthHandlerImpl base, List<String> scopes, String delimiter) {
     super(base.authProvider, Type.BEARER, base.realm);
+    Objects.requireNonNull(scopes, "scopes cannot be null");
     this.scopes = scopes;
+    Objects.requireNonNull(delimiter, "delimiter cannot be null");
     this.delimiter = delimiter;
   }
 
@@ -88,6 +92,7 @@ public class JWTAuthHandlerImpl extends HTTPAuthorizationHandler<JWTAuth> implem
 
   @Override
   public JWTAuthHandler withScope(String scope) {
+    Objects.requireNonNull(scope, "scope cannot be null");
     List<String> updatedScopes = new ArrayList<>(this.scopes);
     updatedScopes.add(scope);
     return new JWTAuthHandlerImpl(this, updatedScopes, delimiter);
@@ -95,12 +100,15 @@ public class JWTAuthHandlerImpl extends HTTPAuthorizationHandler<JWTAuth> implem
 
   @Override
   public JWTAuthHandler withScopes(List<String> scopes) {
+    Objects.requireNonNull(scopes, "scopes cannot be null");
     return new JWTAuthHandlerImpl(this, scopes, delimiter);
   }
 
   @Override
-  public JWTAuthHandler scopeDelimiter(String delimeter) {
-    return new JWTAuthHandlerImpl(this, scopes, delimeter);
+  public JWTAuthHandler scopeDelimiter(String delimiter) {
+    Objects.requireNonNull(delimiter, "delimiter cannot be null");
+    this.delimiter = delimiter;
+    return this;
   }
 
   /**
@@ -115,6 +123,8 @@ public class JWTAuthHandlerImpl extends HTTPAuthorizationHandler<JWTAuth> implem
       return;
     }
     // the user is authenticated, however the user may not have all the required scopes
+    final List<String> scopes = getScopesOrSearchMetadata(this.scopes, ctx);
+
     if (scopes.size() > 0) {
       final JsonObject jwt = user.get("accessToken");
       if (jwt == null) {
diff --git a/vertx-web/src/main/java/io/vertx/ext/web/handler/impl/OAuth2AuthHandlerImpl.java b/vertx-web/src/main/java/io/vertx/ext/web/handler/impl/OAuth2AuthHandlerImpl.java
index 0fc83daf2..62c8bfc7f 100644
--- a/vertx-web/src/main/java/io/vertx/ext/web/handler/impl/OAuth2AuthHandlerImpl.java
+++ b/vertx-web/src/main/java/io/vertx/ext/web/handler/impl/OAuth2AuthHandlerImpl.java
@@ -61,7 +61,6 @@ public class OAuth2AuthHandlerImpl extends HTTPAuthorizationHandler<OAuth2Auth>
   private final MessageDigest sha256;
 
   private final List<String> scopes;
-  private final boolean openId;
   private JsonObject extraParams;
   private String prompt;
   private int pkce = -1;
@@ -92,8 +91,7 @@ public class OAuth2AuthHandlerImpl extends HTTPAuthorizationHandler<OAuth2Auth>
       this.callbackURL = null;
     }
     // scopes are empty by default
-    this.scopes = new ArrayList<>();
-    this.openId = false;
+    this.scopes = Collections.emptyList();
   }
 
   private OAuth2AuthHandlerImpl(OAuth2AuthHandlerImpl base, List<String> scopes) {
@@ -117,8 +115,8 @@ public class OAuth2AuthHandlerImpl extends HTTPAuthorizationHandler<OAuth2Auth>
     this.callback = base.callback;
     this.order = base.order;
     // apply the new scopes
+    Objects.requireNonNull(scopes, "scopes cannot be null");
     this.scopes = scopes;
-    this.openId = scopes != null && scopes.contains("openid");
   }
 
   @Override
@@ -150,13 +148,15 @@ public class OAuth2AuthHandlerImpl extends HTTPAuthorizationHandler<OAuth2Auth>
           // the redirect is processed as a failure to abort the chain
           String redirectUri = context.request().uri();
           try {
-            return Future.failedFuture(new HttpException(302, authURI(context.session(), redirectUri)));
+            return Future.failedFuture(new HttpException(302, authURI(context, redirectUri)));
           } catch (IllegalStateException e) {
             return Future.failedFuture(e);
           }
         }
       } else {
         // continue
+        final List<String> scopes = getScopesOrSearchMetadata(this.scopes, context);
+
         final Credentials credentials =
           scopes.size() > 0 ? new TokenCredentials(token).setScopes(scopes) : new TokenCredentials(token);
 
@@ -170,12 +170,14 @@ public class OAuth2AuthHandlerImpl extends HTTPAuthorizationHandler<OAuth2Auth>
     });
   }
 
-  private String authURI(Session session, String redirectURL) {
+  private String authURI(RoutingContext context, String redirectURL) {
 
     String state = null;
     String codeVerifier = null;
     String loginHint = null;
 
+    final Session session = context.session();
+
     if (session == null) {
       if (pkce > 0) {
         // we can only handle PKCE with a session
@@ -224,6 +226,8 @@ public class OAuth2AuthHandlerImpl extends HTTPAuthorizationHandler<OAuth2Auth>
       config.setRedirectUri(callbackURL.href());
     }
 
+    final List<String> scopes = getScopesOrSearchMetadata(this.scopes, context);
+
     if (scopes.size() > 0) {
       config.setScopes(scopes);
     }
@@ -248,6 +252,8 @@ public class OAuth2AuthHandlerImpl extends HTTPAuthorizationHandler<OAuth2Auth>
 
   @Override
   public OAuth2AuthHandler withScope(String scope) {
+    Objects.requireNonNull(scope, "scope cannot be null");
+
     List<String> updatedScopes = new ArrayList<>(this.scopes);
     updatedScopes.add(scope);
     return new OAuth2AuthHandlerImpl(this, updatedScopes);
@@ -255,6 +261,7 @@ public class OAuth2AuthHandlerImpl extends HTTPAuthorizationHandler<OAuth2Auth>
 
   @Override
   public OAuth2AuthHandler withScopes(List<String> scopes) {
+    Objects.requireNonNull(scopes, "scopes cannot be null");
     return new OAuth2AuthHandlerImpl(this, scopes);
   }
 
@@ -329,7 +336,9 @@ public class OAuth2AuthHandlerImpl extends HTTPAuthorizationHandler<OAuth2Auth>
   @Override
   public void postAuthentication(RoutingContext ctx) {
     // the user is authenticated, however the user may not have all the required scopes
-    if (scopes != null && scopes.size() > 0) {
+    final List<String> scopes = getScopesOrSearchMetadata(this.scopes, ctx);
+
+    if (scopes.size() > 0) {
       final User user = ctx.user().get();
       if (user == null) {
         // bad state
@@ -338,22 +347,26 @@ public class OAuth2AuthHandlerImpl extends HTTPAuthorizationHandler<OAuth2Auth>
       }
 
       if (user.principal().containsKey("scope")) {
-        final String scopes = user.principal().getString("scope");
-        if (scopes != null) {
+        final String userScopes = user.principal().getString("scope");
+        if (userScopes != null) {
           // user principal contains scope, a basic assertion is required to ensure that
           // the scopes present match the required ones
-          for (String scope : this.scopes) {
+
+          // check if openid is active
+          final boolean openId = userScopes.contains("openid");
+
+          for (String scope : scopes) {
             // do not assert openid scopes if openid is active
             if (openId && OPENID_SCOPES.contains(scope)) {
               continue;
             }
 
-            int idx = scopes.indexOf(scope);
+            int idx = userScopes.indexOf(scope);
             if (idx != -1) {
               // match, but is it valid?
               if (
-                (idx != 0 && scopes.charAt(idx -1) != ' ') ||
-                  (idx + scope.length() != scopes.length() && scopes.charAt(idx + scope.length()) != ' ')) {
+                (idx != 0 && userScopes.charAt(idx -1) != ' ') ||
+                  (idx + scope.length() != userScopes.length() && userScopes.charAt(idx + scope.length()) != ' ')) {
                 // invalid scope assignment
                 ctx.fail(403, new IllegalStateException("principal scope != handler scopes"));
                 return;
diff --git a/vertx-web/src/main/java/io/vertx/ext/web/handler/impl/ScopedAuthentication.java b/vertx-web/src/main/java/io/vertx/ext/web/handler/impl/ScopedAuthentication.java
index 853a74634..cf03a03ee 100644
--- a/vertx-web/src/main/java/io/vertx/ext/web/handler/impl/ScopedAuthentication.java
+++ b/vertx-web/src/main/java/io/vertx/ext/web/handler/impl/ScopedAuthentication.java
@@ -1,13 +1,18 @@
 package io.vertx.ext.web.handler.impl;
 
+import io.vertx.ext.web.Route;
+import io.vertx.ext.web.RoutingContext;
 import io.vertx.ext.web.handler.AuthenticationHandler;
 
+import java.util.Collections;
 import java.util.List;
+import java.util.Map;
 
 /**
  * Internal interface for scope aware Authentication handlers.
- * @author <a href="mailto:pmlopes@gmail.com">Paulo Lopes</a>
+ *
  * @param <SELF>
+ * @author <a href="mailto:pmlopes@gmail.com">Paulo Lopes</a>
  */
 public interface ScopedAuthentication<SELF extends AuthenticationHandler> {
 
@@ -28,4 +33,39 @@ public interface ScopedAuthentication<SELF extends AuthenticationHandler> {
    * @return new instance of this interface.
    */
   SELF withScopes(List<String> scopes);
+
+  /**
+   * Return the list of scopes provided as the 1st argument, unless the list is empty. In this case, the list of scopes
+   * is obtained from the routing context metadata if possible. In case the metadata is not available, the list of
+   * scopes is always an empty list.
+   */
+  default List<String> getScopesOrSearchMetadata(List<String> scopes, RoutingContext ctx) {
+    if (!scopes.isEmpty()) {
+      return scopes;
+    }
+
+    final Route currentRoute = ctx.currentRoute();
+
+    if (currentRoute == null) {
+      return Collections.emptyList();
+    }
+
+    final Object value = currentRoute
+      .metadata()
+      .get("scopes");
+
+    if (value == null) {
+      return Collections.emptyList();
+    }
+
+    if (value instanceof List) {
+      return (List<String>) value;
+    }
+
+    if (value instanceof String) {
+      return Collections.singletonList((String) value);
+    }
+
+    throw new IllegalStateException("Invalid type for scopes metadata: " + value.getClass().getName());
+  }
 }
diff --git a/vertx-web/src/test/java/io/vertx/ext/web/handler/JWTAuthHandlerTest.java b/vertx-web/src/test/java/io/vertx/ext/web/handler/JWTAuthHandlerTest.java
index 33069c484..fd688fa8d 100644
--- a/vertx-web/src/test/java/io/vertx/ext/web/handler/JWTAuthHandlerTest.java
+++ b/vertx-web/src/test/java/io/vertx/ext/web/handler/JWTAuthHandlerTest.java
@@ -160,4 +160,50 @@ public class JWTAuthHandlerTest extends WebTestBase {
 
     testRequest(HttpMethod.GET, "/", req -> req.putHeader("Authorization", "Bearer " + authProvider.generateToken(payloadB)), 200, "OK", null);
   }
+
+  @Test
+  public void testLoginWithScopesFromMetadata() throws Exception {
+
+    router.route()
+      .putMetadata("scopes", Arrays.asList("a", "b"))
+      .handler(JWTAuthHandler.create(authProvider))
+      .handler(RoutingContext::end);
+
+    // Payload as String list
+    final JsonObject payloadA = new JsonObject()
+      .put("sub", "Paulo")
+      .put("scope", String.join(" ", Arrays.asList("a", "b")));
+
+    testRequest(HttpMethod.GET, "/", req -> req.putHeader("Authorization", "Bearer " + authProvider.generateToken(payloadA)), 200, "OK", null);
+
+    // Payload as Array
+    final JsonObject payloadB = new JsonObject()
+      .put("sub", "Paulo")
+      .put("scope", new JsonArray().add("a").add("b"));
+
+    testRequest(HttpMethod.GET, "/", req -> req.putHeader("Authorization", "Bearer " + authProvider.generateToken(payloadB)), 200, "OK", null);
+  }
+
+  @Test
+  public void testLoginWithScopesFromMetadataSingle() throws Exception {
+
+    router.route()
+      .putMetadata("scopes", "a")
+      .handler(JWTAuthHandler.create(authProvider))
+      .handler(RoutingContext::end);
+
+    // Payload as String list
+    final JsonObject payloadA = new JsonObject()
+      .put("sub", "Paulo")
+      .put("scope", "a");
+
+    testRequest(HttpMethod.GET, "/", req -> req.putHeader("Authorization", "Bearer " + authProvider.generateToken(payloadA)), 200, "OK", null);
+
+    // Payload as Array
+    final JsonObject payloadB = new JsonObject()
+      .put("sub", "Paulo")
+      .put("scope", new JsonArray().add("a"));
+
+    testRequest(HttpMethod.GET, "/", req -> req.putHeader("Authorization", "Bearer " + authProvider.generateToken(payloadB)), 200, "OK", null);
+  }
 }
diff --git a/vertx-web/src/test/java/io/vertx/ext/web/handler/OAuth2AuthHandlerTest.java b/vertx-web/src/test/java/io/vertx/ext/web/handler/OAuth2AuthHandlerTest.java
index 443350172..5db674edf 100644
--- a/vertx-web/src/test/java/io/vertx/ext/web/handler/OAuth2AuthHandlerTest.java
+++ b/vertx-web/src/test/java/io/vertx/ext/web/handler/OAuth2AuthHandlerTest.java
@@ -241,6 +241,130 @@ public class OAuth2AuthHandlerTest extends WebTestBase {
     server.close();
   }
 
+  @Test
+  public void testAuthCodeFlowWithScopesFromMetadata() throws Exception {
+
+    // lets mock an oauth2 server using code auth code flow
+    OAuth2Auth oauth2 = OAuth2Auth.create(vertx, new OAuth2Options()
+      .setClientId("client-id")
+      .setClientSecret("client-secret")
+      .setSite("http://localhost:10000"));
+
+    final CountDownLatch latch = new CountDownLatch(1);
+
+    HttpServer server = vertx.createHttpServer().requestHandler(req -> {
+      if (req.method() == HttpMethod.POST && "/oauth/token".equals(req.path())) {
+        req.setExpectMultipart(true).bodyHandler(buffer -> req.response().putHeader("Content-Type", "application/json").end(fixture.encode()));
+      } else if (req.method() == HttpMethod.POST && "/oauth/revoke".equals(req.path())) {
+        req.setExpectMultipart(true).bodyHandler(buffer -> req.response().end());
+      } else {
+        req.response().setStatusCode(400).end();
+      }
+    });
+
+    server.listen(10000).onComplete(ready -> {
+      if (ready.failed()) {
+        throw new RuntimeException(ready.cause());
+      }
+      // ready
+      latch.countDown();
+    });
+
+    latch.await();
+
+    // create a oauth2 handler on our domain to the callback: "http://localhost:8080/callback"
+    OAuth2AuthHandler oauth2Handler = OAuth2AuthHandler
+      .create(vertx, oauth2, "http://localhost:8080/callback");
+
+    // setup the callback handler for receiving the callback
+    oauth2Handler.setupCallback(router.route("/callback"));
+
+    // protect everything under /protected
+    router.route("/protected/*")
+      .putMetadata("scopes", "read")
+      .handler(oauth2Handler);
+    // mount some handler under the protected zone
+    router.route("/protected/somepage").handler(rc -> {
+      assertNotNull(rc.user().get());
+      rc.response().end("Welcome to the protected resource!");
+    });
+
+
+    testRequest(HttpMethod.GET, "/protected/somepage", null, resp -> {
+      // in this case we should get a redirect
+      redirectURL = resp.getHeader("Location");
+      assertNotNull(redirectURL);
+    }, 302, "Found", null);
+
+    // fake the redirect
+    testRequest(HttpMethod.GET, "/callback?state=/protected/somepage&code=1", null, resp -> {
+    }, 200, "OK", "Welcome to the protected resource!");
+
+    server.close();
+  }
+
+  @Test
+  public void testAuthCodeFlowWithScopesFromMetadataNoMatch() throws Exception {
+
+    // lets mock an oauth2 server using code auth code flow
+    OAuth2Auth oauth2 = OAuth2Auth.create(vertx, new OAuth2Options()
+      .setClientId("client-id")
+      .setClientSecret("client-secret")
+      .setSite("http://localhost:10000"));
+
+    final CountDownLatch latch = new CountDownLatch(1);
+
+    HttpServer server = vertx.createHttpServer().requestHandler(req -> {
+      if (req.method() == HttpMethod.POST && "/oauth/token".equals(req.path())) {
+        req.setExpectMultipart(true).bodyHandler(buffer -> req.response().putHeader("Content-Type", "application/json").end(fixture.encode()));
+      } else if (req.method() == HttpMethod.POST && "/oauth/revoke".equals(req.path())) {
+        req.setExpectMultipart(true).bodyHandler(buffer -> req.response().end());
+      } else {
+        req.response().setStatusCode(400).end();
+      }
+    });
+
+    server.listen(10000).onComplete(ready -> {
+      if (ready.failed()) {
+        throw new RuntimeException(ready.cause());
+      }
+      // ready
+      latch.countDown();
+    });
+
+    latch.await();
+
+    // create a oauth2 handler on our domain to the callback: "http://localhost:8080/callback"
+    OAuth2AuthHandler oauth2Handler = OAuth2AuthHandler
+      .create(vertx, oauth2, "http://localhost:8080/callback");
+
+    // setup the callback handler for receiving the callback
+    oauth2Handler.setupCallback(router.route("/callback"));
+
+    // protect everything under /protected
+    router.route("/protected/*")
+      .putMetadata("scopes", "delete")
+      .handler(oauth2Handler);
+    // mount some handler under the protected zone
+    router.route("/protected/somepage").handler(rc -> {
+      assertNotNull(rc.user().get());
+      rc.response().end("Welcome to the protected resource!");
+    });
+
+
+    testRequest(HttpMethod.GET, "/protected/somepage", null, resp -> {
+      // in this case we should get a redirect
+      redirectURL = resp.getHeader("Location");
+      assertNotNull(redirectURL);
+    }, 302, "Found", null);
+
+    // fake the redirect
+    testRequest(HttpMethod.GET, "/callback?state=/protected/somepage&code=1", null, resp -> {
+    }, 403, "Forbidden", null);
+
+    server.close();
+  }
+
   @Test
   public void testAuthCodeFlowBypass() throws Exception {