diff --git a/token/src/main/java/com/networknt/oauth/token/PathHandlerProvider.java b/token/src/main/java/com/networknt/oauth/token/PathHandlerProvider.java index 6497f55a..5415f9a9 100644 --- a/token/src/main/java/com/networknt/oauth/token/PathHandlerProvider.java +++ b/token/src/main/java/com/networknt/oauth/token/PathHandlerProvider.java @@ -3,6 +3,7 @@ import com.networknt.health.HealthGetHandler; import com.networknt.info.ServerInfoGetHandler; import com.networknt.oauth.token.handler.Oauth2DerefGetHandler; +import com.networknt.oauth.token.handler.Oauth2SigningPostHandler; import com.networknt.oauth.token.handler.Oauth2TokenPostHandler; import com.networknt.handler.HandlerProvider; import io.undertow.Handlers; @@ -17,6 +18,7 @@ public HttpHandler getHandler() { .add(Methods.GET, "/server/info", new ServerInfoGetHandler()) .add(Methods.POST, "/oauth2/token", new Oauth2TokenPostHandler()) .add(Methods.GET, "/oauth2/deref/{token}", new Oauth2DerefGetHandler()) + .add(Methods.POST, "/oauth2/signing", new Oauth2SigningPostHandler()) ; return handler; } diff --git a/token/src/main/java/com/networknt/oauth/token/handler/Oauth2SigningPostHandler.java b/token/src/main/java/com/networknt/oauth/token/handler/Oauth2SigningPostHandler.java new file mode 100644 index 00000000..11a38d06 --- /dev/null +++ b/token/src/main/java/com/networknt/oauth/token/handler/Oauth2SigningPostHandler.java @@ -0,0 +1,117 @@ +package com.networknt.oauth.token.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.hazelcast.core.IMap; +import com.networknt.body.BodyHandler; +import com.networknt.config.Config; +import com.networknt.exception.ApiException; +import com.networknt.handler.LightHttpHandler; +import com.networknt.oauth.cache.CacheStartupHookProvider; +import com.networknt.oauth.cache.model.Client; +import com.networknt.oauth.token.helper.HttpAuth; +import com.networknt.security.JwtIssuer; +import com.networknt.status.Status; +import com.networknt.utility.HashUtil; +import io.undertow.server.HttpServerExchange; +import io.undertow.util.Headers; +import org.jose4j.jwt.JwtClaims; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.security.NoSuchAlgorithmException; +import java.security.spec.InvalidKeySpecException; +import java.util.HashMap; +import java.util.Map; + +public class Oauth2SigningPostHandler extends TokenAuditHandler implements LightHttpHandler { + private static final Logger logger = LoggerFactory.getLogger(Oauth2SigningPostHandler.class); + private static final String MISSING_AUTHORIZATION_HEADER = "ERR12002"; + private static final String INVALID_AUTHORIZATION_HEADER = "ERR12003"; + private static final String INVALID_BASIC_CREDENTIALS = "ERR12004"; + private static final String CLIENT_NOT_FOUND = "ERR12014"; + private static final String UNAUTHORIZED_CLIENT = "ERR12007"; + private static final String RUNTIME_EXCEPTION = "ERR10010"; + private static final String GENERIC_EXCEPTION = "ERR10014"; + + @Override + public void handleRequest(HttpServerExchange exchange) throws Exception { + ObjectMapper mapper = Config.getInstance().getMapper(); + exchange.getResponseHeaders().put(Headers.CONTENT_TYPE, "application/json"); + // check authorization header for basic authentication + Client client = authenticateClient(exchange); + if(client != null) { + String jwt; + Map body = (Map)exchange.getAttachment(BodyHandler.REQUEST_BODY); + SignRequest sr = Config.getInstance().getMapper().convertValue(body, SignRequest.class); + int expires = sr.getExpires(); + try { + // assume that the custom_claim is in format of json map string. + Map customClaim = sr.getPayload(); + jwt = JwtIssuer.getJwt(mockCcClaims(client.getClientId(), expires, customClaim)); + } catch (Exception e) { + logger.error("Exception:", e); + throw new ApiException(new Status(GENERIC_EXCEPTION, e.getMessage())); + } + Map resMap = new HashMap<>(); + resMap.put("access_token", jwt); + resMap.put("token_type", "bearer"); + resMap.put("expires_in", expires); + exchange.getResponseSender().send(mapper.writeValueAsString(resMap)); + } + processAudit(exchange); + } + + private Client authenticateClient(HttpServerExchange exchange) throws ApiException { + HttpAuth httpAuth = new HttpAuth(exchange); + + String clientId; + String clientSecret; + if(!httpAuth.isHeaderAvailable()) { + throw new ApiException(new Status(MISSING_AUTHORIZATION_HEADER)); + } else { + clientId = httpAuth.getClientId(); + clientSecret = httpAuth.getClientSecret(); + } + + if(clientId == null || clientId.trim().isEmpty() || clientSecret == null || clientSecret.trim().isEmpty()) { + if(httpAuth.isInvalidCredentials()) { + throw new ApiException(new Status(INVALID_BASIC_CREDENTIALS, httpAuth.getCredentials())); + } else { + throw new ApiException(new Status(INVALID_AUTHORIZATION_HEADER, httpAuth.getAuth())); + } + } + + return validateClientSecret(clientId, clientSecret); + } + + private Client validateClientSecret(String clientId, String clientSecret) throws ApiException { + IMap clients = CacheStartupHookProvider.hz.getMap("clients"); + Client client = clients.get(clientId); + if(client == null) { + throw new ApiException(new Status(CLIENT_NOT_FOUND, clientId)); + } else { + try { + if(HashUtil.validatePassword(clientSecret.toCharArray(), client.getClientSecret())) { + return client; + } else { + throw new ApiException(new Status(UNAUTHORIZED_CLIENT)); + } + } catch ( NoSuchAlgorithmException | InvalidKeySpecException e) { + logger.error("Exception:", e); + throw new ApiException(new Status(RUNTIME_EXCEPTION)); + } + } + } + + private JwtClaims mockCcClaims(String clientId, Integer expiresIn, Map formMap) { + JwtClaims claims = JwtIssuer.getJwtClaimsWithExpiresIn(expiresIn); + claims.setClaim("client_id", clientId); + if(formMap != null) { + for(Map.Entry entry : formMap.entrySet()) { + claims.setClaim(entry.getKey(), entry.getValue()); + } + } + return claims; + } + +} diff --git a/token/src/main/java/com/networknt/oauth/token/handler/SignRequest.java b/token/src/main/java/com/networknt/oauth/token/handler/SignRequest.java new file mode 100644 index 00000000..37a799eb --- /dev/null +++ b/token/src/main/java/com/networknt/oauth/token/handler/SignRequest.java @@ -0,0 +1,24 @@ +package com.networknt.oauth.token.handler; + +import java.util.Map; + +public class SignRequest { + int expires; + Map payload; + + public int getExpires() { + return expires; + } + + public void setExpires(int expires) { + this.expires = expires; + } + + public Map getPayload() { + return payload; + } + + public void setPayload(Map payload) { + this.payload = payload; + } +} diff --git a/token/src/test/java/com/networknt/oauth/token/handler/Oauth2SigningPostHandlerTest.java b/token/src/test/java/com/networknt/oauth/token/handler/Oauth2SigningPostHandlerTest.java new file mode 100644 index 00000000..8d77beaa --- /dev/null +++ b/token/src/test/java/com/networknt/oauth/token/handler/Oauth2SigningPostHandlerTest.java @@ -0,0 +1,301 @@ +package com.networknt.oauth.token.handler; + +import com.networknt.client.Http2Client; +import com.networknt.config.Config; +import com.networknt.exception.ClientException; +import com.networknt.status.Status; +import io.undertow.UndertowOptions; +import io.undertow.client.ClientConnection; +import io.undertow.client.ClientRequest; +import io.undertow.client.ClientResponse; +import io.undertow.util.Headers; +import io.undertow.util.Methods; +import org.apache.commons.codec.binary.Base64; +import org.junit.Assert; +import org.junit.ClassRule; +import org.junit.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.xnio.IoUtils; +import org.xnio.OptionMap; + +import java.net.URI; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static java.nio.charset.StandardCharsets.UTF_8; + +public class Oauth2SigningPostHandlerTest { + @ClassRule + public static TestServer server = TestServer.getInstance(); + + private static final Logger logger = LoggerFactory.getLogger(Oauth2SigningPostHandlerTest.class); + + private static String encodeCredentials(String clientId, String clientSecret) { + String cred; + if(clientSecret != null) { + cred = clientId + ":" + clientSecret; + } else { + cred = clientId; + } + String encodedValue; + byte[] encodedBytes = Base64.encodeBase64(cred.getBytes(UTF_8)); + encodedValue = new String(encodedBytes, UTF_8); + return encodedValue; + } + + @Test + public void testSigningToken() throws Exception { + Map jsonMap = new HashMap<>(); + jsonMap.put("expires", 300); + Map payload = new HashMap<>(); + payload.put("key1", "value1"); + payload.put("key2", 12); + payload.put("key3", false); + jsonMap.put("payload", payload); + String s = Config.getInstance().getMapper().writeValueAsString(jsonMap); + + final AtomicReference reference = new AtomicReference<>(); + final Http2Client client = Http2Client.getInstance(); + final CountDownLatch latch = new CountDownLatch(1); + final ClientConnection connection; + try { + connection = client.connect(new URI("https://localhost:6882"), Http2Client.WORKER, Http2Client.SSL, Http2Client.POOL, OptionMap.create(UndertowOptions.ENABLE_HTTP2, true)).get(); + } catch (Exception e) { + throw new ClientException(e); + } + + try { + connection.getIoThread().execute(new Runnable() { + @Override + public void run() { + final ClientRequest request = new ClientRequest().setMethod(Methods.POST).setPath("/oauth2/signing"); + request.getRequestHeaders().put(Headers.HOST, "localhost"); + request.getRequestHeaders().put(Headers.CONTENT_TYPE, "application/json"); + request.getRequestHeaders().put(Headers.AUTHORIZATION, "Basic " + encodeCredentials("f7d42348-c647-4efb-a52d-4c5787421e72", "f6h1FTI8Q3-7UScPZDzfXA")); + request.getRequestHeaders().put(Headers.TRANSFER_ENCODING, "chunked"); + connection.sendRequest(request, client.createClientCallback(reference, latch, s)); + } + }); + latch.await(10, TimeUnit.SECONDS); + int statusCode = reference.get().getResponseCode(); + String body = reference.get().getAttachment(Http2Client.RESPONSE_BODY); + Assert.assertEquals(200, statusCode); + logger.debug("response body = " + body); + Assert.assertTrue(body.indexOf("access_token") > 0); + } catch (Exception e) { + logger.error("IOException: ", e); + throw new ClientException(e); + } finally { + IoUtils.safeClose(connection); + } + } + + @Test + public void testHierarchicalObject() throws Exception { + Map jsonMap = new HashMap<>(); + jsonMap.put("expires", 300); + Map payload = new HashMap<>(); + Map key1Obj = new HashMap<>(); + key1Obj.put("key4", "value4"); + key1Obj.put("key5", 15); + payload.put("key1", key1Obj); + payload.put("key2", 12); + payload.put("key3", false); + jsonMap.put("payload", payload); + String s = Config.getInstance().getMapper().writeValueAsString(jsonMap); + + final AtomicReference reference = new AtomicReference<>(); + final Http2Client client = Http2Client.getInstance(); + final CountDownLatch latch = new CountDownLatch(1); + final ClientConnection connection; + try { + connection = client.connect(new URI("https://localhost:6882"), Http2Client.WORKER, Http2Client.SSL, Http2Client.POOL, OptionMap.create(UndertowOptions.ENABLE_HTTP2, true)).get(); + } catch (Exception e) { + throw new ClientException(e); + } + + try { + connection.getIoThread().execute(new Runnable() { + @Override + public void run() { + final ClientRequest request = new ClientRequest().setMethod(Methods.POST).setPath("/oauth2/signing"); + request.getRequestHeaders().put(Headers.HOST, "localhost"); + request.getRequestHeaders().put(Headers.CONTENT_TYPE, "application/json"); + request.getRequestHeaders().put(Headers.AUTHORIZATION, "Basic " + encodeCredentials("f7d42348-c647-4efb-a52d-4c5787421e72", "f6h1FTI8Q3-7UScPZDzfXA")); + request.getRequestHeaders().put(Headers.TRANSFER_ENCODING, "chunked"); + connection.sendRequest(request, client.createClientCallback(reference, latch, s)); + } + }); + latch.await(10, TimeUnit.SECONDS); + int statusCode = reference.get().getResponseCode(); + String body = reference.get().getAttachment(Http2Client.RESPONSE_BODY); + Assert.assertEquals(200, statusCode); + logger.debug("response body = " + body); + Assert.assertTrue(body.indexOf("access_token") > 0); + } catch (Exception e) { + logger.error("IOException: ", e); + throw new ClientException(e); + } finally { + IoUtils.safeClose(connection); + } + } + + @Test + public void testMissingAuthorization() throws Exception { + Map jsonMap = new HashMap<>(); + jsonMap.put("expires", 300); + Map payload = new HashMap<>(); + Map key1Obj = new HashMap<>(); + key1Obj.put("key4", "value4"); + key1Obj.put("key5", 15); + payload.put("key1", key1Obj); + payload.put("key2", 12); + payload.put("key3", false); + jsonMap.put("payload", payload); + String s = Config.getInstance().getMapper().writeValueAsString(jsonMap); + + final AtomicReference reference = new AtomicReference<>(); + final Http2Client client = Http2Client.getInstance(); + final CountDownLatch latch = new CountDownLatch(1); + final ClientConnection connection; + try { + connection = client.connect(new URI("https://localhost:6882"), Http2Client.WORKER, Http2Client.SSL, Http2Client.POOL, OptionMap.create(UndertowOptions.ENABLE_HTTP2, true)).get(); + } catch (Exception e) { + throw new ClientException(e); + } + + try { + connection.getIoThread().execute(new Runnable() { + @Override + public void run() { + final ClientRequest request = new ClientRequest().setMethod(Methods.POST).setPath("/oauth2/signing"); + request.getRequestHeaders().put(Headers.HOST, "localhost"); + request.getRequestHeaders().put(Headers.CONTENT_TYPE, "application/json"); + //request.getRequestHeaders().put(Headers.AUTHORIZATION, "Basic " + encodeCredentials("f7d42348-c647-4efb-a52d-4c5787421e72", "f6h1FTI8Q3-7UScPZDzfXA")); + request.getRequestHeaders().put(Headers.TRANSFER_ENCODING, "chunked"); + connection.sendRequest(request, client.createClientCallback(reference, latch, s)); + } + }); + latch.await(10, TimeUnit.SECONDS); + int statusCode = reference.get().getResponseCode(); + String body = reference.get().getAttachment(Http2Client.RESPONSE_BODY); + Assert.assertEquals(400, statusCode); + Status status = Config.getInstance().getMapper().readValue(body, Status.class); + Assert.assertNotNull(status); + Assert.assertEquals("ERR11017", status.getCode()); + } catch (Exception e) { + logger.error("IOException: ", e); + throw new ClientException(e); + } finally { + IoUtils.safeClose(connection); + } + } + + + @Test + public void testInvalidClientId() throws Exception { + Map jsonMap = new HashMap<>(); + jsonMap.put("expires", 300); + Map payload = new HashMap<>(); + Map key1Obj = new HashMap<>(); + key1Obj.put("key4", "value4"); + key1Obj.put("key5", 15); + payload.put("key1", key1Obj); + payload.put("key2", 12); + payload.put("key3", false); + jsonMap.put("payload", payload); + String s = Config.getInstance().getMapper().writeValueAsString(jsonMap); + + final AtomicReference reference = new AtomicReference<>(); + final Http2Client client = Http2Client.getInstance(); + final CountDownLatch latch = new CountDownLatch(1); + final ClientConnection connection; + try { + connection = client.connect(new URI("https://localhost:6882"), Http2Client.WORKER, Http2Client.SSL, Http2Client.POOL, OptionMap.create(UndertowOptions.ENABLE_HTTP2, true)).get(); + } catch (Exception e) { + throw new ClientException(e); + } + + try { + connection.getIoThread().execute(new Runnable() { + @Override + public void run() { + final ClientRequest request = new ClientRequest().setMethod(Methods.POST).setPath("/oauth2/signing"); + request.getRequestHeaders().put(Headers.HOST, "localhost"); + request.getRequestHeaders().put(Headers.CONTENT_TYPE, "application/json"); + request.getRequestHeaders().put(Headers.AUTHORIZATION, "Basic " + encodeCredentials("fake", "f6h1FTI8Q3-7UScPZDzfXA")); + request.getRequestHeaders().put(Headers.TRANSFER_ENCODING, "chunked"); + connection.sendRequest(request, client.createClientCallback(reference, latch, s)); + } + }); + latch.await(10, TimeUnit.SECONDS); + int statusCode = reference.get().getResponseCode(); + String body = reference.get().getAttachment(Http2Client.RESPONSE_BODY); + Assert.assertEquals(404, statusCode); + Status status = Config.getInstance().getMapper().readValue(body, Status.class); + Assert.assertNotNull(status); + Assert.assertEquals("ERR12014", status.getCode()); + } catch (Exception e) { + logger.error("IOException: ", e); + throw new ClientException(e); + } finally { + IoUtils.safeClose(connection); + } + } + + @Test + public void testInvalidClientSecret() throws Exception { + Map jsonMap = new HashMap<>(); + jsonMap.put("expires", 300); + Map payload = new HashMap<>(); + Map key1Obj = new HashMap<>(); + key1Obj.put("key4", "value4"); + key1Obj.put("key5", 15); + payload.put("key1", key1Obj); + payload.put("key2", 12); + payload.put("key3", false); + jsonMap.put("payload", payload); + String s = Config.getInstance().getMapper().writeValueAsString(jsonMap); + + final AtomicReference reference = new AtomicReference<>(); + final Http2Client client = Http2Client.getInstance(); + final CountDownLatch latch = new CountDownLatch(1); + final ClientConnection connection; + try { + connection = client.connect(new URI("https://localhost:6882"), Http2Client.WORKER, Http2Client.SSL, Http2Client.POOL, OptionMap.create(UndertowOptions.ENABLE_HTTP2, true)).get(); + } catch (Exception e) { + throw new ClientException(e); + } + + try { + connection.getIoThread().execute(new Runnable() { + @Override + public void run() { + final ClientRequest request = new ClientRequest().setMethod(Methods.POST).setPath("/oauth2/signing"); + request.getRequestHeaders().put(Headers.HOST, "localhost"); + request.getRequestHeaders().put(Headers.CONTENT_TYPE, "application/json"); + request.getRequestHeaders().put(Headers.AUTHORIZATION, "Basic " + encodeCredentials("f7d42348-c647-4efb-a52d-4c5787421e72", "fake")); + request.getRequestHeaders().put(Headers.TRANSFER_ENCODING, "chunked"); + connection.sendRequest(request, client.createClientCallback(reference, latch, s)); + } + }); + latch.await(10, TimeUnit.SECONDS); + int statusCode = reference.get().getResponseCode(); + String body = reference.get().getAttachment(Http2Client.RESPONSE_BODY); + Assert.assertEquals(401, statusCode); + Status status = Config.getInstance().getMapper().readValue(body, Status.class); + Assert.assertNotNull(status); + Assert.assertEquals("ERR12007", status.getCode()); + } catch (Exception e) { + logger.error("IOException: ", e); + throw new ClientException(e); + } finally { + IoUtils.safeClose(connection); + } + } + +} diff --git a/token/src/test/resources/config/swagger.json b/token/src/test/resources/config/swagger.json index c4eb0f95..39cdf00d 100644 --- a/token/src/test/resources/config/swagger.json +++ b/token/src/test/resources/config/swagger.json @@ -142,6 +142,55 @@ } } } + }, + "/oauth2/signing": { + "post": { + "description": "Sign a JSON object and return a JWT", + "operationId": "postSigning", + "parameters": [ + { + "name": "authorization", + "description": "encoded client_id and client_secret pair", + "in": "header", + "type": "string", + "required": true + }, + { + "name": "body", + "in": "body", + "description": "Signing request object", + "required": true, + "schema": { + "$ref": "#/definitions/SignRequest" + } + } + ], + "responses": { + "200": { + "description": "Successful Operation" + } + } + } + } + }, + "definitions": { + "SignRequest": { + "type": "object", + "required": [ + "expires", + "payload" + ], + "properties": { + "expires": { + "type": "integer", + "format": "int32", + "description": "expires in seconds" + }, + "payload": { + "type": "object", + "description": "payload that needs to be signed" + } + } } } } \ No newline at end of file