Skip to content

Sign in with Microsoft #698

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 36 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
cf6a2f5
initial version, account linking confirmed working
barna-isaac May 28, 2025
4c858d0
get email address from token
barna-isaac May 28, 2025
be4b899
verify token signature
barna-isaac May 30, 2025
28c0757
validate `aud`, `iat`, `nbf`, `exp`
barna-isaac May 30, 2025
e854f0f
handle case when token cannot be found
barna-isaac May 30, 2025
965ae2d
handle case when token cannot be found
barna-isaac May 30, 2025
06d15db
validate issuer
barna-isaac May 30, 2025
e715def
allow tokens without `exp`, `iat`, `nbf`
barna-isaac Jun 2, 2025
11fcf00
see if a broken test breaks build
barna-isaac Jun 2, 2025
02c9653
add another broken test to see if it breaks build
barna-isaac Jun 2, 2025
884d1f7
use JUnit4 for MicrosoftAuthenticatorTest
barna-isaac Jun 2, 2025
5b56ab2
Revert "add another broken test to see if it breaks build"
barna-isaac Jun 2, 2025
95f1677
now that we know it runs tests, fix build
barna-isaac Jun 2, 2025
f1a0f10
fix warnings
barna-isaac Jun 2, 2025
681fe81
parse validate token fields
barna-isaac Jun 2, 2025
e98bed4
prepare issuer validation for multitenancy
barna-isaac Jun 4, 2025
0ddf71e
fix test case name
barna-isaac Jun 4, 2025
252485a
add integration tests for Microsoft authentication
barna-isaac Jun 5, 2025
7a33c5f
add integration tests for logging in
barna-isaac Jun 6, 2025
696684f
add integration tests for signing up
barna-isaac Jun 6, 2025
142e157
add integration tests for signing up without a name
barna-isaac Jun 6, 2025
712c0b8
add fallback for name claims
barna-isaac Jun 6, 2025
a88e88d
complete name validation
barna-isaac Jun 9, 2025
b391827
allow " " in names
barna-isaac Jun 9, 2025
7b6c098
pass redirectURL from config
barna-isaac Jun 9, 2025
4ef6394
use non-static cache
barna-isaac Jun 9, 2025
78c7795
cache jwks keys for an hour
barna-isaac Jun 9, 2025
54b49b2
handle authentication code errors
barna-isaac Jun 10, 2025
9d9d4ef
adjust test case name
barna-isaac Jun 10, 2025
8a0bdd4
Revert "adjust test case name"
barna-isaac Jun 10, 2025
e107116
run shared test suite
barna-isaac Jun 10, 2025
ef363aa
test `extractAuthCode`
barna-isaac Jun 10, 2025
af724ab
adjust error handling for getUserInfo
barna-isaac Jun 10, 2025
bfc2703
handle errors in constructor
barna-isaac Jun 10, 2025
2e32a1a
stop requesting the `offline_access` scope
barna-isaac Jun 11, 2025
984d278
stop exposing code exchange errors
barna-isaac Jun 11, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@
<artifactId>google-oauth-client</artifactId>
<version>${oauth-client.version}</version>
</dependency>

