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