001/* 002 * Copyright 2024 Anyware Services 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016package org.ametys.plugins.extrausermgt.authentication.msal; 017 018import java.io.IOException; 019import java.net.URI; 020import java.util.Date; 021import java.util.HashMap; 022import java.util.Map; 023import java.util.Set; 024import java.util.UUID; 025 026import org.apache.avalon.framework.context.Context; 027import org.apache.avalon.framework.context.ContextException; 028import org.apache.avalon.framework.context.Contextualizable; 029import org.apache.cocoon.ProcessingException; 030import org.apache.cocoon.components.ContextHelper; 031import org.apache.cocoon.environment.ObjectModelHelper; 032import org.apache.cocoon.environment.Redirector; 033import org.apache.cocoon.environment.Request; 034import org.apache.cocoon.environment.Session; 035import org.apache.commons.lang3.StringUtils; 036 037import org.ametys.core.authentication.AbstractCredentialProvider; 038import org.ametys.core.authentication.BlockingCredentialProvider; 039import org.ametys.core.authentication.LogoutCapable; 040import org.ametys.core.authentication.NonBlockingCredentialProvider; 041import org.ametys.core.user.UserIdentity; 042import org.ametys.core.util.URIUtils; 043import org.ametys.plugins.extrausermgt.authentication.oidc.AbstractOIDCCredentialProvider; 044import org.ametys.plugins.extrausermgt.authentication.oidc.OIDCBasedCredentialProvider; 045import org.ametys.runtime.authentication.AccessDeniedException; 046import org.ametys.workspaces.extrausermgt.authentication.oidc.OIDCCallbackAction; 047 048import com.microsoft.aad.msal4j.AuthorizationCodeParameters; 049import com.microsoft.aad.msal4j.AuthorizationRequestUrlParameters; 050import com.microsoft.aad.msal4j.AuthorizationRequestUrlParameters.Builder; 051import com.microsoft.aad.msal4j.ClientCredentialFactory; 052import com.microsoft.aad.msal4j.ConfidentialClientApplication; 053import com.microsoft.aad.msal4j.IAccount; 054import com.microsoft.aad.msal4j.IAuthenticationResult; 055import com.microsoft.aad.msal4j.IClientSecret; 056import com.microsoft.aad.msal4j.Prompt; 057import com.microsoft.aad.msal4j.ResponseMode; 058import com.microsoft.aad.msal4j.SilentParameters; 059import com.nimbusds.jwt.SignedJWT; 060 061/** 062 * Sign in through Entra ID, using the OpenId Connect protocol. 063 */ 064public abstract class AbstractMSALCredentialProvider extends AbstractCredentialProvider implements OIDCBasedCredentialProvider, BlockingCredentialProvider, NonBlockingCredentialProvider, Contextualizable, LogoutCapable 065{ 066 /** Session attribute to store the access token */ 067 public static final String ACCESS_TOKEN_SESSION_ATTRIBUTE = "msal_token"; 068 private static final String __ID_TOKEN_SESSION_ATTRIBUTE = "msal_idtoken"; 069 private static final String __ATTRIBUTE_EXPIRATIONDATE = "msal_expirationDate"; 070 private static final String __ATTRIBUTE_ACCOUNT = "msal_account"; 071 private static final String __ATTRIBUTE_TOKENCACHE = "msal_tokenCache"; 072 private static final String __ATTRIBUTE_CODE = "msal_code"; 073 private static final String __ATTRIBUTE_SILENT = "msal_silent"; 074 private static final String __ATTRIBUTE_STATE = "msal_state"; 075 private static final String __ATTRIBUTE_NONCE = "msal_nonce"; 076 077 /** the OIDC app id */ 078 protected String _clientID; 079 /** the client secret */ 080 protected String _clientSecret; 081 /** whether the user should be explicitely forced to enter its username */ 082 protected boolean _prompt; 083 /** whether we should try to silently log the user in */ 084 protected boolean _silent; 085 086 private Context _context; 087 088 @Override 089 public void contextualize(Context context) throws ContextException 090 { 091 _context = context; 092 } 093 094 /** 095 * Set the mandatory properties. Should be called by implementors as early as possible. 096 * @param cliendId the OIDC app id 097 * @param clientSecret the client secret 098 * @param prompt whether the user should be explicitely forced to enter its username 099 * @param silent whether we should try to silently log the user in 100 */ 101 protected void init(String cliendId, String clientSecret, boolean prompt, boolean silent) 102 { 103 _clientID = cliendId; 104 _clientSecret = clientSecret; 105 _prompt = prompt; 106 _silent = silent; 107 } 108 109 private ConfidentialClientApplication _getClient() throws Exception 110 { 111 IClientSecret secret = ClientCredentialFactory.createFromSecret(_clientSecret); 112 ConfidentialClientApplication client = ConfidentialClientApplication.builder(_clientID, secret) 113 .authority(getAuthority()) 114 .build(); 115 return client; 116 } 117 118 /** 119 * Returns the URL to send authorization and token requests to. 120 * @return the OIDC authority URL 121 */ 122 protected abstract String getAuthority(); 123 124 public String getClientId() 125 { 126 return _clientID; 127 } 128 129 @Override 130 public boolean blockingIsStillConnected(UserIdentity userIdentity, Redirector redirector) throws Exception 131 { 132 Map objectModel = ContextHelper.getObjectModel(_context); 133 Request request = ObjectModelHelper.getRequest(objectModel); 134 Session session = request.getSession(true); 135 136 refreshTokenIfNeeded(session); 137 138 return true; 139 } 140 141 @Override 142 public boolean nonBlockingIsStillConnected(UserIdentity userIdentity, Redirector redirector) throws Exception 143 { 144 return blockingIsStillConnected(userIdentity, redirector); 145 } 146 147 @Override 148 public boolean blockingGrantAnonymousRequest() 149 { 150 return false; 151 } 152 153 @Override 154 public boolean nonBlockingGrantAnonymousRequest() 155 { 156 return false; 157 } 158 159 private String _getRequestURI(Request request, String path) 160 { 161 StringBuilder uriBuilder = new StringBuilder(); 162 if (request.isSecure()) 163 { 164 uriBuilder.append("https://").append(request.getServerName()); 165 if (request.getServerPort() != 443) 166 { 167 uriBuilder.append(":"); 168 uriBuilder.append(request.getServerPort()); 169 } 170 } 171 else 172 { 173 uriBuilder.append("http://").append(request.getServerName()); 174 if (request.getServerPort() != 80) 175 { 176 uriBuilder.append(":"); 177 uriBuilder.append(request.getServerPort()); 178 } 179 } 180 181 uriBuilder.append(request.getContextPath()); 182 uriBuilder.append(path); 183 return uriBuilder.toString(); 184 } 185 186 private UserIdentity _login(boolean silent, Redirector redirector) throws Exception 187 { 188 Map objectModel = ContextHelper.getObjectModel(_context); 189 Request request = ObjectModelHelper.getRequest(objectModel); 190 Session session = request.getSession(true); 191 192 ConfidentialClientApplication client = _getClient(); 193 194 String requestURI = _getRequestURI(request, OIDCCallbackAction.CALLBACK_URL); 195 getLogger().debug("MSAL CredentialProvider callback URI: {}", requestURI); 196 197 String storedCode = (String) session.getAttribute(__ATTRIBUTE_CODE); 198 199 if (storedCode != null) 200 { 201 return _getUserIdentityFromCode(storedCode, session, client, requestURI); 202 } 203 204 boolean wasSilent = false; 205 if (silent) 206 { 207 wasSilent = "true".equals(session.getAttribute(__ATTRIBUTE_SILENT)); 208 } 209 210 String code = request.getParameter("code"); 211 if (code == null) 212 { 213 // sign-in request: redirect the client through the actual authentication process 214 215 if (wasSilent) 216 { 217 // already passed through this, there should have been some error somewhere 218 return null; 219 } 220 221 if (silent) 222 { 223 session.setAttribute(__ATTRIBUTE_SILENT, "true"); 224 } 225 226 String state = UUID.randomUUID().toString(); 227 session.setAttribute(__ATTRIBUTE_STATE, state); 228 229 String actualRedirectUri = request.getRequestURI(); 230 if (request.getQueryString() != null) 231 { 232 actualRedirectUri += "?" + request.getQueryString(); 233 } 234 session.setAttribute(AbstractOIDCCredentialProvider.REDIRECT_URI_SESSION_ATTRIBUTE, actualRedirectUri); 235 236 String nonce = UUID.randomUUID().toString(); 237 session.setAttribute(__ATTRIBUTE_NONCE, nonce); 238 239 Builder builder = AuthorizationRequestUrlParameters.builder(requestURI, getScopes()) 240 .responseMode(ResponseMode.QUERY) 241 .state(state) 242 .nonce(nonce); 243 244 if (silent) 245 { 246 builder.prompt(Prompt.NONE); 247 } 248 else if (_prompt) 249 { 250 builder.prompt(Prompt.SELECT_ACCOUNT); 251 } 252 253 AuthorizationRequestUrlParameters parameters = builder.build(); 254 255 String authorizationRequestUrl = client.getAuthorizationRequestUrl(parameters).toString(); 256 redirector.redirect(false, authorizationRequestUrl); 257 return null; 258 } 259 260 // we got an authorization code, 261 262 // but first, check the state to prevent CSRF attacks 263 String storedState = (String) session.getAttribute(__ATTRIBUTE_STATE); 264 String state = request.getParameter("state"); 265 266 if (!storedState.equals(state)) 267 { 268 throw new AccessDeniedException("MSAL state mismatch"); 269 } 270 271 session.setAttribute(__ATTRIBUTE_STATE, null); 272 273 // then store the authorization code 274 session.setAttribute(__ATTRIBUTE_CODE, code); 275 276 // and finally redirect to initial URI 277 String redirectUri = (String) session.getAttribute(AbstractOIDCCredentialProvider.REDIRECT_URI_SESSION_ATTRIBUTE); 278 redirector.redirect(true, redirectUri); 279 return null; 280 } 281 282 /** 283 * Returns all needed OIDC scopes. Defaults to ["openid"] 284 * @return all needed OIDC scopes 285 */ 286 protected Set<String> getScopes() 287 { 288 return Set.of("openid"); 289 } 290 291 private UserIdentity _getUserIdentityFromCode(String code, Session session, ConfidentialClientApplication client, String requestURI) throws Exception 292 { 293 AuthorizationCodeParameters authParams = AuthorizationCodeParameters.builder(code, new URI(requestURI)) 294 .scopes(getScopes()) 295 .build(); 296 297 IAuthenticationResult result = client.acquireToken(authParams).get(); 298 299 // check nonce 300 Map<String, Object> tokenClaims = SignedJWT.parse(result.idToken()).getJWTClaimsSet().getClaims(); 301 302 String storedNonce = (String) session.getAttribute(__ATTRIBUTE_NONCE); 303 String nonce = (String) tokenClaims.get("nonce"); 304 305 if (!storedNonce.equals(nonce)) 306 { 307 throw new AccessDeniedException("MSAL nonce mismatch"); 308 } 309 310 session.setAttribute(__ATTRIBUTE_NONCE, null); 311 312 session.setAttribute(__ATTRIBUTE_EXPIRATIONDATE, result.expiresOnDate()); 313 session.setAttribute(__ATTRIBUTE_TOKENCACHE, client.tokenCache().serialize()); 314 session.setAttribute(__ATTRIBUTE_ACCOUNT, result.account()); 315 316 session.setAttribute(ACCESS_TOKEN_SESSION_ATTRIBUTE, result.accessToken()); 317 session.setAttribute(__ID_TOKEN_SESSION_ATTRIBUTE, result.idToken()); 318 // then the user is finally logged in 319 String login = getLogin(result); 320 321 return new UserIdentity(login, null); 322 } 323 324 /** 325 * Retrieves the login from the given authentication result 326 * @param result the authentication result 327 * @return the login 328 */ 329 protected String getLogin(IAuthenticationResult result) 330 { 331 return result.account().username(); 332 } 333 334 @Override 335 public UserIdentity blockingGetUserIdentity(Redirector redirector) throws Exception 336 { 337 return _login(false, redirector); 338 } 339 340 public UserIdentity nonBlockingGetUserIdentity(Redirector redirector) throws Exception 341 { 342 if (!_silent) 343 { 344 return null; 345 } 346 347 return _login(true, redirector); 348 } 349 350 @Override 351 public void blockingUserNotAllowed(Redirector redirector) 352 { 353 // Nothing to do. 354 } 355 356 @Override 357 public void nonBlockingUserNotAllowed(Redirector redirector) throws Exception 358 { 359 // Nothing to do. 360 } 361 362 @Override 363 public void blockingUserAllowed(UserIdentity userIdentity, Redirector redirector) throws ProcessingException, IOException 364 { 365 // Nothing to do. 366 } 367 368 @Override 369 public void nonBlockingUserAllowed(UserIdentity userIdentity, Redirector redirector) 370 { 371 // Empty method, nothing more to do. 372 } 373 374 public boolean requiresNewWindow() 375 { 376 return true; 377 } 378 379 /** 380 * Refresh the access token of the user if needed 381 * @param session the session 382 * @throws Exception when an error occurs 383 */ 384 public void refreshTokenIfNeeded(Session session) throws Exception 385 { 386 // this check is also done by the following MSAL code, but it's way faster with just a simple date check 387 Date expDat = (Date) session.getAttribute(__ATTRIBUTE_EXPIRATIONDATE); 388 if (expDat != null && new Date().after(expDat)) 389 { 390 ConfidentialClientApplication client = _getClient(); 391 392 IAccount account = (IAccount) session.getAttribute(__ATTRIBUTE_ACCOUNT); 393 String tokenCache = (String) session.getAttribute(__ATTRIBUTE_TOKENCACHE); 394 395 SilentParameters parameters = SilentParameters.builder(Set.of("openid"), account).build(); 396 client.tokenCache().deserialize(tokenCache); 397 IAuthenticationResult result = client.acquireTokenSilently(parameters).get(); 398 399 session.setAttribute(__ATTRIBUTE_EXPIRATIONDATE, result.expiresOnDate()); 400 session.setAttribute(__ATTRIBUTE_TOKENCACHE, client.tokenCache().serialize()); 401 session.setAttribute(__ATTRIBUTE_ACCOUNT, result.account()); 402 403 session.setAttribute(ACCESS_TOKEN_SESSION_ATTRIBUTE, result.accessToken()); 404 session.setAttribute(__ID_TOKEN_SESSION_ATTRIBUTE, result.idToken()); 405 } 406 } 407 408 public void logout(Redirector redirector) throws ProcessingException 409 { 410 Request request = ContextHelper.getRequest(_context); 411 Session session = request.getSession(); 412 413 Map<String, String> params = new HashMap<>(); 414 415 String idToken = (String) session.getAttribute(__ID_TOKEN_SESSION_ATTRIBUTE); 416 if (StringUtils.isNotBlank(idToken)) 417 { 418 params.put("post_logout_redirect_uri", _getRequestURI(request, request.getRequestURI().substring(request.getContextPath().length()))); 419 params.put("id_token_hint", idToken); 420 } 421 422 try 423 { 424 redirector.redirect(false, URIUtils.encodeURI(getLogoutUrl(), params)); 425 } 426 catch (IOException e) 427 { 428 throw new ProcessingException("Failed to redirect to " + getLogoutUrl(), e); 429 } 430 } 431 432 /** 433 * Get the URL to call to logout the user from the OIDC server 434 * @return the end session endpoint URL as a string 435 */ 436 protected abstract String getLogoutUrl(); 437}