KNOX-3028 - add support for OAuth Token Exchange to KNOXTOKEN (#900)

* KNOX-3028 - add support for OAuth Token Exchange to KNOXTOKEN
diff --git a/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/OAuthResource.java b/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/OAuthResource.java
new file mode 100644
index 0000000..71cf28b
--- /dev/null
+++ b/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/OAuthResource.java
@@ -0,0 +1,133 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.knox.gateway.service.knoxtoken;
+
+import org.apache.knox.gateway.i18n.messages.MessagesFactory;
+import org.apache.knox.gateway.util.JsonUtils;
+
+import javax.inject.Singleton;
+import javax.ws.rs.GET;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import javax.ws.rs.Produces;
+import javax.ws.rs.core.Response;
+
+import java.time.Duration;
+import java.time.format.DateTimeParseException;
+import java.util.HashMap;
+
+import static javax.ws.rs.core.MediaType.APPLICATION_JSON;
+import static javax.ws.rs.core.MediaType.APPLICATION_XML;
+
+@Singleton
+@Path(OAuthResource.RESOURCE_PATH)
+public class OAuthResource extends TokenResource {
+    private static TokenServiceMessages log = MessagesFactory.get(TokenServiceMessages.class);
+    static final String RESOURCE_PATH = "/{serviceName:.*}/v1/{oauthSegment:(oauth|token)}{path:(/tokens)?}";
+    public static final String ISSUED_TOKEN_TYPE = "issued_token_type";
+    public static final String REFRESH_TOKEN = "refresh_token";
+    public static final String ISSUED_TOKEN_TYPE_ACCESS_TOKEN_VALUE = "urn:ietf:params:oauth:token-type:access_token";
+
+    @Override
+    @GET
+    @Produces({ APPLICATION_JSON, APPLICATION_XML })
+    public Response doGet() {
+        return super.doGet();
+    }
+
+    @Override
+    @POST
+    @Produces({ APPLICATION_JSON, APPLICATION_XML })
+    public Response doPost() {
+        return super.doPost();
+    }
+
+    @Override
+    public Response getAuthenticationToken() {
+
+        Response response = enforceClientCertIfRequired();
+        if (response != null) { return response; }
+
+        response = onlyAllowGroupsToBeAddedWhenEnabled();
+        if (response != null) { return response; }
+
+        UserContext context = buildUserContext(request);
+
+        response = enforceTokenLimitsAsRequired(context.userName);
+        if (response != null) { return response; }
+
+        TokenResponseContext resp = getTokenResponse(context);
+        // if the responseMap isn't null then the knoxtoken request was successful
+        // if not then there may have been an error and the underlying response
+        // builder will communicate those details
+        if (resp.responseMap != null) {
+            // let's get the subset of the KnoxToken Response needed for OAuth
+            String accessToken = resp.responseMap.accessToken;
+            String passcode = resp.responseMap.passcode;
+            long expires = (long) resp.responseMap.map.get(EXPIRES_IN);
+            String tokenType = (String) resp.responseMap.map.get(TOKEN_TYPE);
+
+            // build and return the expected OAuth response
+            final HashMap<String, Object> map = new HashMap<>();
+            map.put(ACCESS_TOKEN, accessToken);
+            map.put(TOKEN_TYPE, tokenType);
+            map.put(EXPIRES_IN, expires);
+            map.put(ISSUED_TOKEN_TYPE, ISSUED_TOKEN_TYPE_ACCESS_TOKEN_VALUE);
+            // let's use the passcode as the refresh token
+            map.put(REFRESH_TOKEN, passcode);
+            String jsonResponse = JsonUtils.renderAsJsonString(map);
+            return resp.responseBuilder.entity(jsonResponse).build();
+        }
+        // there was an error if we got here - let's surface it appropriately
+        // TODO: LJM we may need to translate certain errors into OAuth error messages
+        if (resp.responseStr != null) {
+            return resp.responseBuilder.entity(resp.responseStr).build();
+        }
+        else {
+            return resp.responseBuilder.build();
+        }
+    }
+
+    @Override
+    protected long getExpiry() {
+        long secs = tokenTTL/1000;
+
+        String lifetimeStr = request.getParameter(LIFESPAN);
+        if (lifetimeStr == null || lifetimeStr.isEmpty()) {
+            if (tokenTTL == -1) {
+                return -1;
+            }
+        }
+        else {
+            try {
+                long lifetime = Duration.parse(lifetimeStr).toMillis()/1000;
+                if (tokenTTL == -1) {
+                    // if TTL is set to -1 the topology owner grants unlimited lifetime therefore no additional check is needed on lifespan
+                    secs = lifetime;
+                } else if (lifetime <= tokenTTL/1000) {
+                    //this is expected due to security reasons: the configured TTL acts as an upper limit regardless of the supplied lifespan
+                    secs = lifetime;
+                }
+            }
+            catch (DateTimeParseException e) {
+                log.invalidLifetimeValue(lifetimeStr);
+            }
+        }
+        return secs;
+    }
+}
diff --git a/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/TokenResource.java b/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/TokenResource.java
index 8698ce8..b5703ae 100644
--- a/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/TokenResource.java
+++ b/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/TokenResource.java
@@ -108,15 +108,15 @@
 public class TokenResource {
   static final String LIFESPAN = "lifespan";
   static final String COMMENT = "comment";
-  private static final String EXPIRES_IN = "expires_in";
-  private static final String TOKEN_TYPE = "token_type";
-  private static final String ACCESS_TOKEN = "access_token";
-  private static final String TOKEN_ID = "token_id";
+  protected static final String EXPIRES_IN = "expires_in";
+  protected static final String TOKEN_TYPE = "token_type";
+  protected static final String ACCESS_TOKEN = "access_token";
+  protected static final String TOKEN_ID = "token_id";
   static final String PASSCODE = "passcode";
-  private static final String MANAGED_TOKEN = "managed";
+  protected static final String MANAGED_TOKEN = "managed";
   private static final String TARGET_URL = "target_url";
   private static final String ENDPOINT_PUBLIC_CERT = "endpoint_public_cert";
-  private static final String BEARER = "Bearer";
+  protected static final String BEARER = "Bearer";
   private static final String TOKEN_PARAM_PREFIX = "knox.token.";
   private static final String TOKEN_TTL_PARAM = TOKEN_PARAM_PREFIX + "ttl";
   private static final String TOKEN_TYPE_PARAM = TOKEN_PARAM_PREFIX + "type";
@@ -160,7 +160,7 @@
   public static final String KNOX_TOKEN_ISSUER = TOKEN_PARAM_PREFIX + "issuer";
   private static TokenServiceMessages log = MessagesFactory.get(TokenServiceMessages.class);
   private static final Gson GSON = new Gson();
-  private long tokenTTL = TOKEN_TTL_DEFAULT;
+  protected long tokenTTL = TOKEN_TTL_DEFAULT;
   private String tokenType;
   private String tokenTTLAsText;
   private List<String> targetAudiences = new ArrayList<>();
@@ -172,7 +172,7 @@
   private String endpointPublicCert;
 
   // Optional token store service
-  private TokenStateService tokenStateService;
+  protected TokenStateService tokenStateService;
   private TokenMAC tokenMAC;
   private final Map<String, String> tokenStateServiceStatusMap = new HashMap<>();
 
@@ -185,6 +185,7 @@
   private String tokenIssuer;
 
   enum UserLimitExceededAction {REMOVE_OLDEST, RETURN_ERROR};
+
   private UserLimitExceededAction userLimitExceededAction = UserLimitExceededAction.RETURN_ERROR;
 
   private List<String> allowedRenewers;
@@ -362,7 +363,7 @@
       final GatewayConfig config = (GatewayConfig) request.getServletContext().getAttribute(GatewayConfig.GATEWAY_CONFIG_ATTRIBUTE);
       final String configuredTokenStateServiceImpl = config.getServiceParameter(ServiceType.TOKEN_STATE_SERVICE.getShortName(), "impl");
       final String configuredTokenServiceName = StringUtils.isBlank(configuredTokenStateServiceImpl) ? ""
-          : configuredTokenStateServiceImpl.substring(configuredTokenStateServiceImpl.lastIndexOf('.') + 1);
+              : configuredTokenStateServiceImpl.substring(configuredTokenStateServiceImpl.lastIndexOf('.') + 1);
       final String actualTokenStateServiceImpl = tokenStateService.getClass().getCanonicalName();
       final String actualTokenServiceName = actualTokenStateServiceImpl.substring(actualTokenStateServiceImpl.lastIndexOf('.') + 1);
       tokenStateServiceStatusMap.put(TSS_STATUS_CONFIFURED_BACKEND, configuredTokenServiceName);
@@ -483,7 +484,7 @@
         tokens.addAll(userTokens);
       } else {
         userTokens.forEach(knoxToken -> {
-          for (Map.Entry<String,  List<String>> entry : metadataMap.entrySet()) {
+          for (Map.Entry<String, List<String>> entry : metadataMap.entrySet()) {
             if (entry.getValue().contains("*")) {
               // we should only filter tokens by metadata name
               if (knoxToken.hasMetadata(entry.getKey())) {
@@ -504,15 +505,15 @@
 
   @GET
   @Path(GET_TSS_STATUS_PATH)
-  @Produces({ APPLICATION_JSON })
+  @Produces({APPLICATION_JSON})
   public Response getTokenStateServiceStatus() {
     return Response.status(Response.Status.OK).entity(JsonUtils.renderAsJsonString(tokenStateServiceStatusMap)).build();
   }
 
   /**
    * @deprecated This method is no longer acceptable for token renewal. Please
-   *             use the '/knoxtoken/v2/api/token/renew' path; instead which is a
-   *             PUT HTTP request.
+   * use the '/knoxtoken/v2/api/token/renew' path; instead which is a
+   * PUT HTTP request.
    */
   @POST
   @Path(RENEW_PATH)
@@ -523,8 +524,8 @@
 
     long expiration = 0;
 
-    String          error       = "";
-    ErrorCode       errorCode   = ErrorCode.UNKNOWN;
+    String error = "";
+    ErrorCode errorCode = ErrorCode.UNKNOWN;
     Response.Status errorStatus = Response.Status.BAD_REQUEST;
 
     if (tokenStateService == null) {
@@ -532,8 +533,8 @@
       try {
         JWTToken jwt = new JWTToken(token);
         log.renewalDisabled(getTopologyName(),
-                            Tokens.getTokenDisplayText(token),
-                            Tokens.getTokenIDDisplayText(TokenUtils.getTokenId(jwt)));
+                Tokens.getTokenDisplayText(token),
+                Tokens.getTokenIDDisplayText(TokenUtils.getTokenId(jwt)));
         expiration = Long.parseLong(jwt.getExpires());
       } catch (ParseException e) {
         log.invalidToken(getTopologyName(), Tokens.getTokenDisplayText(token), e);
@@ -571,15 +572,15 @@
       }
     }
 
-    if(error.isEmpty()) {
-      resp =  Response.status(Response.Status.OK)
-                      .entity("{\n  \"renewed\": \"true\",\n  \"expires\": \"" + expiration + "\"\n}\n")
-                      .build();
+    if (error.isEmpty()) {
+      resp = Response.status(Response.Status.OK)
+              .entity("{\n  \"renewed\": \"true\",\n  \"expires\": \"" + expiration + "\"\n}\n")
+              .build();
     } else {
       log.badRenewalRequest(getTopologyName(), Tokens.getTokenDisplayText(token), error);
       resp = Response.status(errorStatus)
-                     .entity("{\n  \"renewed\": \"false\",\n  \"error\": \"" + error + "\",\n  \"code\": " + errorCode.toInt() + "\n}\n")
-                     .build();
+              .entity("{\n  \"renewed\": \"false\",\n  \"error\": \"" + error + "\",\n  \"code\": " + errorCode.toInt() + "\n}\n")
+              .build();
     }
 
     return resp;
@@ -603,8 +604,8 @@
 
   /**
    * @deprecated This method is no longer acceptable for token revocation. Please
-   *             use the '/knoxtoken/v2/api/token/revoke' path; instead which is a
-   *             DELETE HTTP request.
+   * use the '/knoxtoken/v2/api/token/revoke' path; instead which is a
+   * DELETE HTTP request.
    */
   @POST
   @Path(REVOKE_PATH)
@@ -613,8 +614,8 @@
   public Response revoke(String token) {
     Response resp;
 
-    String          error       = "";
-    ErrorCode       errorCode   = ErrorCode.UNKNOWN;
+    String error = "";
+    ErrorCode errorCode = ErrorCode.UNKNOWN;
     Response.Status errorStatus = Response.Status.BAD_REQUEST;
 
     if (tokenStateService == null) {
@@ -626,14 +627,14 @@
         final String tokenId = getTokenId(token);
         if (isKnoxSsoCookie(tokenId)) {
           errorStatus = Response.Status.FORBIDDEN;
-          error = "SSO cookie (" + Tokens.getTokenIDDisplayText(tokenId) + ") cannot not be revoked." ;
+          error = "SSO cookie (" + Tokens.getTokenIDDisplayText(tokenId) + ") cannot not be revoked.";
           errorCode = ErrorCode.UNAUTHORIZED;
         } else if (triesToRevokeOwnToken(tokenId, revoker) || allowedRenewers.contains(revoker)) {
           tokenStateService.revokeToken(tokenId);
           log.revokedToken(getTopologyName(),
-              Tokens.getTokenDisplayText(token),
-              Tokens.getTokenIDDisplayText(tokenId),
-              revoker);
+                  Tokens.getTokenDisplayText(token),
+                  Tokens.getTokenIDDisplayText(tokenId),
+                  revoker);
         } else {
           errorStatus = Response.Status.FORBIDDEN;
           error = "Caller (" + revoker + ") not authorized to revoke tokens.";
@@ -650,14 +651,14 @@
     }
 
     if (error.isEmpty()) {
-      resp =  Response.status(Response.Status.OK)
-                      .entity("{\n  \"revoked\": \"true\"\n}\n")
-                      .build();
+      resp = Response.status(Response.Status.OK)
+              .entity("{\n  \"revoked\": \"true\"\n}\n")
+              .build();
     } else {
       log.badRevocationRequest(getTopologyName(), Tokens.getTokenDisplayText(token), error);
       resp = Response.status(errorStatus)
-                     .entity("{\n  \"revoked\": \"false\",\n  \"error\": \"" + error + "\",\n  \"code\": " + errorCode.toInt() + "\n}\n")
-                     .build();
+              .entity("{\n  \"revoked\": \"false\",\n  \"error\": \"" + error + "\",\n  \"code\": " + errorCode.toInt() + "\n}\n")
+              .build();
     }
 
     return resp;
@@ -671,7 +672,7 @@
   private boolean triesToRevokeOwnToken(String tokenId, String revoker) throws UnknownTokenException {
     final TokenMetadata metadata = tokenStateService.getTokenMetadata(tokenId);
     final String tokenUserName = metadata == null ? "" : metadata.getUserName();
-    final String tokenCreatedBy =  metadata == null ? "" : metadata.getCreatedBy();
+    final String tokenCreatedBy = metadata == null ? "" : metadata.getCreatedBy();
     return StringUtils.isNotBlank(revoker) && (revoker.equals(tokenUserName) || revoker.equals(tokenCreatedBy));
   }
 
@@ -693,30 +694,30 @@
 
   @PUT
   @Path(ENABLE_PATH)
-  @Produces({ APPLICATION_JSON })
+  @Produces({APPLICATION_JSON})
   public Response enable(String tokenId) {
     return setTokenEnabledFlag(tokenId, true, false);
   }
 
   @PUT
   @Path(BATCH_ENABLE_PATH)
-  @Consumes({ APPLICATION_JSON })
-  @Produces({ APPLICATION_JSON })
+  @Consumes({APPLICATION_JSON})
+  @Produces({APPLICATION_JSON})
   public Response enableTokens(String tokenIds) {
     return setTokenEnabledFlags(tokenIds, true);
   }
 
   @PUT
   @Path(DISABLE_PATH)
-  @Produces({ APPLICATION_JSON })
+  @Produces({APPLICATION_JSON})
   public Response disable(String tokenId) {
     return setTokenEnabledFlag(tokenId, false, false);
   }
 
   @PUT
   @Path(BATCH_DISABLE_PATH)
-  @Consumes({ APPLICATION_JSON })
-  @Produces({ APPLICATION_JSON })
+  @Consumes({APPLICATION_JSON})
+  @Produces({APPLICATION_JSON})
   public Response disableTokens(String tokenIds) {
     return setTokenEnabledFlags(tokenIds, false);
   }
@@ -780,26 +781,97 @@
     return null;
   }
 
-  private Response getAuthenticationToken() {
-    if (clientCertRequired) {
-      X509Certificate cert = extractCertificate(request);
-      if (cert != null) {
-        if (!allowedDNs.contains(cert.getSubjectDN().getName().replaceAll("\\s+", ""))) {
-          return Response.status(Response.Status.FORBIDDEN)
-                         .entity("{ \"Unable to get token - untrusted client cert.\" }")
-                         .build();
-        }
+  protected Response getAuthenticationToken() {
+    Response response = enforceClientCertIfRequired();
+    if (response != null) { return response; }
+
+    response = onlyAllowGroupsToBeAddedWhenEnabled();
+    if (response != null) { return response; }
+
+    UserContext context = buildUserContext(request);
+
+    response = enforceTokenLimitsAsRequired(context.userName);
+    if (response != null) { return response; }
+
+    TokenResponseContext resp = getTokenResponse(context);
+    return resp.build();
+  }
+
+  protected TokenResponseContext getTokenResponse(UserContext context) {
+    TokenResponseContext response = null;
+    long expires = getExpiry();
+    setupPublicCertPEM();
+    String jku = getJku();
+    try
+    {
+      JWT token = getJWT(context.userName, expires, jku);
+      if (token != null) {
+        ResponseMap result = buildResponseMap(token, expires);
+        String jsonResponse = JsonUtils.renderAsJsonString(result.map);
+        persistTokenDetails(result, expires, context.userName, context.createdBy);
+
+        response = new TokenResponseContext(result, jsonResponse, Response.ok());
       } else {
-        return Response.status(Response.Status.FORBIDDEN)
-                       .entity("{ \"Unable to get token - client cert required.\" }")
-                       .build();
+        response = new TokenResponseContext(null, null, Response.serverError());
       }
+    } catch (TokenServiceException e) {
+      log.unableToIssueToken(e);
+      response = new TokenResponseContext(null
+              , "{ \"Unable to acquire token.\" }"
+              , Response.serverError());
     }
-    GatewayServices services = (GatewayServices) request.getServletContext()
+    return response;
+  }
+
+  protected static class TokenResponseContext {
+    public ResponseMap responseMap;
+    public String responseStr;
+    public Response.ResponseBuilder responseBuilder;
+
+    public TokenResponseContext(ResponseMap respMap, String resp, Response.ResponseBuilder builder) {
+      responseMap = respMap;
+      responseStr = resp;
+      responseBuilder = builder;
+    }
+
+    public Response build() {
+      Response response = null;
+      if (responseStr != null) {
+        response = responseBuilder.entity(responseStr).build();
+      } else {
+        response = responseBuilder.build();
+      }
+      return response;
+    }
+  }
+
+  protected GatewayServices getGatewayServices() {
+      return (GatewayServices) request.getServletContext()
         .getAttribute(GatewayServices.GATEWAY_SERVICES_ATTRIBUTE);
+  }
 
-    JWTokenAuthority ts = services.getService(ServiceType.TOKEN_SERVICE);
+  protected String getJku() {
+    String jku = null;
+    /* remove .../token and replace it with ..../jwks.json */
+    final int idx = request.getRequestURL().lastIndexOf("/");
+    if(idx > 1) {
+      jku = request.getRequestURL().substring(0, idx) + JWKSResource.JWKS_PATH;
+    }
+    return jku;
+  }
 
+  protected Response onlyAllowGroupsToBeAddedWhenEnabled() {
+    Response response = null;
+    if (shouldIncludeGroups() && !includeGroupsInTokenAllowed) {
+      response = Response
+              .status(Response.Status.BAD_REQUEST)
+              .entity("{\n  \"error\": \"Including group information in tokens is disabled\"\n}\n")
+              .build();
+    }
+    return response;
+  }
+
+  protected UserContext buildUserContext(HttpServletRequest request) {
     String userName = request.getUserPrincipal().getName();
     String createdBy = null;
     // checking the doAs user only makes sense if tokens are managed (this is where we store the userName/createdBy information)
@@ -816,9 +888,52 @@
         }
       }
     }
+    return new UserContext(userName, createdBy);
+  }
 
-    long expires = getExpiry();
+  protected static class UserContext {
+    public final String userName;
+    public final String createdBy;
 
+    public UserContext(String userName, String createdBy) {
+      this.userName = userName;
+      this.createdBy = createdBy;
+    }
+  }
+
+  protected Response enforceTokenLimitsAsRequired(String userName) {
+    Response response = null;
+    if (tokenStateService != null) {
+      if (tokenLimitPerUser != -1) { // if -1 => unlimited tokens for all users
+        final Collection<KnoxToken> allUserTokens = tokenStateService.getTokens(userName);
+        final Collection<KnoxToken> userTokens = new LinkedList<>();
+        allUserTokens.stream().forEach(token -> {
+          if(!token.getMetadata().isKnoxSsoCookie()) {
+            userTokens.add(token);
+          }
+        });
+        if (userTokens.size() >= tokenLimitPerUser) {
+          log.tokenLimitExceeded(userName);
+          if (UserLimitExceededAction.RETURN_ERROR == userLimitExceededAction) {
+            response = Response.status(Response.Status.FORBIDDEN).entity("{ \"Unable to get token - token limit exceeded.\" }").build();
+          } else {
+            // userTokens is an ordered collection (by issue time) -> the first element is the oldest one
+            final String oldestTokenId = userTokens.iterator().next().getTokenId();
+            log.generalInfoMessage(String.format(Locale.getDefault(), "Revoking %s's oldest token %s ...", userName, Tokens.getTokenIDDisplayText(oldestTokenId)));
+            final Response revocationResponse = revoke(oldestTokenId);
+            if (Response.Status.OK.getStatusCode() != revocationResponse.getStatus()) {
+              response = Response.status(Response.Status.fromStatusCode(revocationResponse.getStatus()))
+                  .entity("{\n  \"error\": \"An error occurred during the oldest token revocation of " + userName + " \"\n}\n").build();
+            }
+           }
+        }
+      }
+    }
+    return response;
+  }
+
+  protected void setupPublicCertPEM() {
+    GatewayServices services = getGatewayServices();
     if (endpointPublicCert == null) {
       // acquire PEM for gateway identity of this gateway instance
       KeystoreService ks = services.getService(ServiceType.KEYSTORE_SERVICE);
@@ -833,125 +948,115 @@
         }
       }
     }
+  }
 
-    String jku = null;
-    /* remove .../token and replace it with ..../jwks.json */
-    final int idx = request.getRequestURL().lastIndexOf("/");
-    if(idx > 1) {
-      jku = request.getRequestURL().substring(0, idx) + JWKSResource.JWKS_PATH;
-    }
-
-    if (tokenStateService != null) {
-      if (tokenLimitPerUser != -1) { // if -1 => unlimited tokens for all users
-        final Collection<KnoxToken> allUserTokens = tokenStateService.getTokens(userName);
-        final Collection<KnoxToken> userTokens = new LinkedList<>();
-        allUserTokens.stream().forEach(token -> {
-          if(!token.getMetadata().isKnoxSsoCookie()) {
-            userTokens.add(token);
-          }
-        });
-        if (userTokens.size() >= tokenLimitPerUser) {
-          log.tokenLimitExceeded(userName);
-          if (UserLimitExceededAction.RETURN_ERROR == userLimitExceededAction) {
-            return Response.status(Response.Status.FORBIDDEN).entity("{ \"Unable to get token - token limit exceeded.\" }").build();
-          } else {
-            // userTokens is an ordered collection (by issue time) -> the first element is the oldest one
-            final String oldestTokenId = userTokens.iterator().next().getTokenId();
-            log.generalInfoMessage(String.format(Locale.getDefault(), "Revoking %s's oldest token %s ...", userName, Tokens.getTokenIDDisplayText(oldestTokenId)));
-            final Response revocationResponse = revoke(oldestTokenId);
-            if (Response.Status.OK.getStatusCode() != revocationResponse.getStatus()) {
-              return Response.status(Response.Status.fromStatusCode(revocationResponse.getStatus()))
-                  .entity("{\n  \"error\": \"An error occurred during the oldest token revocation of " + userName + " \"\n}\n").build();
-            }
-           }
+  protected Response enforceClientCertIfRequired() {
+    Response response = null;
+    if (clientCertRequired) {
+      X509Certificate cert = extractCertificate(request);
+      if (cert != null) {
+        if (!allowedDNs.contains(cert.getSubjectDN().getName().replaceAll("\\s+", ""))) {
+          response = Response.status(Response.Status.FORBIDDEN)
+                         .entity("{ \"Unable to get token - untrusted client cert.\" }")
+                         .build();
         }
-      }
-    }
-
-    try {
-      final boolean managedToken = tokenStateService != null;
-      JWT token;
-      JWTokenAttributes jwtAttributes;
-      final JWTokenAttributesBuilder jwtAttributesBuilder = new JWTokenAttributesBuilder();
-      jwtAttributesBuilder
-          .setIssuer(tokenIssuer)
-          .setUserName(userName)
-          .setAlgorithm(signatureAlgorithm)
-          .setExpires(expires)
-          .setManaged(managedToken)
-          .setJku(jku)
-          .setType(tokenType);
-      if (!targetAudiences.isEmpty()) {
-        jwtAttributesBuilder.setAudiences(targetAudiences);
-      }
-      if (shouldIncludeGroups()) {
-        if (includeGroupsInTokenAllowed) {
-          jwtAttributesBuilder.setGroups(groups());
-        } else {
-          return Response
-                  .status(Response.Status.BAD_REQUEST)
-                  .entity("{\n  \"error\": \"Including group information in tokens is disabled\"\n}\n")
-                  .build();
-        }
-      }
-
-      jwtAttributes = jwtAttributesBuilder.build();
-      token = ts.issueToken(jwtAttributes);
-
-      if (token != null) {
-        String accessToken = token.toString();
-        String tokenId = TokenUtils.getTokenId(token);
-        log.issuedToken(getTopologyName(), Tokens.getTokenDisplayText(accessToken), Tokens.getTokenIDDisplayText(tokenId));
-
-        final HashMap<String, Object> map = new HashMap<>();
-        map.put(ACCESS_TOKEN, accessToken);
-        map.put(TOKEN_ID, tokenId);
-        map.put(MANAGED_TOKEN, String.valueOf(managedToken));
-        map.put(TOKEN_TYPE, BEARER);
-        map.put(EXPIRES_IN, expires);
-        if (tokenTargetUrl != null) {
-          map.put(TARGET_URL, tokenTargetUrl);
-        }
-        if (tokenClientDataMap != null) {
-          map.putAll(tokenClientDataMap);
-        }
-        if (endpointPublicCert != null) {
-          map.put(ENDPOINT_PUBLIC_CERT, endpointPublicCert);
-        }
-
-        final String passcode = UUID.randomUUID().toString();
-        if (tokenStateService != null && tokenStateService instanceof PersistentTokenStateService) {
-          map.put(PASSCODE, generatePasscodeField(tokenId, passcode));
-        }
-
-        String jsonResponse = JsonUtils.renderAsJsonString(map);
-
-        // Optional token store service persistence
-        if (tokenStateService != null) {
-          final long issueTime = System.currentTimeMillis();
-          tokenStateService.addToken(tokenId,
-                                     issueTime,
-                                     expires,
-                                     maxTokenLifetime.orElse(tokenStateService.getDefaultMaxLifetimeDuration()));
-          final String comment = request.getParameter(COMMENT);
-          final TokenMetadata tokenMetadata = new TokenMetadata(userName, StringUtils.isBlank(comment) ? null : comment);
-          tokenMetadata.setPasscode(tokenMAC.hash(tokenId, issueTime, userName, passcode));
-          addArbitraryTokenMetadata(tokenMetadata);
-          if (createdBy != null) {
-            tokenMetadata.setCreatedBy(createdBy);
-          }
-          tokenStateService.addMetadata(tokenId, tokenMetadata);
-          log.storedToken(getTopologyName(), Tokens.getTokenDisplayText(accessToken), Tokens.getTokenIDDisplayText(tokenId));
-        }
-
-        return Response.ok().entity(jsonResponse).build();
       } else {
-        return Response.serverError().build();
+        response = Response.status(Response.Status.FORBIDDEN)
+                       .entity("{ \"Unable to get token - client cert required.\" }")
+                       .build();
       }
-    } catch (TokenServiceException e) {
-      log.unableToIssueToken(e);
     }
-    return Response.ok().entity("{ \"Unable to acquire token.\" }").build();
+    return response;
+  }
+
+  protected void persistTokenDetails(ResponseMap result, long expires, String userName, String createdBy) {
+    // Optional token store service persistence
+    if (tokenStateService != null) {
+      final long issueTime = System.currentTimeMillis();
+      tokenStateService.addToken(result.tokenId,
+                                 issueTime,
+              expires,
+                                 maxTokenLifetime.orElse(tokenStateService.getDefaultMaxLifetimeDuration()));
+      final String comment = request.getParameter(COMMENT);
+      final TokenMetadata tokenMetadata = new TokenMetadata(userName, StringUtils.isBlank(comment) ? null : comment);
+      tokenMetadata.setPasscode(tokenMAC.hash(result.tokenId, issueTime, userName, result.passcode));
+      addArbitraryTokenMetadata(tokenMetadata);
+      if (createdBy != null) {
+        tokenMetadata.setCreatedBy(createdBy);
+      }
+      tokenStateService.addMetadata(result.tokenId, tokenMetadata);
+      log.storedToken(getTopologyName(), Tokens.getTokenDisplayText(result.accessToken), Tokens.getTokenIDDisplayText(result.tokenId));
+    }
+  }
+
+  protected ResponseMap buildResponseMap(JWT token, long expires) {
+    String accessToken = token.toString();
+    String tokenId = TokenUtils.getTokenId(token);
+    final boolean managedToken = tokenStateService != null;
+
+    log.issuedToken(getTopologyName(), Tokens.getTokenDisplayText(accessToken), Tokens.getTokenIDDisplayText(tokenId));
+
+    final Map<String, Object> map = new HashMap<>();
+    map.put(ACCESS_TOKEN, accessToken);
+    map.put(TOKEN_ID, tokenId);
+    map.put(MANAGED_TOKEN, String.valueOf(managedToken));
+    map.put(TOKEN_TYPE, BEARER);
+    map.put(EXPIRES_IN, expires);
+    if (tokenTargetUrl != null) {
+      map.put(TARGET_URL, tokenTargetUrl);
+    }
+    if (tokenClientDataMap != null) {
+      map.putAll(tokenClientDataMap);
+    }
+    if (endpointPublicCert != null) {
+      map.put(ENDPOINT_PUBLIC_CERT, endpointPublicCert);
+    }
+
+    final String passcode = UUID.randomUUID().toString();
+    if (tokenStateService != null && tokenStateService instanceof PersistentTokenStateService) {
+      map.put(PASSCODE, generatePasscodeField(tokenId, passcode));
+    }
+    return new ResponseMap(accessToken, tokenId, map, passcode);
+  }
+
+  protected static class ResponseMap {
+    public final String accessToken;
+    public final String tokenId;
+    public final Map<String, Object> map;
+    public final String passcode;
+
+    public ResponseMap(String accessToken, String tokenId, Map<String, Object> map, String passcode) {
+      this.accessToken = accessToken;
+      this.tokenId = tokenId;
+      this.map = map;
+      this.passcode = passcode;
+    }
+  }
+
+  protected JWT getJWT(String userName, long expires, String jku) throws TokenServiceException {
+    JWTokenAttributes jwtAttributes;
+    JWT token;
+    JWTokenAuthority ts = getGatewayServices().getService(ServiceType.TOKEN_SERVICE);
+    final boolean managedToken = tokenStateService != null;
+    final JWTokenAttributesBuilder jwtAttributesBuilder = new JWTokenAttributesBuilder();
+    jwtAttributesBuilder
+        .setIssuer(tokenIssuer)
+        .setUserName(userName)
+        .setAlgorithm(signatureAlgorithm)
+        .setExpires(expires)
+        .setManaged(managedToken)
+        .setJku(jku)
+        .setType(tokenType);
+    if (!targetAudiences.isEmpty()) {
+      jwtAttributesBuilder.setAudiences(targetAudiences);
+    }
+    if (shouldIncludeGroups()) {
+      jwtAttributesBuilder.setGroups(groups());
+    }
+
+    jwtAttributes = jwtAttributesBuilder.build();
+    token = ts.issueToken(jwtAttributes);
+    return token;
   }
 
   private boolean shouldIncludeGroups() {
@@ -995,7 +1100,7 @@
     }
   }
 
-  private long getExpiry() {
+  protected long getExpiry() {
     long expiry = 0L;
     long millis = tokenTTL;
 
@@ -1004,8 +1109,7 @@
       if (tokenTTL == -1) {
         return -1;
       }
-    }
-    else {
+    } else {
       try {
         long lifetime = Duration.parse(lifetimeStr).toMillis();
         if (tokenTTL == -1) {
diff --git a/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/deploy/TokenServiceDeploymentContributor.java b/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/deploy/TokenServiceDeploymentContributor.java
index b46ddeb..e80db1d 100644
--- a/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/deploy/TokenServiceDeploymentContributor.java
+++ b/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/deploy/TokenServiceDeploymentContributor.java
@@ -40,6 +40,6 @@
 
   @Override
   protected String[] getPatterns() {
-    return new String[]{ "knoxtoken/api/**?**" };
+    return new String[]{ "knoxtoken/api/**?**", "/oauth/v1/token/", "/**/v1/oauth/tokens/" };
   }
 }
diff --git a/gateway-service-knoxtoken/src/test/java/org/apache/knox/gateway/service/knoxtoken/TokenServiceResourceTest.java b/gateway-service-knoxtoken/src/test/java/org/apache/knox/gateway/service/knoxtoken/TokenServiceResourceTest.java
index 169650e..b072b8d 100644
--- a/gateway-service-knoxtoken/src/test/java/org/apache/knox/gateway/service/knoxtoken/TokenServiceResourceTest.java
+++ b/gateway-service-knoxtoken/src/test/java/org/apache/knox/gateway/service/knoxtoken/TokenServiceResourceTest.java
@@ -1346,6 +1346,45 @@
     }
   }
 
+  @Test
+  public void testOAuthTokenResponse() throws Exception {
+    Map<String, String> contextExpectations = new HashMap<>();
+    configureCommonExpectations(contextExpectations, Boolean.TRUE);
+
+    OAuthResource or = new OAuthResource();
+    or.request = request;
+    or.context = context;
+    or.init();
+
+    Response response = or.doPost();
+    assertEquals(200, response.getStatus());
+
+    String accessToken = getTagValue(response.getEntity().toString(), TokenResource.ACCESS_TOKEN);
+    assertNotNull(accessToken);
+    String expiresIn = getTagValue(response.getEntity().toString(), TokenResource.EXPIRES_IN);
+    // default value for TTL for KNOXTOKEN is 30 secs - OAuth response has this in secs
+    assertEquals("30", expiresIn);
+    // there is no passcode or token_id in OAuth responses
+    String passcode = getTagValue(response.getEntity().toString(), TokenResource.PASSCODE);
+    assertNull(passcode);
+    String tokenId = getTagValue(response.getEntity().toString(), TokenResource.TOKEN_ID);
+    assertNull(tokenId);
+    String tokenType = getTagValue(response.getEntity().toString(), TokenResource.TOKEN_TYPE);
+    assertEquals(TokenResource.BEARER, tokenType);
+    // oauth requires issued token type so we are hardcoding this
+    String issuedTokenType = getTagValue(response.getEntity().toString(), OAuthResource.ISSUED_TOKEN_TYPE);
+    assertEquals(OAuthResource.ISSUED_TOKEN_TYPE_ACCESS_TOKEN_VALUE, issuedTokenType);
+    // oauth credentials flow sometimes requires a refresh token even though they can just get a
+    // new access_token with the client_id and client_secret. Since this token service can't actually
+    // assume the credentials flow is being used even though it is most likely, we will include the
+    // passcode as the refresh token
+    String refreshToken = getTagValue(response.getEntity().toString(), OAuthResource.REFRESH_TOKEN);
+    assertNotNull(refreshToken);
+
+    Map<String, Object> payload = parseJSONResponse(JWTToken.parseToken(accessToken).getPayload());
+    assertFalse(payload.containsKey(KNOX_GROUPS_CLAIM));
+  }
+
   /**
    *
    * @param isTokenStateServerManaged true, if server-side token state management should be enabled; Otherwise, false or null.
@@ -1621,7 +1660,7 @@
     if (!token.contains(tagName)) {
       return null;
     }
-    String searchString = tagName + "\":";
+    String searchString = "\"" + tagName + "\":";
     String value = token.substring(token.indexOf(searchString) + searchString.length());
     if (value.startsWith("\"")) {
       value = value.substring(1);