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 44 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
44 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
51ba006
tests: valid token contains expiration
barna-isaac Jun 13, 2025
733f156
use oid in place of sub
barna-isaac Jun 13, 2025
e8b6185
remove unnecessary `toString` call
barna-isaac Jun 13, 2025
9031481
add logging
barna-isaac Jun 17, 2025
6a5fac7
extract validation helpers
barna-isaac Jun 17, 2025
b585fe2
extract test helpers from IsaacTest
barna-isaac Jun 17, 2025
5c82c6d
resolve GitHub codeQL findings
barna-isaac Jun 17, 2025
f997cd0
only register Microsoft provider for right config
barna-isaac Jun 17, 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
17 changes: 17 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -156,12 +156,19 @@
<artifactId>google-oauth-client</artifactId>
<version>${oauth-client.version}</version>
</dependency>

<dependency>
<groupId>com.google.oauth-client</groupId>
<artifactId>google-oauth-client-servlet</artifactId>
<version>${oauth-client.version}</version>
</dependency>

<dependency>
<groupId>com.microsoft.azure</groupId>
<artifactId>msal4j</artifactId>
<version>1.20.0</version>
</dependency>

<dependency>
<groupId>com.warrenstrange</groupId>
<artifactId>googleauth</artifactId>
Expand Down Expand Up @@ -219,6 +226,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
6 changes: 6 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,12 @@ 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";

// 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 @@ -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
}
221 changes: 221 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,221 @@
/*
* 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.InvalidPublicKeyException;
import com.auth0.jwk.JwkException;
import com.auth0.jwk.JwkProvider;
import com.auth0.jwk.UrlJwkProvider;
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.interfaces.Claim;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.google.api.client.auth.oauth2.AuthorizationCodeResponseUrl;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import com.google.inject.Inject;
import com.google.inject.name.Named;
import com.microsoft.aad.msal4j.AuthorizationCodeParameters;
import com.microsoft.aad.msal4j.AuthorizationRequestUrlParameters;
import com.microsoft.aad.msal4j.Prompt;
import com.microsoft.aad.msal4j.ResponseMode;
import com.microsoft.aad.msal4j.ConfidentialClientApplication;
import com.microsoft.aad.msal4j.ClientCredentialFactory;

import org.apache.commons.validator.routines.EmailValidator;
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.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.URI;
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 {
static final int CREDENTIAL_CACHE_TTL_MINUTES = 10;
// TODO: why do we cache idTokens? Why don't we just pass them around in code?
protected static Cache<String, String> credentialStore = CacheBuilder
.newBuilder()
.expireAfterAccess(CREDENTIAL_CACHE_TTL_MINUTES, TimeUnit.MINUTES)
.build();

private final String scopes = "email";
private final String callbackURL = "http://localhost:8004/auth/microsoft/callback";
private final String clientId;
private final String tenantId;
private final String clientSecret;
private final JwkProvider provider;

@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
) throws MalformedURLException {
this.clientId = clientId;
this.tenantId = tenantId;
this.clientSecret = clientSecret;
provider = new UrlJwkProvider(new URL(jwksUrl));
}

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

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

@Override
public String getAuthorizationUrl(String antiForgeryStateToken) {
var parameters = AuthorizationRequestUrlParameters
.builder(callbackURL, Collections.singleton(scopes))
.responseMode(ResponseMode.QUERY)
.prompt(Prompt.SELECT_ACCOUNT)
.state(antiForgeryStateToken)
.build();
return microsoftClient().getAuthorizationRequestUrl(parameters).toString();
}

@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) {
return new AuthorizationCodeResponseUrl(url).getCode();
}

@Override
public String exchangeCode(String authorizationCode) throws CodeExchangeException {
try {
var authParams = AuthorizationCodeParameters
.builder(authorizationCode, new URI(callbackURL)).scopes(Collections.singleton(scopes))
.build();
var result = microsoftClient().acquireToken(authParams).get();

var internalCredentialID = UUID.randomUUID().toString();
credentialStore.put(internalCredentialID, result.idToken());
return internalCredentialID;
} catch (Exception e) {
throw new CodeExchangeException(e.getMessage());
}
}

@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);

return new UserFromAuthProvider(
token.getSubject(),
token.getClaim("given_name").asString(),
token.getClaim("family_name").asString(),
token.getClaim("email").asString(),
EmailVerificationStatus.NOT_VERIFIED,
null, null, null, null,
false
);
}

private ConfidentialClientApplication microsoftClient() {
var secret = ClientCredentialFactory.createFromSecret(clientSecret);
try {
return ConfidentialClientApplication.builder(clientId, secret)
.authority(String.format("https://login.microsoftonline.com/%s", tenantId))
.build();
} catch (MalformedURLException e) {
throw new RuntimeException(e);
}
}

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
// I've ignored "nonce" validation as RaspberryPi Authenticator also skips it
// Validating `sub`, `email`, `family_name` and `given_name` to meet our own requirements. For example, when a
// user is unable to sign in, we use email to look up whether they use the platform with another account.
var token = JWT.decode(tokenStr);
try {
var keyId = token.getKeyId();
var jwk = provider.get(keyId);
var algorithm = Algorithm.RSA256((RSAPublicKey) jwk.getPublicKey());
var verifier = 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())
.withClaim("family_name", notEmpty())
.withClaim("given_name", notEmpty())
.build();
verifier.verify(tokenStr); // TODO: does this check validity of cert?
} catch (InvalidPublicKeyException e) {
throw new AuthenticatorSecurityException("Token verification: INVALID_PUBLIC_KEY");
} catch (MissingClaimException | IncorrectClaimException e) {
String claimName = e instanceof MissingClaimException ?
((MissingClaimException) e).getClaimName() : ((IncorrectClaimException) e).getClaimName();
if (List.of("sub", "tid", "email", "family_name", "given_name").contains(claimName)) {
throw new NoUserException(String.format("Required field '%s' missing from identity provider's response.", claimName));
}
throw e;
} catch (JwkException e) {
throw new AuthenticatorSecurityException(e.getMessage());
}
return token;
}

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

private BiPredicate<Claim, DecodedJWT> validEmail() {
return (c, j) -> EmailValidator.getInstance().isValid(c.asString());
}

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;
}
};
}
}
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,13 @@ 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);
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