Skip to content

Commit be4b899

Browse files
committed
verify token signature
this just checks that the token was signed using one of the keys listed by the key server. still need to validate the token contents.
1 parent 4c858d0 commit be4b899

File tree

5 files changed

+234
-31
lines changed

5 files changed

+234
-31
lines changed

pom.xml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,16 @@
226226
<version>${junit-5.version}</version>
227227
<scope>test</scope>
228228
</dependency>
229+
<dependency>
230+
<groupId>com.auth0</groupId>
231+
<artifactId>java-jwt</artifactId>
232+
<version>4.5.0</version>
233+
</dependency>
234+
<dependency>
235+
<groupId>com.auth0</groupId>
236+
<artifactId>jwks-rsa</artifactId>
237+
<version>0.22.1</version>
238+
</dependency>
229239
<dependency>
230240
<groupId>org.testcontainers</groupId>
231241
<artifactId>testcontainers</artifactId>

src/main/java/uk/ac/cam/cl/dtg/segue/api/Constants.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,7 @@ public String toString() {
303303
public static final String MICROSOFT_SECRET = "MICROSOFT_SECRET";
304304
public static final String MICROSOFT_CLIENT_ID = "MICROSOFT_CLIENT_ID";
305305
public static final String MICROSOFT_TENANT_ID = "MICROSOFT_TENANT_ID";
306+
public static final String MICROSOFT_JWKS_URL = "MICROSOFT_JWKS_URL";
306307

307308
// Facebook properties
308309
public static final String FACEBOOK_SECRET = "FACEBOOK_SECRET";

src/main/java/uk/ac/cam/cl/dtg/segue/auth/MicrosoftAuthenticator.java

Lines changed: 45 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,14 @@
1515
*/
1616
package uk.ac.cam.cl.dtg.segue.auth;
1717

18+
import com.auth0.jwk.InvalidPublicKeyException;
19+
import com.auth0.jwk.JwkException;
20+
import com.auth0.jwk.JwkProvider;
21+
import com.auth0.jwk.UrlJwkProvider;
22+
import com.auth0.jwt.JWT;
23+
import com.auth0.jwt.algorithms.Algorithm;
24+
import com.auth0.jwt.interfaces.DecodedJWT;
1825
import com.google.api.client.auth.oauth2.AuthorizationCodeResponseUrl;
19-
import com.google.api.client.auth.openidconnect.IdToken;
20-
import com.google.api.client.json.gson.GsonFactory;
2126
import com.google.common.cache.Cache;
2227
import com.google.common.cache.CacheBuilder;
2328
import com.google.inject.Inject;
@@ -28,6 +33,7 @@
2833
import com.microsoft.aad.msal4j.ResponseMode;
2934
import com.microsoft.aad.msal4j.ConfidentialClientApplication;
3035
import com.microsoft.aad.msal4j.ClientCredentialFactory;
36+
3137
import uk.ac.cam.cl.dtg.isaac.dos.users.UserFromAuthProvider;
3238
import uk.ac.cam.cl.dtg.segue.api.Constants;
3339
import uk.ac.cam.cl.dtg.segue.auth.exceptions.AuthenticatorSecurityException;
@@ -36,17 +42,16 @@
3642
import java.math.BigInteger;
3743
import java.net.MalformedURLException;
3844
import java.net.URI;
45+
import java.net.URL;
3946
import java.security.SecureRandom;
40-
import java.util.Collections;
41-
import java.util.Objects;
42-
import java.util.UUID;
47+
import java.security.interfaces.RSAPublicKey;
48+
import java.util.*;
4349
import java.util.concurrent.TimeUnit;
4450

4551
public class MicrosoftAuthenticator implements IOAuth2Authenticator {
46-
private static final int CREDENTIAL_CACHE_TTL_MINUTES = 10;
47-
52+
static final int CREDENTIAL_CACHE_TTL_MINUTES = 10;
4853
// TODO: why do we cache idTokens? Why don't we just pass them around in code?
49-
private static final Cache<String, String> credentialStore = CacheBuilder
54+
static Cache<String, String> credentialStore = CacheBuilder
5055
.newBuilder()
5156
.expireAfterAccess(CREDENTIAL_CACHE_TTL_MINUTES, TimeUnit.MINUTES)
5257
.build();
@@ -56,16 +61,19 @@ public class MicrosoftAuthenticator implements IOAuth2Authenticator {
5661
private final String clientId;
5762
private final String tenantId;
5863
private final String clientSecret;
64+
private JwkProvider provider;
5965

6066
@Inject
6167
public MicrosoftAuthenticator(
6268
@Named(Constants.MICROSOFT_CLIENT_ID) final String clientId,
6369
@Named(Constants.MICROSOFT_TENANT_ID) final String tenantId,
64-
@Named(Constants.MICROSOFT_SECRET) final String clientSecret
65-
) {
70+
@Named(Constants.MICROSOFT_SECRET) final String clientSecret,
71+
@Named(Constants.MICROSOFT_JWKS_URL) final String jwksUrl
72+
) throws MalformedURLException {
6673
this.clientId = clientId;
6774
this.tenantId = tenantId;
6875
this.clientSecret = clientSecret;
76+
provider = new UrlJwkProvider(new URL(jwksUrl));
6977
}
7078

7179
@Override
@@ -119,20 +127,14 @@ public String exchangeCode(String authorizationCode) throws CodeExchangeExceptio
119127
}
120128

121129
@Override
122-
public UserFromAuthProvider getUserInfo(String internalProviderReference) {
123-
String idTokenStr = Objects.requireNonNull(credentialStore.getIfPresent(internalProviderReference));
124-
try {
125-
var idToken = IdToken.parse(new GsonFactory(), idTokenStr);
126-
verifyIdToken(idToken);
127-
128-
// TODO: to support sign-ups, parse more info
129-
return new UserFromAuthProvider(
130-
(String) idToken.getPayload().get("sub"), null, null, (String) idToken.getPayload().get("email"),
131-
null, null, null, null, null, null
132-
);
133-
} catch (Exception e) {
134-
throw new RuntimeException(e);
135-
}
130+
public UserFromAuthProvider getUserInfo(String internalProviderReference) throws AuthenticatorSecurityException {
131+
String tokenStr = Objects.requireNonNull(credentialStore.getIfPresent(internalProviderReference));
132+
var token = parseAndVerifyToken(tokenStr);
133+
// TODO: to support sign-ups, parse more info
134+
return new UserFromAuthProvider(
135+
token.getSubject(), null, null, token.getClaim("email").asString(),
136+
null, null, null, null, null, null
137+
);
136138
}
137139

138140
private ConfidentialClientApplication client() {
@@ -146,13 +148,25 @@ private ConfidentialClientApplication client() {
146148
}
147149
}
148150

149-
private void verifyIdToken(IdToken token) throws AuthenticatorSecurityException {
150-
if (null == token) {
151-
throw new AuthenticatorSecurityException("No ID token was found.");
151+
private DecodedJWT parseAndVerifyToken (String tokenStr) throws AuthenticatorSecurityException {
152+
var token = JWT.decode(tokenStr);
153+
var keyId = token.getKeyId();
154+
if (null == keyId) {
155+
throw new AuthenticatorSecurityException("Token verification: NO_KEY_ID");
152156
}
153-
154-
// TODO: validate token signature against server's public key, as well as issuer, audience and expiration
155-
// since the token came directly from the server via HTTPS, we're pretty safe even without verification
156-
// (although, it's actually coming from our own cache by the time it gets here)
157+
try {
158+
var jwk = provider.get(keyId);
159+
var algorithm = Algorithm.RSA256((RSAPublicKey) jwk.getPublicKey());
160+
algorithm.verify(token);
161+
}
162+
catch (InvalidPublicKeyException e) {
163+
throw new AuthenticatorSecurityException("Token verification: INVALID_PUBLIC_KEY");
164+
}
165+
catch (JwkException e) {
166+
throw new AuthenticatorSecurityException(e.getMessage());
167+
}
168+
return token;
157169
}
158170
}
171+
172+

src/main/java/uk/ac/cam/cl/dtg/segue/configuration/SegueGuiceConfigurationModule.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,7 @@ private void configureAuthenticationProviders() {
368368
this.bindConstantToProperty(Constants.MICROSOFT_SECRET, globalProperties);
369369
this.bindConstantToProperty(Constants.MICROSOFT_CLIENT_ID, globalProperties);
370370
this.bindConstantToProperty(Constants.MICROSOFT_TENANT_ID, globalProperties);
371+
this.bindConstantToProperty(MICROSOFT_JWKS_URL, globalProperties);
371372
mapBinder.addBinding(AuthenticationProvider.MICROSOFT).to(MicrosoftAuthenticator.class);
372373

373374
// Facebook
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
package uk.ac.cam.cl.dtg.segue.auth;
2+
3+
import com.auth0.jwt.JWT;
4+
import com.auth0.jwt.JWTCreator;
5+
import com.auth0.jwt.algorithms.Algorithm;
6+
import com.auth0.jwt.exceptions.SignatureVerificationException;
7+
import com.google.common.cache.Cache;
8+
import com.google.common.cache.CacheBuilder;
9+
import jakarta.servlet.http.HttpServlet;
10+
import jakarta.servlet.http.HttpServletRequest;
11+
import jakarta.servlet.http.HttpServletResponse;
12+
import org.eclipse.jetty.server.Server;
13+
import org.eclipse.jetty.servlet.ServletHandler;
14+
import org.eclipse.jetty.servlet.ServletHolder;
15+
import org.json.JSONArray;
16+
import org.json.JSONObject;
17+
import org.junit.jupiter.api.Test;
18+
import uk.ac.cam.cl.dtg.isaac.dos.users.UserFromAuthProvider;
19+
import uk.ac.cam.cl.dtg.segue.auth.exceptions.AuthenticatorSecurityException;
20+
21+
import java.io.IOException;
22+
import java.security.KeyPair;
23+
import java.security.KeyPairGenerator;
24+
import java.security.NoSuchAlgorithmException;
25+
import java.security.interfaces.RSAPrivateKey;
26+
import java.security.interfaces.RSAPublicKey;
27+
import java.util.Base64;
28+
import java.util.concurrent.TimeUnit;
29+
import java.util.function.Function;
30+
31+
import static org.junit.jupiter.api.Assertions.*;
32+
import static uk.ac.cam.cl.dtg.segue.auth.Helpers.*;
33+
34+
class MicrosoftAuthenticatorTest {
35+
@Test
36+
void getUserInfo_validToken_returnsUserInformation() throws Exception {
37+
String token = signedToken(validSigningKey, t -> t
38+
.withKeyId(validSigningKey.id())
39+
.withPayload("{\"email\": \"[email protected]\"}"));
40+
var userInfo = testGetUserInfo(token);
41+
assertEquals("[email protected]", userInfo.getEmail());
42+
}
43+
44+
@Test
45+
void getUserInfo_tokenSignatureNoKeyId_throwsError() {
46+
String token = signedToken(validSigningKey, t -> t);
47+
var error = assertThrows(AuthenticatorSecurityException.class, () -> testGetUserInfo(token));
48+
assertEquals("Token verification: NO_KEY_ID", error.getMessage());
49+
}
50+
51+
@Test
52+
void getUserInfo_tokenSignatureKeyNotFound_throwsError() {
53+
String token = signedToken(validSigningKey, t -> t.withKeyId("no-such-key"));
54+
var error = assertThrows(AuthenticatorSecurityException.class, () -> testGetUserInfo(token));
55+
assertEquals("No key found in http://localhost:8888/keys with kid no-such-key", error.getMessage());
56+
}
57+
58+
@Test
59+
void getUserInfo_tokenSignatureMismatch_throwsError() {
60+
String token = signedToken(invalidSigningKey, t -> t.withKeyId(validSigningKey.id()));
61+
var error = assertThrows(SignatureVerificationException.class, () -> testGetUserInfo(token));
62+
assertEquals("The Token's Signature resulted invalid when verified using the Algorithm: SHA256withRSA", error.getMessage());
63+
}
64+
}
65+
66+
class Helpers {
67+
public static UserFromAuthProvider testGetUserInfo(String token) throws Exception {
68+
var keyServer = TestKeyServer.withKey(validSigningKey).start(8888);
69+
Cache<String, String> store = CacheBuilder.newBuilder()
70+
.expireAfterAccess(10, TimeUnit.MINUTES)
71+
.build();
72+
try {
73+
var subject = new MicrosoftAuthenticator(
74+
"", "", "", "http://localhost:8888/keys"
75+
) {{
76+
MicrosoftAuthenticator.credentialStore = store;
77+
}};
78+
store.put("the_internal_id", token);
79+
return subject.getUserInfo("the_internal_id");
80+
} finally {
81+
keyServer.stop();
82+
}
83+
}
84+
85+
public static String signedToken(TestKeyPair key, Function<JWTCreator.Builder, JWTCreator.Builder> fn) {
86+
var algorithm = Algorithm.RSA256(key.publicKey(), key.privateKey());
87+
var token = fn.apply(JWT.create());
88+
return token.sign(algorithm);
89+
}
90+
91+
public static TestKeyPair validSigningKey = new TestKeyPair();
92+
public static TestKeyPair invalidSigningKey = new TestKeyPair();
93+
}
94+
95+
class TestKeyServer {
96+
private Server server;
97+
private TestKeyPair key;
98+
99+
private TestKeyServer(TestKeyPair key) {
100+
this.key = key;
101+
}
102+
103+
public static TestKeyServer withKey(TestKeyPair key) {
104+
return new TestKeyServer(key);
105+
}
106+
107+
public TestKeyServer start(int port) throws Exception {
108+
server = new Server(port);
109+
ServletHandler handler = new ServletHandler();
110+
server.setHandler(handler);
111+
handler.addServletWithMapping(new ServletHolder(new HttpServlet() {
112+
@Override
113+
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
114+
throws IOException {
115+
resp.setContentType("application/json");
116+
resp.setStatus(HttpServletResponse.SC_OK);
117+
resp.getWriter().println(response());
118+
}
119+
}), "/keys");
120+
server.start();
121+
return this;
122+
}
123+
124+
public void stop() throws Exception {
125+
server.stop();
126+
}
127+
128+
private JSONObject response() {
129+
return new JSONObject().put(
130+
"keys", new JSONArray().put(
131+
new JSONObject()
132+
.put("kty", "RSA")
133+
.put("use", "sig")
134+
.put("kid", key.id())
135+
.put("n", key.modulus())
136+
.put("e", key.exponent())
137+
.put("cloud_instance_name", "microsoftonline.com")
138+
// Microsoft's response also contains an X.509 certificate, which we don't test here.
139+
// For an example, response, see: https://login.microsoftonline.com/common/discovery/keys
140+
// .put("x5t", key_id)
141+
// .put("x5c", new JSONArray("some_string"))
142+
)
143+
);
144+
}
145+
}
146+
147+
class TestKeyPair {
148+
private KeyPair keyPair;
149+
150+
public TestKeyPair() {
151+
try {
152+
keyPair = KeyPairGenerator.getInstance("RSA").generateKeyPair();
153+
} catch (NoSuchAlgorithmException e) {
154+
throw new RuntimeException(e);
155+
}
156+
}
157+
158+
public String id() {
159+
return String.format("key_id_%s", keyPair.getPublic().hashCode());
160+
}
161+
162+
public String modulus() {
163+
return Base64.getUrlEncoder().withoutPadding().encodeToString(publicKey().getModulus().toByteArray());
164+
}
165+
166+
public String exponent() {
167+
return Base64.getUrlEncoder().withoutPadding().encodeToString(publicKey().getPublicExponent().toByteArray());
168+
}
169+
170+
public RSAPublicKey publicKey() {
171+
return (RSAPublicKey) keyPair.getPublic();
172+
}
173+
174+
public RSAPrivateKey privateKey() {
175+
return (RSAPrivateKey) keyPair.getPrivate();
176+
}
177+
}

0 commit comments

Comments
 (0)