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