diff --git a/etor/src/main/java/gov/hhs/cdc/trustedintermediary/external/reportstream/ReportStreamOrderSender.java b/etor/src/main/java/gov/hhs/cdc/trustedintermediary/external/reportstream/ReportStreamOrderSender.java index bc6e5d1a2..b0461fbdf 100644 --- a/etor/src/main/java/gov/hhs/cdc/trustedintermediary/external/reportstream/ReportStreamOrderSender.java +++ b/etor/src/main/java/gov/hhs/cdc/trustedintermediary/external/reportstream/ReportStreamOrderSender.java @@ -37,7 +37,12 @@ public class ReportStreamOrderSender implements OrderSender { .map(urlPrefix -> urlPrefix.replace("https://", "").replace("http://", "")) .orElse(""); + private static final String OUR_PRIVATE_KEY_ID = + "trusted-intermediary-private-key-" + ApplicationContext.getEnvironment(); + private static final String CLIENT_NAME = "flexion.etor-service-sender"; + private static final Map RS_AUTH_API_HEADERS = + Map.of("Content-Type", "application/x-www-form-urlencoded"); private String rsTokenCache; @@ -131,42 +136,53 @@ protected String sendRequestBody(@Nonnull String json, @Nonnull String bearerTok protected String requestToken() throws UnableToSendOrderException { logger.logInfo("Requesting token from ReportStream"); - String senderToken = null; - String token = ""; - String body; - Map headers = Map.of("Content-Type", "application/x-www-form-urlencoded"); + String ourPrivateKey; + String token; + try { - senderToken = + ourPrivateKey = retrievePrivateKey(); + String senderToken = jwt.generateToken( CLIENT_NAME, CLIENT_NAME, CLIENT_NAME, RS_DOMAIN_NAME, 300, - retrievePrivateKey()); - body = composeRequestBody(senderToken); - String rsResponse = client.post(RS_AUTH_API_URL, headers, body); + ourPrivateKey); + String body = composeRequestBody(senderToken); + String rsResponse = client.post(RS_AUTH_API_URL, RS_AUTH_API_HEADERS, body); token = extractToken(rsResponse); } catch (Exception e) { throw new UnableToSendOrderException( "Error getting the API token from ReportStream", e); } + + // only cache our private key if we successfully authenticate to RS + cacheOurPrivateKeyIfNotCachedAlready(ourPrivateKey); + return token; } protected String retrievePrivateKey() throws SecretRetrievalException { - var senderPrivateKey = - "trusted-intermediary-private-key-" + ApplicationContext.getEnvironment(); - String key = this.keyCache.get(senderPrivateKey); + String key = keyCache.get(OUR_PRIVATE_KEY_ID); if (key != null) { return key; } - key = secrets.getKey(senderPrivateKey); - this.keyCache.put(senderPrivateKey, key); + key = secrets.getKey(OUR_PRIVATE_KEY_ID); + return key; } + void cacheOurPrivateKeyIfNotCachedAlready(String privateKey) { + String key = keyCache.get(OUR_PRIVATE_KEY_ID); + if (key != null) { + return; + } + + keyCache.put(OUR_PRIVATE_KEY_ID, privateKey); + } + protected String extractToken(String responseBody) throws FormatterProcessingException { var value = formatter.convertJsonToObject( diff --git a/etor/src/test/groovy/gov/hhs/cdc/trustedintermediary/external/reportstream/ReportStreamOrderSenderTest.groovy b/etor/src/test/groovy/gov/hhs/cdc/trustedintermediary/external/reportstream/ReportStreamOrderSenderTest.groovy index d5ccfc9d7..38e6c47cd 100644 --- a/etor/src/test/groovy/gov/hhs/cdc/trustedintermediary/external/reportstream/ReportStreamOrderSenderTest.groovy +++ b/etor/src/test/groovy/gov/hhs/cdc/trustedintermediary/external/reportstream/ReportStreamOrderSenderTest.groovy @@ -73,15 +73,100 @@ class ReportStreamOrderSenderTest extends Specification { TestApplicationContext.register(Secrets, mockSecrets) TestApplicationContext.register(Cache, mockCache) TestApplicationContext.injectRegisteredImplementations() + when: mockSecrets.getKey(_ as String) >> "Fake Azure Key" def actual = ReportStreamOrderSender.getInstance().requestToken() + then: 1 * mockAuthEngine.generateToken(_ as String, _ as String, _ as String, _ as String, 300, _ as String) >> "sender fake token" 1 * mockClient.post(_ as String, _ as Map, _ as String) >> """{"access_token":"${expected}", "token_type":"bearer"}""" actual == expected } + def "requestToken saves our private key only after successful call to RS"() { + given: + def mockSecrets = Mock(Secrets) + def mockCache = Mock(Cache) + def mockFormatter = Mock(Formatter) + + def fakeOurPrivateKey = "DogCow" // pragma: allowlist secret + mockSecrets.getKey(_ as String) >> fakeOurPrivateKey + mockFormatter.convertJsonToObject(_ , _) >> [access_token: "Moof!"] + + TestApplicationContext.register(AuthEngine, Mock(AuthEngine)) + TestApplicationContext.register(HttpClient, Mock(HttpClient)) + TestApplicationContext.register(Formatter, mockFormatter) + TestApplicationContext.register(Secrets, mockSecrets) + TestApplicationContext.register(Cache, mockCache) + + TestApplicationContext.injectRegisteredImplementations() + + when: + ReportStreamOrderSender.getInstance().requestToken() + + then: + 1 * mockCache.put(_ as String, fakeOurPrivateKey) + } + + def "requestToken doesn't cache our private key if RS auth call fails"() { + given: + def mockClient = Mock(HttpClient) + def mockCache = Mock(Cache) + def mockFormatter = Mock(Formatter) + + mockClient.post(_, _, _) >> { throw new HttpClientException("Fake failure", new NullPointerException()) } + + mockFormatter.convertJsonToObject(_ , _) >> [access_token: "Moof!"] + + TestApplicationContext.register(AuthEngine, Mock(AuthEngine)) + TestApplicationContext.register(HttpClient, mockClient) + TestApplicationContext.register(Formatter, mockFormatter) + TestApplicationContext.register(Secrets, Mock(Secrets)) + TestApplicationContext.register(Cache, mockCache) + + TestApplicationContext.injectRegisteredImplementations() + + when: + ReportStreamOrderSender.getInstance().requestToken() + + then: + thrown(UnableToSendOrderException) + 0 * mockCache.put(_ , _) + } + + def "cacheOurPrivateKeyIfNotCachedAlready doesn't cache when the key is already is cached"() { + given: + def mockCache = Mock(Cache) + mockCache.get(_ as String) >> "DogCow private key" + + TestApplicationContext.register(Cache, mockCache) + + TestApplicationContext.injectRegisteredImplementations() + + when: + ReportStreamOrderSender.getInstance().cacheOurPrivateKeyIfNotCachedAlready("Moof!") + + then: + 0 * mockCache.put(_, _) + } + + def "cacheOurPrivateKeyIfNotCachedAlready caches when the key isn't cached"() { + given: + def mockCache = Mock(Cache) + mockCache.get(_ as String) >> null + + TestApplicationContext.register(Cache, mockCache) + + TestApplicationContext.injectRegisteredImplementations() + + when: + ReportStreamOrderSender.getInstance().cacheOurPrivateKeyIfNotCachedAlready("Moof!") + + then: + 1 * mockCache.put(_, _) + } + def "extractToken works"() { given: TestApplicationContext.register(Formatter, Jackson.getInstance()) @@ -193,11 +278,9 @@ class ReportStreamOrderSenderTest extends Specification { given: def mockSecret = Mock(Secrets) def expected = "New Fake Azure Key" - def keyCache = KeyCache.getInstance() - def key = "trusted-intermediary-private-key-local" mockSecret.getKey(_ as String) >> expected TestApplicationContext.register(Secrets, mockSecret) - TestApplicationContext.register(Cache, keyCache) + TestApplicationContext.register(Cache, KeyCache.getInstance()) TestApplicationContext.injectRegisteredImplementations() def rsOrderSender = ReportStreamOrderSender.getInstance() when: @@ -205,7 +288,6 @@ class ReportStreamOrderSenderTest extends Specification { then: actual == expected - keyCache.get(key) == expected } def "retrievePrivateKey works when cache is not empty" () {