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