Skip to content

Commit 3bfbb2f

Browse files
committed
add TokenValidable interface
1 parent 700d07e commit 3bfbb2f

File tree

6 files changed

+273
-22
lines changed

6 files changed

+273
-22
lines changed

gradle/dependencies.gradle

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ dependencies {
1111

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

14+
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
15+
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
16+
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
17+
implementation 'com.auth0:jwks-rsa:0.22.1'
18+
1419
api 'com.squareup.okhttp3:okhttp:4.12.0'
1520
api 'com.azure:azure-core:1.54.1'
1621

spotBugsExcludeFilter.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,4 +112,8 @@ xsi:schemaLocation="https://github.com/spotbugs/filter/3.0.0 https://raw.githubu
112112
<Bug pattern="DCN_NULLPOINTER_EXCEPTION" />
113113
<Class name="com.microsoft.graph.core.content.BatchResponseContentTest" />
114114
</Match>
115+
<Match>
116+
<Bug pattern="CT_CONSTRUCTOR_THROW" />
117+
<Class name="com.microsoft.graph.core.models.DiscoverUrlAdapter" />
118+
</Match>
115119
</FindBugsFilter>

src/main/java/com/microsoft/graph/core/models/DecryptableContent.java

Lines changed: 26 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
package com.microsoft.graph.core.models;
22

33
import java.io.ByteArrayInputStream;
4-
import java.nio.charset.Charset;
54
import java.nio.charset.StandardCharsets;
6-
import java.security.interfaces.RSAPrivateKey;
5+
import java.security.Key;
76
import java.util.Arrays;
87
import java.util.Base64;
98
import java.util.Objects;
@@ -21,6 +20,9 @@
2120

2221
import jakarta.annotation.Nonnull;
2322

23+
/**
24+
* DecryptableContent interface
25+
*/
2426
public interface DecryptableContent {
2527

2628
/**
@@ -81,14 +83,14 @@ public interface DecryptableContent {
8183
*
8284
* @param <T> Parsable type to return
8385
* @param decryptableContent instance of DecryptableContent
84-
* @param privateKeyProvider provides an RSA Private Key for the certificate provided when subscribing
86+
* @param certificateKeyProvider provides an RSA Private Key for the certificate provided when subscribing
8587
* @param factory ParsableFactory for the return type
86-
* @return
87-
* @throws Exception
88+
* @return decrypted resource data
89+
* @throws Exception if an error occurs while decrypting the data
8890
*/
89-
public static <T extends Parsable> T decrypt(@Nonnull final DecryptableContent decryptableContent, @Nonnull final PrivateKeyProvider privateKeyProvider, @Nonnull final ParsableFactory<T> factory) throws Exception {
90-
Objects.requireNonNull(privateKeyProvider);
91-
final String decryptedContent = decryptAsString(decryptableContent, privateKeyProvider);
91+
public static <T extends Parsable> T decrypt(@Nonnull final DecryptableContent decryptableContent, @Nonnull final CertificateKeyProvider certificateKeyProvider, @Nonnull final ParsableFactory<T> factory) throws Exception {
92+
Objects.requireNonNull(certificateKeyProvider);
93+
final String decryptedContent = decryptAsString(decryptableContent, certificateKeyProvider);
9294
final ParseNode rootParseNode = ParseNodeFactoryRegistry.defaultInstance.getParseNode(
9395
"application/json", new ByteArrayInputStream(decryptedContent.getBytes(StandardCharsets.UTF_8)));
9496
return rootParseNode.getObjectValue(factory);
@@ -99,14 +101,14 @@ public static <T extends Parsable> T decrypt(@Nonnull final DecryptableContent d
99101
* https://learn.microsoft.com/en-us/graph/change-notifications-with-resource-data?tabs=csharp#decrypting-resource-data-from-change-notifications
100102
*
101103
* @param content instance of DecryptableContent
102-
* @param privateKeyProvider provides an RSA Private Key for the certificate provided when subscribing
104+
* @param certificateKeyProvider provides an RSA Private Key for the certificate provided when subscribing
103105
* @return decrypted resource data
104-
* @throws Exception
106+
* @throws Exception if an error occurs while decrypting the data
105107
*/
106-
public static String decryptAsString(@Nonnull final DecryptableContent content, @Nonnull final PrivateKeyProvider privateKeyProvider) throws Exception {
107-
Objects.requireNonNull(privateKeyProvider);
108-
final RSAPrivateKey privateKey = privateKeyProvider.getCertificatePrivateKey(content.getEncryptionCertificateId(), content.getEncryptionCertificateThumbprint());
109-
final Cipher cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-1AndMGF1Padding");
108+
public static String decryptAsString(@Nonnull final DecryptableContent content, @Nonnull final CertificateKeyProvider certificateKeyProvider) throws Exception {
109+
Objects.requireNonNull(certificateKeyProvider);
110+
final Key privateKey = certificateKeyProvider.getCertificateKey(content.getEncryptionCertificateId(), content.getEncryptionCertificateThumbprint());
111+
final Cipher cipher = Cipher.getInstance("RSA/None/OAEPWithSHA1AndMGF1Padding");
110112
cipher.init(Cipher.DECRYPT_MODE, privateKey);
111113
final byte[] decryptedSymmetricKey = cipher.doFinal(Base64.getDecoder().decode(content.getDataKey()));
112114

@@ -126,10 +128,13 @@ public static String decryptAsString(@Nonnull final DecryptableContent content,
126128
* @param data Base-64 decoded resource data
127129
* @param key Decrypted symmetric key from DecryptableContent.getDataKey()
128130
* @return decrypted resource data
129-
* @throws Exception
131+
* @throws Exception if an error occurs while decrypting the data
130132
*/
131133
public static byte[] aesDecrypt(byte[] data, byte[] key) throws Exception {
132134
try {
135+
@SuppressWarnings("java:S3329")
136+
// Sonar warns that a random IV should be used for encryption
137+
// but we are decrypting here.
133138
final IvParameterSpec ivSpec = new IvParameterSpec(Arrays.copyOf(key, 16));
134139
final Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
135140
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key, "AES"), ivSpec);
@@ -140,17 +145,17 @@ public static byte[] aesDecrypt(byte[] data, byte[] key) throws Exception {
140145
}
141146

142147
/**
143-
* Provides an RSA Private Key for the certificate with the ID provided when creating the
148+
* Provides a private key for the certificate with the ID provided when creating the
144149
* subscription and the thumbprint.
145150
*/
146151
@FunctionalInterface
147-
public interface PrivateKeyProvider {
152+
public interface CertificateKeyProvider {
148153
/**
149-
* Returns the RSAPrivateKey for an X.509 certificate with the given id and thumbprint
154+
* Returns the private key for an X.509 certificate with the given id and thumbprint
150155
* @param certificateId certificate Id provided when subscribing
151-
* @param certtificateThumbprint certificate thumbprint
152-
* @return RSA private key used to sign the certificate
156+
* @param certificateThumbprint certificate thumbprint
157+
* @return Private key used to sign the certificate
153158
*/
154-
public RSAPrivateKey getCertificatePrivateKey(String certificateId, String certtificateThumbprint);
159+
public Key getCertificateKey(String certificateId, String certificateThumbprint);
155160
}
156161
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package com.microsoft.graph.core.models;
2+
3+
import java.net.MalformedURLException;
4+
import java.net.URI;
5+
import java.net.URISyntaxException;
6+
import java.security.Key;
7+
import java.util.Objects;
8+
9+
import org.slf4j.LoggerFactory;
10+
11+
import com.auth0.jwk.Jwk;
12+
import com.auth0.jwk.JwkProvider;
13+
import com.auth0.jwk.UrlJwkProvider;
14+
15+
import io.jsonwebtoken.JweHeader;
16+
import io.jsonwebtoken.JwsHeader;
17+
import io.jsonwebtoken.LocatorAdapter;
18+
import jakarta.annotation.Nonnull;
19+
20+
/**
21+
* DiscoverUrlAdapter class
22+
*/
23+
public class DiscoverUrlAdapter extends LocatorAdapter<Key> {
24+
25+
/**
26+
* Key store
27+
*/
28+
private final JwkProvider keyStore;
29+
30+
/**
31+
* Constructor
32+
* @param keyDiscoveryUrl the JWKS endpoint to use to retrieve signing keys
33+
* @throws URISyntaxException if uri is invalid
34+
* @throws MalformedURLException if url is invalid
35+
*/
36+
public DiscoverUrlAdapter(@Nonnull final String keyDiscoveryUrl)
37+
throws URISyntaxException, MalformedURLException {
38+
this.keyStore =
39+
new UrlJwkProvider(new URI(Objects.requireNonNull(keyDiscoveryUrl)).toURL());
40+
}
41+
42+
@Override
43+
protected Key locate(JwsHeader header) {
44+
Objects.requireNonNull(header);
45+
try {
46+
String keyId = header.getKeyId();
47+
Jwk publicKey = keyStore.get(keyId);
48+
return publicKey.getPublicKey();
49+
} catch (final Exception e) {
50+
throw new IllegalArgumentException("Could not locate key", e);
51+
}
52+
}
53+
54+
@Override
55+
protected Key locate(JweHeader header) {
56+
return null;
57+
}
58+
59+
}

src/main/java/com/microsoft/graph/core/models/EncryptableSubscription.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ public interface EncryptableSubscription {
2828
* Converts an X.509 Certificate object to Base-64 string and adds to the encryptableSubscription provided
2929
* @param subscription encryptable subscription
3030
* @param certificate X.509 Certificate
31-
* @throws CertificateEncodingException
31+
* @throws CertificateEncodingException if the certificate cannot be encoded
3232
*/
3333
public static void addPublicEncryptionCertificate(@Nonnull final EncryptableSubscription subscription, @Nonnull final X509Certificate certificate) throws CertificateEncodingException {
3434
Objects.requireNonNull(subscription);
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
package com.microsoft.graph.core.models;
2+
3+
import java.security.Key;
4+
import java.util.List;
5+
import java.util.Objects;
6+
import java.util.Set;
7+
import java.util.UUID;
8+
9+
import jakarta.annotation.Nonnull;
10+
import io.jsonwebtoken.Claims;
11+
import io.jsonwebtoken.Jws;
12+
import io.jsonwebtoken.Jwts;
13+
import io.jsonwebtoken.Locator;
14+
15+
/**
16+
* TokenValidable interface
17+
*/
18+
public interface TokenValidable<U extends DecryptableContent, T extends EncryptedContentBearer<U>> {
19+
20+
/**
21+
* Graph notification publisher. Ensures that a different app that isn't Microsoft Graph did not send the change notifications
22+
*/
23+
public static final String graphNotificationPublisher = "0bf30f3b-4a52-48df-9a82-234910c4a086";
24+
25+
/**
26+
* Sets collection of validation tokens
27+
* @param validationTokens tokens
28+
*/
29+
public void setValidationTokens(List<String> validationTokens);
30+
31+
/**
32+
* Returns validation tokens
33+
* @return list of tokens
34+
*/
35+
public List<String> getValidationTokens();
36+
37+
/**
38+
* Sets collection of encrypted token bearers
39+
* @param value collection of encrypted token bearers
40+
*/
41+
public void setValue(List<T> value);
42+
43+
/**
44+
* Get collection of encrypted token bearers
45+
* @return encrypted token bearers
46+
*/
47+
public List<T> getValue();
48+
49+
/**
50+
* Validates the tokens
51+
* @param <U> DecryptableContent
52+
* @param <T> EncryptedContentBearer
53+
* @param collection collection of encrypted token bearers
54+
* @param tenantIds tenant ids
55+
* @param appIds app ids
56+
* @param keyDiscoveryUrl the JWKS endpoint to use to retrieve signing keys
57+
* @return true if the tokens are valid
58+
* @throws IllegalArgumentException if one of the tokens are invalid
59+
*/
60+
public static <U extends DecryptableContent, T extends EncryptedContentBearer<U>>
61+
boolean areTokensValid(
62+
@Nonnull final TokenValidable<U,T> collection,
63+
@Nonnull final List<UUID> tenantIds,
64+
@Nonnull final List<UUID> appIds,
65+
@Nonnull final String keyDiscoveryUrl) {
66+
67+
Objects.requireNonNull(collection);
68+
Objects.requireNonNull(tenantIds);
69+
Objects.requireNonNull(appIds);
70+
Objects.requireNonNull(keyDiscoveryUrl);
71+
72+
if (collection.getValidationTokens().isEmpty()
73+
|| collection.getValue().stream().allMatch(x -> x.getEncryptedContent() == null)) {
74+
return true;
75+
}
76+
77+
if (tenantIds.isEmpty() || appIds.isEmpty()) {
78+
throw new IllegalArgumentException("tenantIds, appIds and issuer formats must be provided");
79+
}
80+
81+
for (final String token : collection.getValidationTokens()) {
82+
if (!isTokenValid(token, tenantIds, appIds, keyDiscoveryUrl)) {
83+
return false;
84+
}
85+
}
86+
return true;
87+
}
88+
89+
/**
90+
* Validates the tokens
91+
* @param <U> DecryptableContent
92+
* @param <T> EncryptedContentBearer
93+
* @param collection collection of encrypted token bearers
94+
* @param tenantIds tenant ids
95+
* @param appIds app ids
96+
* @return true if the tokens are valid
97+
*/
98+
public static <U extends DecryptableContent, T extends EncryptedContentBearer<U>>
99+
boolean areTokensValid(
100+
@Nonnull final TokenValidable<U,T> collection,
101+
@Nonnull final List<UUID> tenantIds,
102+
@Nonnull final List<UUID> appIds) {
103+
104+
final String keyDiscoveryUrl = "https://login.microsoftonline.com/common/discovery/keys";
105+
return areTokensValid(collection, tenantIds, appIds, keyDiscoveryUrl);
106+
}
107+
108+
/**
109+
* Validates the token
110+
* @param <U> DecryptableContent
111+
* @param <T> EncryptedContentBearer
112+
* @param token token
113+
* @param tenantIds tenant ids
114+
* @param appIds app ids
115+
* @param keyDiscoveryUrl the JWKS endpoint to use to retrieve signing keys
116+
* @return true if the token is valid
117+
* @throws IllegalArgumentException if the token is invalid
118+
*/
119+
public static <U extends DecryptableContent, T extends EncryptedContentBearer<U>>
120+
boolean isTokenValid(
121+
@Nonnull final String token,
122+
@Nonnull final List<UUID> tenantIds,
123+
@Nonnull final List<UUID> appIds,
124+
@Nonnull final String keyDiscoveryUrl) {
125+
126+
Objects.requireNonNull(token);
127+
Objects.requireNonNull(tenantIds);
128+
Objects.requireNonNull(appIds);
129+
Objects.requireNonNull(keyDiscoveryUrl);
130+
131+
if (tenantIds.isEmpty() || appIds.isEmpty()) {
132+
throw new IllegalArgumentException("tenantIds, appIds and issuer formats must be provided");
133+
}
134+
135+
try {
136+
Locator<Key> discoverUrlAdapter = new DiscoverUrlAdapter(keyDiscoveryUrl);
137+
// As part of this process, the signature is validated
138+
// This throws if the signature is invalid
139+
Jws<Claims> parsedToken = Jwts.parser().keyLocator(discoverUrlAdapter).build().parseSignedClaims(token);
140+
141+
Claims body = parsedToken.getPayload();
142+
143+
if (body.getExpiration().before(new java.util.Date())) {
144+
throw new IllegalArgumentException("Token is expired");
145+
}
146+
147+
String issuer = body.getIssuer();
148+
Set<String> audience = body.getAudience();
149+
150+
boolean isAudienceValid = false;
151+
for (final UUID appId : appIds) {
152+
if (audience.contains(appId.toString())) {
153+
isAudienceValid = true;
154+
break;
155+
}
156+
}
157+
158+
boolean isIssuerValid = false;
159+
for (final UUID tenantId : tenantIds) {
160+
if (issuer.contains(tenantId.toString())) {
161+
isIssuerValid = true;
162+
break;
163+
}
164+
}
165+
166+
if (body.get("azp", String.class) != graphNotificationPublisher) {
167+
throw new IllegalArgumentException("Invalid token publisher. Expected Graph notification publisher (azp): " + graphNotificationPublisher);
168+
}
169+
170+
return isAudienceValid && isIssuerValid;
171+
172+
} catch (final Exception e) {
173+
throw new IllegalArgumentException("Invalid token", e);
174+
}
175+
176+
}
177+
178+
}

0 commit comments

Comments
 (0)