<dependency>
<groupId>com.google.oauth-client</groupId>
<artifactId>google-oauth-client-servlet</artifactId>
Expand Down Expand Up @@ -219,6 +220,16 @@
<version>${junit-5.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>4.5.0</version>
</dependency>
<dependency>
<groupId>com.auth0</groupId>
<artifactId>jwks-rsa</artifactId>
<version>0.22.1</version>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
Expand Down
7 changes: 7 additions & 0 deletions src/main/java/uk/ac/cam/cl/dtg/segue/api/Constants.java
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,13 @@ public String toString() {
public static final String GOOGLE_CALLBACK_URI = "GOOGLE_CALLBACK_URI";
public static final String GOOGLE_OAUTH_SCOPES = "GOOGLE_OAUTH_SCOPES";

// Microsoft properties
public static final String MICROSOFT_SECRET = "MICROSOFT_SECRET";
public static final String MICROSOFT_CLIENT_ID = "MICROSOFT_CLIENT_ID";
public static final String MICROSOFT_TENANT_ID = "MICROSOFT_TENANT_ID";
public static final String MICROSOFT_JWKS_URL = "MICROSOFT_JWKS_URL";
public static final String MICROSOFT_REDIRECT_URL = "MICROSOFT_REDIRECT_URL";

// Facebook properties
public static final String FACEBOOK_SECRET = "FACEBOOK_SECRET";
public static final String FACEBOOK_CLIENT_ID = "FACEBOOK_CLIENT_ID";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1861,7 +1861,7 @@ private RegisteredUser registerUserWithFederatedProvider(final AuthenticationPro
* @param email - the user email to validate.
* @return true if it meets the internal storage requirements, false if not.
*/
private static boolean isUserEmailValid(final String email) {
public static boolean isUserEmailValid(final String email) {
return email != null && !email.isEmpty()
&& email.matches(".*(@.+\\.[^.]+|-(facebook|google|twitter)$)");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,5 @@
* (ignoring case).
*/
public enum AuthenticationProvider {
GOOGLE, FACEBOOK, @Deprecated TWITTER, RAVEN, TEST, SEGUE, RASPBERRYPI
GOOGLE, FACEBOOK, @Deprecated TWITTER, RAVEN, TEST, SEGUE, RASPBERRYPI, MICROSOFT
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/
package uk.ac.cam.cl.dtg.segue.auth;

import uk.ac.cam.cl.dtg.segue.auth.exceptions.AuthenticationCodeException;
import uk.ac.cam.cl.dtg.segue.auth.exceptions.CodeExchangeException;

/**
Expand All @@ -32,7 +33,7 @@ public interface IOAuthAuthenticator extends IFederatedAuthenticator {
* containing the authorisation code
* @return the extracted authorisation code.
*/
String extractAuthCode(String url);
String extractAuthCode(String url) throws AuthenticationCodeException;

/**
* Step 3 of OAUTH - Exchange short term authorisation code for an access
Expand Down
260 changes: 260 additions & 0 deletions src/main/java/uk/ac/cam/cl/dtg/segue/auth/MicrosoftAuthenticator.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
/*
* Copyright 2025 Barna Magyarkuti
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package uk.ac.cam.cl.dtg.segue.auth;

import com.auth0.jwk.*;
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.IncorrectClaimException;
import com.auth0.jwt.exceptions.MissingClaimException;
import com.auth0.jwt.exceptions.SignatureVerificationException;
import com.auth0.jwt.exceptions.TokenExpiredException;
import com.auth0.jwt.interfaces.Claim;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.google.api.client.auth.oauth2.AuthorizationCodeRequestUrl;
import com.google.api.client.auth.oauth2.AuthorizationCodeResponseUrl;
import com.google.api.client.auth.oauth2.AuthorizationCodeTokenRequest;
import com.google.api.client.auth.openidconnect.IdTokenResponse;
import com.google.api.client.http.BasicAuthentication;
import com.google.api.client.http.GenericUrl;
import com.google.api.client.http.javanet.NetHttpTransport;
import com.google.api.client.json.gson.GsonFactory;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import com.google.inject.Inject;
import com.google.inject.name.Named;

import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.Validate;
import org.apache.commons.lang3.tuple.Pair;
import uk.ac.cam.cl.dtg.isaac.dos.users.EmailVerificationStatus;
import uk.ac.cam.cl.dtg.isaac.dos.users.UserFromAuthProvider;
import uk.ac.cam.cl.dtg.segue.api.Constants;
import uk.ac.cam.cl.dtg.segue.api.managers.UserAccountManager;
import uk.ac.cam.cl.dtg.segue.auth.exceptions.AuthenticationCodeException;
import uk.ac.cam.cl.dtg.segue.auth.exceptions.AuthenticatorSecurityException;
import uk.ac.cam.cl.dtg.segue.auth.exceptions.CodeExchangeException;
import uk.ac.cam.cl.dtg.segue.auth.exceptions.NoUserException;

import java.math.BigInteger;
import java.net.MalformedURLException;
import java.net.URL;
import java.security.SecureRandom;
import java.security.interfaces.RSAPublicKey;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.function.BiPredicate;

public class MicrosoftAuthenticator implements IOAuth2Authenticator {
private final List<String> scopes = List.of("openid", "profile", "email");
private final String clientId;
private final String tenantId;
private final String clientSecret;
private final URL redirectUrl;

private final JwkProvider jwkProvider;
protected Cache<String, String> credentialStore;

@Inject
public MicrosoftAuthenticator(
@Named(Constants.MICROSOFT_CLIENT_ID) final String clientId,
@Named(Constants.MICROSOFT_TENANT_ID) final String tenantId,
@Named(Constants.MICROSOFT_SECRET) final String clientSecret,
@Named(Constants.MICROSOFT_JWKS_URL) final String jwksUrl,
@Named(Constants.MICROSOFT_REDIRECT_URL) final String redirectUrL
) {
this.clientId = Validate.notBlank(clientId, "Missing client_id, can't be \"%s\".", clientId);
this.tenantId = Validate.notBlank(tenantId, "Missing tenant_id, can't be \"%s\".", tenantId);
this.clientSecret = Validate.notBlank(clientSecret, "Missing client_secret, can't be \"%s\".", clientSecret);
this.redirectUrl = validateURL(redirectUrL, "Missing redirect_url, can't be \"%s\".");
var parsedJWKSUrl = validateURL(jwksUrl, "Missing jwks_url, can't be \"%s\".");
this.jwkProvider = new JwkProviderBuilder(parsedJWKSUrl).cached(10, 1, TimeUnit.HOURS).build();
this.credentialStore = CacheBuilder.newBuilder().expireAfterAccess(10, TimeUnit.MINUTES).build();
}

@Override
public AuthenticationProvider getAuthenticationProvider() {
return AuthenticationProvider.MICROSOFT;
}

@Override
public String getFriendlyName() {
return "Microsoft";
}

@Override
public String getAuthorizationUrl(String antiForgeryStateToken) {
return new AuthorizationCodeRequestUrl("https://login.microsoftonline.com/common/oauth2/v2.0/authorize", this.clientId)
.setScopes(scopes)
.setRedirectUri(redirectUrl.toString())
.setState(antiForgeryStateToken)
.set("prompt", "select_account")
.set("response_mode", "query")
.build();
}

@Override
public String getAntiForgeryStateToken() {
int SALT_SIZE_BITS = 130;
int RADIX_FOR_SALT = 32;

return "microsoft" + new BigInteger(SALT_SIZE_BITS, new SecureRandom()).toString(RADIX_FOR_SALT);
}

@Override
public String extractAuthCode(String url) throws AuthenticationCodeException{
try {
return new AuthorizationCodeResponseUrl(url).getCode();
} catch (Exception e) {
throw new AuthenticationCodeException("Error extracting authentication code.");
}
}

@Override
public String exchangeCode(String authorizationCode) throws CodeExchangeException {
try {
var request = new AuthorizationCodeTokenRequest(
new NetHttpTransport(),
new GsonFactory(),
new GenericUrl(String.format("https://login.microsoftonline.com/%s/oauth2/v2.0/token", tenantId)),
authorizationCode
);
IdTokenResponse response = (IdTokenResponse) request
.setResponseClass(IdTokenResponse.class)
.setRedirectUri(redirectUrl.toString())
.setClientAuthentication(new BasicAuthentication(clientId, clientSecret))
.execute();

var internalCredentialID = UUID.randomUUID().toString();
credentialStore.put(internalCredentialID, response.getIdToken());
return internalCredentialID;
} catch (Exception e) {
throw new CodeExchangeException("There was an error exchanging the code.");
}
}

@Override
public UserFromAuthProvider getUserInfo(String internalProviderReference) throws AuthenticatorSecurityException, NoUserException {
String tokenStr = credentialStore.getIfPresent(internalProviderReference);
if (null == tokenStr) {
throw new AuthenticatorSecurityException("Token verification: TOKEN_MISSING");
}

var token = parseAndVerifyToken(tokenStr);
var name = getName(token.getClaim("given_name").asString(), token.getClaim("family_name").asString(), token);

return new UserFromAuthProvider(
token.getSubject(),
name.getLeft(),
name.getRight(),
token.getClaim("email").asString(),
EmailVerificationStatus.NOT_VERIFIED,
null, null, null, null,
false
);
}

private DecodedJWT parseAndVerifyToken(String tokenStr) throws AuthenticatorSecurityException, NoUserException{
// Validating id token based on requirements at
// https://learn.microsoft.com/en-us/entra/identity-platform/id-tokens, as well as our own requirements
var token = JWT.decode(tokenStr);
try {
var keyId = token.getKeyId();
var jwk = jwkProvider.get(keyId);
var algorithm = Algorithm.RSA256((RSAPublicKey) jwk.getPublicKey());
JWT.require(algorithm)
.withAudience(clientId)
.withClaim("tid", validUUID())
.withIssuer(String.format("https://login.microsoftonline.com/%s/v2.0", token.getClaim("tid").asString()))
.withClaim("sub", notEmpty())
.withClaim("email", validEmail())
.build()
.verify(tokenStr);
return token;
} catch (InvalidPublicKeyException e) {
throw new AuthenticatorSecurityException("Token verification: INVALID_PUBLIC_KEY");
} catch (SigningKeyNotFoundException e) {
throw new AuthenticatorSecurityException("Token verification: KEY_NOT_FOUND");
} catch (SignatureVerificationException e) {
throw new AuthenticatorSecurityException("Token verification: BAD_SIGNATURE");
} catch (TokenExpiredException e) {
throw new AuthenticatorSecurityException("Token verification: TOKEN_EXPIRED");
}
catch (MissingClaimException | IncorrectClaimException e) {
String claimName = e instanceof MissingClaimException ?
((MissingClaimException) e).getClaimName() : ((IncorrectClaimException) e).getClaimName();
if (List.of("sub", "tid", "email").contains(claimName)) {
throw new NoUserException(String.format("User verification: BAD_CLAIM (%s)", claimName));
} else {
throw new AuthenticatorSecurityException(String.format("Token verification: BAD_CLAIM (%s)", claimName));
}
} catch (Exception e) {
throw new AuthenticatorSecurityException("Token verification: UNEXPECTED_ERROR");
}
}

private BiPredicate<Claim, DecodedJWT> notEmpty() {
return (c, j) -> !c.isNull() && !c.asString().isBlank();
}

private BiPredicate<Claim, DecodedJWT> validEmail() {
return (c, j) -> UserAccountManager.isUserEmailValid(c.toString());
}

private BiPredicate<Claim, DecodedJWT> validUUID() {
return (c, j) -> {
try {
var uuid = UUID.fromString(c.asString());
return uuid.toString().equals(c.asString());
} catch (Exception e) {
return false;
}
};
}

private Pair<String, String> getName(String givenName, String familyName, DecodedJWT token) throws NoUserException {
if (UserAccountManager.isUserNameValid(givenName) && UserAccountManager.isUserNameValid((familyName))) {
return Pair.of(givenName, familyName);
}
if (UserAccountManager.isUserNameValid((givenName))) {
return Pair.of(givenName, null);
}
if (UserAccountManager.isUserNameValid((familyName))) {
return Pair.of(null, familyName);
}
if (token != null) {
try {
var name = token.getClaim("name").asString();
var names = StringUtils.split(name, " ");
var firstName = Arrays.copyOfRange(names, 0, names.length - 1);
return getName(String.join(" ", firstName), names[names.length - 1], null);
} catch (Exception ignored) {}
}

throw new NoUserException("Could not determine name");
}

private static URL validateURL(String urlString, String message) {
try {
return new URL(urlString);
} catch (MalformedURLException e ) {
if (null == urlString) {
throw new NullPointerException(String.format(message, urlString));
}
throw new IllegalArgumentException(String.format(message, urlString));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@
import uk.ac.cam.cl.dtg.segue.auth.AuthenticationProvider;
import uk.ac.cam.cl.dtg.segue.auth.FacebookAuthenticator;
import uk.ac.cam.cl.dtg.segue.auth.GoogleAuthenticator;
import uk.ac.cam.cl.dtg.segue.auth.MicrosoftAuthenticator;
import uk.ac.cam.cl.dtg.segue.auth.IAuthenticator;
import uk.ac.cam.cl.dtg.segue.auth.ISecondFactorAuthenticator;
import uk.ac.cam.cl.dtg.segue.auth.ISegueHashingAlgorithm;
Expand Down Expand Up @@ -363,6 +364,14 @@ private void configureAuthenticationProviders() {
this.bindConstantToProperty(Constants.GOOGLE_OAUTH_SCOPES, globalProperties);
mapBinder.addBinding(AuthenticationProvider.GOOGLE).to(GoogleAuthenticator.class);

// Microsoft
this.bindConstantToProperty(Constants.MICROSOFT_SECRET, globalProperties);
this.bindConstantToProperty(Constants.MICROSOFT_CLIENT_ID, globalProperties);
this.bindConstantToProperty(Constants.MICROSOFT_TENANT_ID, globalProperties);
this.bindConstantToProperty(MICROSOFT_JWKS_URL, globalProperties);
this.bindConstantToProperty(MICROSOFT_REDIRECT_URL, globalProperties);
mapBinder.addBinding(AuthenticationProvider.MICROSOFT).to(MicrosoftAuthenticator.class);

// Facebook
this.bindConstantToProperty(Constants.FACEBOOK_SECRET, globalProperties);
this.bindConstantToProperty(Constants.FACEBOOK_CLIENT_ID, globalProperties);
Expand Down
Loading
Loading