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