Skip to content

Commit

Permalink
add TokenValidable interface
Browse files Browse the repository at this point in the history
  • Loading branch information
Ndiritu committed Feb 11, 2025
1 parent 700d07e commit 3bfbb2f
Show file tree
Hide file tree
Showing 6 changed files with 273 additions and 22 deletions.
5 changes: 5 additions & 0 deletions gradle/dependencies.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ dependencies {

implementation 'jakarta.annotation:jakarta.annotation-api:2.1.1'

implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
implementation 'com.auth0:jwks-rsa:0.22.1'

api 'com.squareup.okhttp3:okhttp:4.12.0'
api 'com.azure:azure-core:1.54.1'

Expand Down
4 changes: 4 additions & 0 deletions spotBugsExcludeFilter.xml
Original file line number Diff line number Diff line change
Expand Up @@ -112,4 +112,8 @@ xsi:schemaLocation="https://github.com/spotbugs/filter/3.0.0 https://raw.githubu
<Bug pattern="DCN_NULLPOINTER_EXCEPTION" />
<Class name="com.microsoft.graph.core.content.BatchResponseContentTest" />
</Match>
<Match>
<Bug pattern="CT_CONSTRUCTOR_THROW" />
<Class name="com.microsoft.graph.core.models.DiscoverUrlAdapter" />
</Match>
</FindBugsFilter>
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
package com.microsoft.graph.core.models;

import java.io.ByteArrayInputStream;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.security.interfaces.RSAPrivateKey;
import java.security.Key;
import java.util.Arrays;
import java.util.Base64;
import java.util.Objects;
Expand All @@ -21,6 +20,9 @@

import jakarta.annotation.Nonnull;

/**
* DecryptableContent interface
*/
public interface DecryptableContent {

/**
Expand Down Expand Up @@ -81,14 +83,14 @@ public interface DecryptableContent {
*
* @param <T> Parsable type to return
* @param decryptableContent instance of DecryptableContent
* @param privateKeyProvider provides an RSA Private Key for the certificate provided when subscribing
* @param certificateKeyProvider provides an RSA Private Key for the certificate provided when subscribing
* @param factory ParsableFactory for the return type
* @return
* @throws Exception
* @return decrypted resource data
* @throws Exception if an error occurs while decrypting the data
*/
public static <T extends Parsable> T decrypt(@Nonnull final DecryptableContent decryptableContent, @Nonnull final PrivateKeyProvider privateKeyProvider, @Nonnull final ParsableFactory<T> factory) throws Exception {
Objects.requireNonNull(privateKeyProvider);
final String decryptedContent = decryptAsString(decryptableContent, privateKeyProvider);
public static <T extends Parsable> T decrypt(@Nonnull final DecryptableContent decryptableContent, @Nonnull final CertificateKeyProvider certificateKeyProvider, @Nonnull final ParsableFactory<T> factory) throws Exception {
Objects.requireNonNull(certificateKeyProvider);
final String decryptedContent = decryptAsString(decryptableContent, certificateKeyProvider);
final ParseNode rootParseNode = ParseNodeFactoryRegistry.defaultInstance.getParseNode(
"application/json", new ByteArrayInputStream(decryptedContent.getBytes(StandardCharsets.UTF_8)));
return rootParseNode.getObjectValue(factory);
Expand All @@ -99,14 +101,14 @@ public static <T extends Parsable> T decrypt(@Nonnull final DecryptableContent d
* https://learn.microsoft.com/en-us/graph/change-notifications-with-resource-data?tabs=csharp#decrypting-resource-data-from-change-notifications
*
* @param content instance of DecryptableContent
* @param privateKeyProvider provides an RSA Private Key for the certificate provided when subscribing
* @param certificateKeyProvider provides an RSA Private Key for the certificate provided when subscribing
* @return decrypted resource data
* @throws Exception
* @throws Exception if an error occurs while decrypting the data
*/
public static String decryptAsString(@Nonnull final DecryptableContent content, @Nonnull final PrivateKeyProvider privateKeyProvider) throws Exception {
Objects.requireNonNull(privateKeyProvider);
final RSAPrivateKey privateKey = privateKeyProvider.getCertificatePrivateKey(content.getEncryptionCertificateId(), content.getEncryptionCertificateThumbprint());
final Cipher cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-1AndMGF1Padding");
public static String decryptAsString(@Nonnull final DecryptableContent content, @Nonnull final CertificateKeyProvider certificateKeyProvider) throws Exception {
Objects.requireNonNull(certificateKeyProvider);
final Key privateKey = certificateKeyProvider.getCertificateKey(content.getEncryptionCertificateId(), content.getEncryptionCertificateThumbprint());
final Cipher cipher = Cipher.getInstance("RSA/None/OAEPWithSHA1AndMGF1Padding");
cipher.init(Cipher.DECRYPT_MODE, privateKey);
final byte[] decryptedSymmetricKey = cipher.doFinal(Base64.getDecoder().decode(content.getDataKey()));

Expand All @@ -126,10 +128,13 @@ public static String decryptAsString(@Nonnull final DecryptableContent content,
* @param data Base-64 decoded resource data
* @param key Decrypted symmetric key from DecryptableContent.getDataKey()
* @return decrypted resource data
* @throws Exception
* @throws Exception if an error occurs while decrypting the data
*/
public static byte[] aesDecrypt(byte[] data, byte[] key) throws Exception {
try {
@SuppressWarnings("java:S3329")
// Sonar warns that a random IV should be used for encryption
// but we are decrypting here.
final IvParameterSpec ivSpec = new IvParameterSpec(Arrays.copyOf(key, 16));
final Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");

Check failure

Code scanning / SonarCloud

Encryption algorithms should be used with secure mode and padding scheme High

Use another cipher mode or disable padding. See more on SonarQube Cloud

Check failure

Code scanning / CodeQL

Use of a broken or risky cryptographic algorithm High

Cryptographic algorithm
AES/CBC/PKCS5Padding
is insecure. CBC mode with PKCS#5 or PKCS#7 padding is vulnerable to padding oracle attacks. Consider using GCM instead.
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key, "AES"), ivSpec);
Expand All @@ -140,17 +145,17 @@ public static byte[] aesDecrypt(byte[] data, byte[] key) throws Exception {
}

/**
* Provides an RSA Private Key for the certificate with the ID provided when creating the
* Provides a private key for the certificate with the ID provided when creating the
* subscription and the thumbprint.
*/
@FunctionalInterface
public interface PrivateKeyProvider {
public interface CertificateKeyProvider {
/**
* Returns the RSAPrivateKey for an X.509 certificate with the given id and thumbprint
* Returns the private key for an X.509 certificate with the given id and thumbprint
* @param certificateId certificate Id provided when subscribing
* @param certtificateThumbprint certificate thumbprint
* @return RSA private key used to sign the certificate
* @param certificateThumbprint certificate thumbprint
* @return Private key used to sign the certificate
*/
public RSAPrivateKey getCertificatePrivateKey(String certificateId, String certtificateThumbprint);
public Key getCertificateKey(String certificateId, String certificateThumbprint);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package com.microsoft.graph.core.models;

import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.security.Key;
import java.util.Objects;

import org.slf4j.LoggerFactory;

import com.auth0.jwk.Jwk;
import com.auth0.jwk.JwkProvider;
import com.auth0.jwk.UrlJwkProvider;

import io.jsonwebtoken.JweHeader;
import io.jsonwebtoken.JwsHeader;
import io.jsonwebtoken.LocatorAdapter;
import jakarta.annotation.Nonnull;

/**
* DiscoverUrlAdapter class
*/
public class DiscoverUrlAdapter extends LocatorAdapter<Key> {

/**
* Key store
*/
private final JwkProvider keyStore;

/**
* Constructor
* @param keyDiscoveryUrl the JWKS endpoint to use to retrieve signing keys
* @throws URISyntaxException if uri is invalid
* @throws MalformedURLException if url is invalid
*/
public DiscoverUrlAdapter(@Nonnull final String keyDiscoveryUrl)
throws URISyntaxException, MalformedURLException {
this.keyStore =
new UrlJwkProvider(new URI(Objects.requireNonNull(keyDiscoveryUrl)).toURL());
}

@Override
protected Key locate(JwsHeader header) {
Objects.requireNonNull(header);
try {
String keyId = header.getKeyId();
Jwk publicKey = keyStore.get(keyId);
return publicKey.getPublicKey();
} catch (final Exception e) {
throw new IllegalArgumentException("Could not locate key", e);
}
}

@Override
protected Key locate(JweHeader header) {
return null;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public interface EncryptableSubscription {
* Converts an X.509 Certificate object to Base-64 string and adds to the encryptableSubscription provided
* @param subscription encryptable subscription
* @param certificate X.509 Certificate
* @throws CertificateEncodingException
* @throws CertificateEncodingException if the certificate cannot be encoded
*/
public static void addPublicEncryptionCertificate(@Nonnull final EncryptableSubscription subscription, @Nonnull final X509Certificate certificate) throws CertificateEncodingException {
Objects.requireNonNull(subscription);
Expand Down
178 changes: 178 additions & 0 deletions src/main/java/com/microsoft/graph/core/models/TokenValidable.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
package com.microsoft.graph.core.models;

import java.security.Key;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;

import jakarta.annotation.Nonnull;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.Locator;

/**
* TokenValidable interface
*/
public interface TokenValidable<U extends DecryptableContent, T extends EncryptedContentBearer<U>> {

/**
* Graph notification publisher. Ensures that a different app that isn't Microsoft Graph did not send the change notifications
*/
public static final String graphNotificationPublisher = "0bf30f3b-4a52-48df-9a82-234910c4a086";

/**
* Sets collection of validation tokens
* @param validationTokens tokens
*/
public void setValidationTokens(List<String> validationTokens);

/**
* Returns validation tokens
* @return list of tokens
*/
public List<String> getValidationTokens();

/**
* Sets collection of encrypted token bearers
* @param value collection of encrypted token bearers
*/
public void setValue(List<T> value);

/**
* Get collection of encrypted token bearers
* @return encrypted token bearers
*/
public List<T> getValue();

/**
* Validates the tokens
* @param <U> DecryptableContent
* @param <T> EncryptedContentBearer
* @param collection collection of encrypted token bearers
* @param tenantIds tenant ids
* @param appIds app ids
* @param keyDiscoveryUrl the JWKS endpoint to use to retrieve signing keys
* @return true if the tokens are valid
* @throws IllegalArgumentException if one of the tokens are invalid
*/
public static <U extends DecryptableContent, T extends EncryptedContentBearer<U>>
boolean areTokensValid(
@Nonnull final TokenValidable<U,T> collection,
@Nonnull final List<UUID> tenantIds,
@Nonnull final List<UUID> appIds,
@Nonnull final String keyDiscoveryUrl) {

Objects.requireNonNull(collection);
Objects.requireNonNull(tenantIds);
Objects.requireNonNull(appIds);
Objects.requireNonNull(keyDiscoveryUrl);

if (collection.getValidationTokens().isEmpty()
|| collection.getValue().stream().allMatch(x -> x.getEncryptedContent() == null)) {
return true;
}

if (tenantIds.isEmpty() || appIds.isEmpty()) {
throw new IllegalArgumentException("tenantIds, appIds and issuer formats must be provided");
}

for (final String token : collection.getValidationTokens()) {
if (!isTokenValid(token, tenantIds, appIds, keyDiscoveryUrl)) {
return false;
}
}
return true;
}

/**
* Validates the tokens
* @param <U> DecryptableContent
* @param <T> EncryptedContentBearer
* @param collection collection of encrypted token bearers
* @param tenantIds tenant ids
* @param appIds app ids
* @return true if the tokens are valid
*/
public static <U extends DecryptableContent, T extends EncryptedContentBearer<U>>
boolean areTokensValid(
@Nonnull final TokenValidable<U,T> collection,
@Nonnull final List<UUID> tenantIds,
@Nonnull final List<UUID> appIds) {

final String keyDiscoveryUrl = "https://login.microsoftonline.com/common/discovery/keys";
return areTokensValid(collection, tenantIds, appIds, keyDiscoveryUrl);
}

/**
* Validates the token
* @param <U> DecryptableContent
* @param <T> EncryptedContentBearer
* @param token token
* @param tenantIds tenant ids
* @param appIds app ids
* @param keyDiscoveryUrl the JWKS endpoint to use to retrieve signing keys
* @return true if the token is valid
* @throws IllegalArgumentException if the token is invalid
*/
public static <U extends DecryptableContent, T extends EncryptedContentBearer<U>>
boolean isTokenValid(
@Nonnull final String token,
@Nonnull final List<UUID> tenantIds,
@Nonnull final List<UUID> appIds,
@Nonnull final String keyDiscoveryUrl) {

Objects.requireNonNull(token);
Objects.requireNonNull(tenantIds);
Objects.requireNonNull(appIds);
Objects.requireNonNull(keyDiscoveryUrl);

if (tenantIds.isEmpty() || appIds.isEmpty()) {
throw new IllegalArgumentException("tenantIds, appIds and issuer formats must be provided");
}

try {
Locator<Key> discoverUrlAdapter = new DiscoverUrlAdapter(keyDiscoveryUrl);
// As part of this process, the signature is validated
// This throws if the signature is invalid
Jws<Claims> parsedToken = Jwts.parser().keyLocator(discoverUrlAdapter).build().parseSignedClaims(token);

Claims body = parsedToken.getPayload();

if (body.getExpiration().before(new java.util.Date())) {
throw new IllegalArgumentException("Token is expired");
}

String issuer = body.getIssuer();
Set<String> audience = body.getAudience();

boolean isAudienceValid = false;
for (final UUID appId : appIds) {
if (audience.contains(appId.toString())) {
isAudienceValid = true;
break;
}
}

boolean isIssuerValid = false;
for (final UUID tenantId : tenantIds) {
if (issuer.contains(tenantId.toString())) {
isIssuerValid = true;
break;
}
}

if (body.get("azp", String.class) != graphNotificationPublisher) {
throw new IllegalArgumentException("Invalid token publisher. Expected Graph notification publisher (azp): " + graphNotificationPublisher);
}

return isAudienceValid && isIssuerValid;

} catch (final Exception e) {
throw new IllegalArgumentException("Invalid token", e);
}

}

}

0 comments on commit 3bfbb2f

Please sign in to comment.