001/* 002 * JGrapes Event Driven Framework 003 * Copyright (C) 2024 Michael N. Lipp 004 * 005 * This program is free software; you can redistribute it and/or modify it 006 * under the terms of the GNU Affero General Public License as published by 007 * the Free Software Foundation; either version 3 of the License, or 008 * (at your option) any later version. 009 * 010 * This program is distributed in the hope that it will be useful, but 011 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY 012 * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License 013 * for more details. 014 * 015 * You should have received a copy of the GNU Affero General Public License along 016 * with this program; if not, see <http://www.gnu.org/licenses/>. 017 */ 018 019package org.jgrapes.webconlet.oidclogin; 020 021import com.fasterxml.jackson.core.JsonProcessingException; 022import com.fasterxml.jackson.databind.JsonMappingException; 023import com.fasterxml.jackson.databind.ObjectMapper; 024import jakarta.mail.internet.AddressException; 025import jakarta.mail.internet.InternetAddress; 026import java.io.IOException; 027import java.net.HttpURLConnection; 028import java.net.MalformedURLException; 029import java.net.URI; 030import java.net.URISyntaxException; 031import java.nio.ByteBuffer; 032import java.nio.charset.StandardCharsets; 033import java.security.SecureRandom; 034import java.time.Instant; 035import java.util.Arrays; 036import java.util.Base64; 037import java.util.Collections; 038import java.util.List; 039import java.util.Locale; 040import java.util.Map; 041import java.util.Optional; 042import java.util.TreeMap; 043import java.util.concurrent.ConcurrentHashMap; 044import java.util.logging.Level; 045import java.util.regex.Pattern; 046import java.util.stream.Collectors; 047import javax.security.auth.Subject; 048import org.jdrupes.httpcodec.protocols.http.HttpField; 049import org.jdrupes.httpcodec.protocols.http.HttpRequest; 050import org.jdrupes.httpcodec.protocols.http.HttpResponse; 051import org.jdrupes.httpcodec.types.MediaType; 052import org.jdrupes.httpcodec.types.ParameterizedValue; 053import org.jgrapes.core.Channel; 054import org.jgrapes.core.ClassChannel; 055import org.jgrapes.core.Component; 056import org.jgrapes.core.Manager; 057import org.jgrapes.core.annotation.Handler; 058import org.jgrapes.core.annotation.HandlerDefinition.ChannelReplacements; 059import org.jgrapes.core.events.Error; 060import org.jgrapes.http.HttpConnector; 061import org.jgrapes.http.HttpServer; 062import org.jgrapes.http.ResponseCreationSupport; 063import org.jgrapes.http.annotation.RequestHandler; 064import org.jgrapes.http.events.HttpConnected; 065import org.jgrapes.http.events.Request; 066import org.jgrapes.http.events.Response; 067import org.jgrapes.io.IOSubchannel; 068import org.jgrapes.io.events.Input; 069import org.jgrapes.io.util.CharBufferWriter; 070import org.jgrapes.io.util.InputConsumer; 071import org.jgrapes.io.util.JsonReader; 072import org.jgrapes.io.util.OutputSupplier; 073import org.jgrapes.io.util.events.DataInput; 074import org.jgrapes.util.events.ConfigurationUpdate; 075import org.jgrapes.webconlet.oidclogin.OidcError.Kind; 076import org.jgrapes.webconsole.base.ConsoleRole; 077import org.jgrapes.webconsole.base.ConsoleUser; 078import org.jgrapes.webconsole.base.events.UserAuthenticated; 079 080/** 081 * Helper component for {@link LoginConlet} that handles the 082 * communication with the OIDC provider. "OidcClient" is a bit 083 * of a misnomer because this class not only initiates requests 084 * to the OIDC provider but also serves the redirect URI that the 085 * provider uses as callback. However, the callback can be seen 086 * as the asynchronous response to the authentication request 087 * that the {@link OidcClient} sends initially, therefore the 088 * component primarily acts as a client nevertheless. 089 * 090 * The component requires an HTTP connector (usually an 091 * instance of {@link HttpConnector}) to exist that handles the 092 * {@link Request.Out} events that this component fires. There 093 * must also be an HTTP server (usually an instance of 094 * {@link HttpServer}) that converts the provider's calls to the 095 * redirect URI from the provider to a {@link Request.In.Get} event. 096 * Details about configuring the various channels used can be found 097 * in the {@link #OidcClient description of the constructor}. 098 * 099 * The component has a single configuration property that sets 100 * the value of the redirect URI sent to the OIDC provider. 101 * ```yaml 102 * "...": 103 * "/OidcClient": 104 * redirectUri: "https://localhost:5443/vjconsole/oauth/callback" 105 * ``` 106 * 107 * While it is tempting to simply use as redirect URI the host/port 108 * from the HTTP server component together with the request path 109 * passed to the constructor, there are two reasons why the redirect 110 * URI has to be configured explicitly. First, the framework does 111 * not support querying the host/port properties from the server 112 * component. Second, and more import, the HTTP server component will 113 * often be placed behind a firewall or reverse proxy and therefore 114 * the URL that it serves will usually differ from the redirect 115 * URI sent to the OIDC provider. 116 */ 117@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.ExcessiveImports", 118 "PMD.CouplingBetweenObjects", "PMD.GodClass" }) 119public class OidcClient extends Component { 120 121 @SuppressWarnings("PMD.FieldNamingConventions") 122 private static final SecureRandom secureRandom = new SecureRandom(); 123 private Configuration config = new Configuration(); 124 private final Map<String, Context> contexts = new ConcurrentHashMap<>(); 125 private final Channel httpClientChannel; 126 127 /** For channel replacement. */ 128 private final class HttpClientChannel extends ClassChannel { 129 } 130 131 /** For channel replacement. */ 132 private final class HttpServerChannel extends ClassChannel { 133 } 134 135 /** 136 * Instantiates a new OIDC client. The OIDC uses three channels. 137 * 138 * * It is a helper component for the {@link LoginConlet} and 139 * therefore uses its "primary" (component) channel to 140 * exchange events with the conlet. 141 * 142 * * It uses the `httpClientChannel` to accesses the provider 143 * as client, i.e. when it fires {@link Request.Out} 144 * events to obtain information from the provider. 145 * 146 * * It defines a request handler that listens on the 147 * `httpServerChannel` for handling the authorization callback 148 * from the provider. 149 * 150 * @param componentChannel the component's channel 151 * @param httpClientChannel the channel used for connecting the provider 152 * @param httpServerChannel the channel used by some {@link HttpServer} 153 * to send the {@link Request.In} events from the provider callback 154 * @param redirectTarget defines the path handled by 155 * {@link #onAuthCallback} 156 * @param priority the priority of the {@link #onAuthCallback} handler. 157 * Must be higher than the default priority of request handlers if the 158 * callback URL uses a sub-path of the web console's URL. 159 */ 160 public OidcClient(Channel componentChannel, Channel httpClientChannel, 161 Channel httpServerChannel, URI redirectTarget, int priority) { 162 super(componentChannel, ChannelReplacements.create() 163 .add(HttpClientChannel.class, httpClientChannel) 164 .add(HttpServerChannel.class, httpServerChannel)); 165 this.httpClientChannel = httpClientChannel; 166 RequestHandler.Evaluator.add(this, "onAuthCallback", 167 redirectTarget.toString(), priority); 168 } 169 170 /** 171 * The component can be configured with events that include 172 * a path (see @link {@link ConfigurationUpdate#paths()}) 173 * that matches this components path (see {@link Manager#componentPath()}). 174 * 175 * The following properties are recognized: 176 * 177 * `redirectUri` 178 * : The redirect URI as defined in the OIDC provider. 179 * 180 * @param event the event 181 */ 182 @Handler 183 public void onConfigUpdate(ConfigurationUpdate event) { 184 event.structured(componentPath()).ifPresent(m -> { 185 ObjectMapper mapper = new ObjectMapper(); 186 config = mapper.convertValue(m, Configuration.class); 187 }); 188 } 189 190 /** 191 * On start provider login. 192 * 193 * @param event the event 194 * @throws URISyntaxException 195 */ 196 @Handler 197 public void onStartProviderLogin(StartOidcLogin event) 198 throws URISyntaxException { 199 var providerData = event.provider(); 200 if (providerData.authorizationEndpoint() != null 201 && providerData.tokenEndpoint() != null) { 202 attemptAuthorization(new Context(event)); 203 return; 204 } 205 // Get configuration information first 206 fire(new Request.Out.Get(providerData.configurationEndpoint()) 207 .setAssociated(this, new Context(event)), httpClientChannel); 208 } 209 210 /** 211 * Invoked when the connection to the provider has been established. 212 * 213 * @param event the event 214 * @param clientChannel the client channel 215 */ 216 @Handler(channels = HttpClientChannel.class) 217 public void onConnected(HttpConnected event, IOSubchannel clientChannel) { 218 // Transfer context from the request to the new subchannel. 219 event.request().associated(this, Context.class).ifPresent(c -> { 220 clientChannel.setAssociated(this, c); 221 // Also keep the request in order to dispatch responses 222 clientChannel.setAssociated(HttpRequest.class, 223 event.request().httpRequest()); 224 // Send body if provided together with the request 225 event.request().associated(OutputSupplier.class) 226 .ifPresent(bp -> bp.emit(clientChannel)); 227 }); 228 } 229 230 /** 231 * Invoked when a response is received from the provider. 232 * 233 * @param response the response 234 * @param clientChannel the client channel 235 * @throws URISyntaxException 236 */ 237 @Handler(channels = HttpClientChannel.class) 238 public void onResponse(Response response, IOSubchannel clientChannel) 239 throws URISyntaxException { 240 var optCtx = clientChannel.associated(this, Context.class); 241 if (optCtx.isEmpty()) { 242 return; 243 } 244 var rsp = (HttpResponse) response.response(); 245 clientChannel.setAssociated(Response.class, response); 246 var reqUri 247 = clientChannel.associated(HttpRequest.class).get().requestUri(); 248 if (rsp.statusCode() != HttpURLConnection.HTTP_OK) { 249 fire(new Error(response, "Request \"" + reqUri + "\" returned \"" 250 + rsp.statusCode() + " " + rsp.reasonPhrase() + "\"")); 251 if (rsp.statusCode() == HttpURLConnection.HTTP_INTERNAL_ERROR) { 252 fire(new OidcError(optCtx.get().startEvent, 253 Kind.INTERNAL_SERVER_ERROR, 254 "Provider returned an internal server error.")); 255 } 256 } 257 // All expected responses have a JSON payload (body), we don't 258 // perform any actions immediately. 259 if (rsp.hasPayload()) { 260 var charset = response.charset().orElse(StandardCharsets.UTF_8); 261 if (rsp.statusCode() == HttpURLConnection.HTTP_OK) { 262 clientChannel.setAssociated(InputConsumer.class, 263 new JsonReader(Map.class, activeEventPipeline(), 264 clientChannel).charset(charset)); 265 } else { 266 clientChannel.setAssociated(InputConsumer.class, 267 new TextCollector().charset(charset).consumer(msg -> { 268 logger.warning(() -> "Request \"" + reqUri 269 + "\" returned \"" + rsp.statusCode() + " " 270 + rsp.reasonPhrase() + "\" with message:\n" + msg); 271 })); 272 } 273 } 274 } 275 276 /** 277 * Collect and process input from the provider. 278 * 279 * @param event the event 280 * @param clientChannel the client channel 281 * @throws IOException Signals that an I/O exception has occurred. 282 */ 283 @Handler(channels = HttpClientChannel.class) 284 public void onInput(Input<ByteBuffer> event, IOSubchannel clientChannel) 285 throws IOException { 286 if (clientChannel.associated(this, Context.class).isEmpty()) { 287 return; 288 } 289 clientChannel.associated(InputConsumer.class).ifPresent(ic -> { 290 ic.feed(event); 291 if (event.isEndOfRecord()) { 292 ic.feed((Input<ByteBuffer>) null); 293 } 294 }); 295 } 296 297 /** 298 * On data input. 299 * 300 * @param event the event 301 * @param clientChannel the client channel 302 * @throws MalformedURLException the malformed URL exception 303 * @throws URISyntaxException the URI syntax exception 304 * @throws JsonProcessingException 305 * @throws JsonMappingException 306 */ 307 @Handler(channels = HttpClientChannel.class) 308 public void onDataInput(DataInput<Map<String, Object>> event, 309 IOSubchannel clientChannel) 310 throws MalformedURLException, URISyntaxException, 311 JsonMappingException, JsonProcessingException { 312 var optCtx = clientChannel.associated(this, Context.class); 313 if (optCtx.isEmpty()) { 314 return; 315 } 316 if (clientChannel.associated(Response.class) 317 .map(r -> (HttpResponse) r.response()) 318 .map(r -> r.statusCode() != HttpURLConnection.HTTP_OK) 319 .orElse(true)) { 320 // Payload already handled in onResponse 321 return; 322 } 323 var provider = optCtx.get().startEvent.provider(); 324 // Dispatch based on information from the request URI 325 var reqUri 326 = clientChannel.associated(HttpRequest.class).get().requestUri(); 327 if (reqUri.equals(provider.configurationEndpoint().toURI())) { 328 processConfigurationData(event, optCtx.get(), provider); 329 } else if (reqUri.equals(provider.tokenEndpoint().toURI())) { 330 processTokenResponse(event, optCtx.get(), provider); 331 } 332 } 333 334 private void processConfigurationData(DataInput<Map<String, Object>> event, 335 Context ctx, OidcProviderData provider) 336 throws MalformedURLException, URISyntaxException { 337 String aep = (String) event.data().get("authorization_endpoint"); 338 if (aep != null) { 339 provider.setAuthorizationEndpoint(new URI(aep).toURL()); 340 } 341 String tep = (String) event.data().get("token_endpoint"); 342 if (tep != null) { 343 provider.setTokenEndpoint(new URI(tep).toURL()); 344 } 345 String uiep = (String) event.data().get("userinfo_endpoint"); 346 if (uiep != null) { 347 provider.setUserinfoEndpoint(new URI(uiep).toURL()); 348 } 349 String issuer = (String) event.data().get("issuer"); 350 if (issuer != null) { 351 provider.setIssuer(new URI(issuer).toURL()); 352 } 353 354 // We only get the configuration information as part of a login 355 // process, so continue with the login now. 356 attemptAuthorization(ctx); 357 } 358 359 @SuppressWarnings("PMD.AvoidDuplicateLiterals") 360 private void attemptAuthorization(Context ctx) throws URISyntaxException { 361 @SuppressWarnings("PMD.UseConcurrentHashMap") 362 Map<String, String> params = new TreeMap<>(); 363 params.put("scope", "openid"); 364 params.put("response_type", "code"); 365 params.put("client_id", ctx.startEvent.provider().clientId()); 366 params.put("redirect_uri", config.redirectUri); 367 params.put("prompt", "login"); 368 if (ctx.startEvent.locales().length > 0) { 369 params.put("ui_locales", Arrays.stream(ctx.startEvent.locales()) 370 .map(Locale::toLanguageTag).collect(Collectors.joining(" "))); 371 } 372 var stateBytes = new byte[16]; 373 secureRandom.nextBytes(stateBytes); 374 var state = Base64.getEncoder().encodeToString(stateBytes); 375 params.put("state", state); 376 var request = HttpRequest.replaceQuery( 377 ctx.startEvent.provider().authorizationEndpoint().toURI(), 378 HttpRequest.simpleWwwFormUrlencode(params)); 379 logger.finer(() -> "Getting " + request); 380 contexts.put(state, ctx); 381 fire(new OpenLoginWindow(ctx.startEvent, request)); 382 383 // Simple purging of left overs 384 var ageLimit = Instant.now().minusSeconds(60); 385 for (var itr = contexts.entrySet().iterator(); itr.hasNext();) { 386 var entry = itr.next(); 387 if (entry.getValue().createdAt().isBefore(ageLimit)) { 388 itr.remove(); 389 } 390 } 391 } 392 393 /** 394 * On callback from the authorization request. (Path selector 395 * defined in constructor.) 396 * 397 * @param event the event 398 * @param channel the channel 399 */ 400 @RequestHandler(channels = HttpServerChannel.class, dynamic = true) 401 public void onAuthCallback(Request.In.Get event, IOSubchannel channel) { 402 ResponseCreationSupport.sendStaticContent(event, channel, 403 path -> getClass().getResource("CloseWindow.html"), null); 404 var query = event.httpRequest().queryData(); 405 var state = Optional.ofNullable(query.get("state")) 406 .orElse(Collections.emptyList()).stream().findFirst().orElse(null); 407 var ctx = contexts.remove(state); 408 if (ctx == null) { 409 return; 410 } 411 if (query.get("code") == null) { 412 fire(new OidcError(ctx.startEvent, Kind.INTERNAL_SERVER_ERROR, 413 Optional.ofNullable(query.get("error")).map(es -> es.get(0)) 414 .orElse("No code in callback from provider."))); 415 return; 416 } 417 ctx.code = query.get("code").get(0); 418 419 // Prepare token request 420 OidcProviderData provider = ctx.startEvent.provider(); 421 Request.Out.Post post = new Request.Out.Post(provider.tokenEndpoint()); 422 post.httpRequest().setField(HttpField.CONTENT_TYPE, 423 new MediaType("application", "x-www-form-urlencoded")); 424 post.httpRequest().setField(HttpField.AUTHORIZATION, 425 new ParameterizedValue<>("Basic " 426 + Base64.getEncoder().encodeToString( 427 (provider.clientId() + ":" + provider.secret()) 428 .getBytes()))); 429 post.httpRequest().setHasPayload(true); 430 431 // Prepare payload data 432 var params = new TreeMap<String, String>(); 433 params.put("grant_type", "authorization_code"); 434 params.put("code", ctx.code); 435 params.put("redirect_uri", config.redirectUri); 436 @SuppressWarnings("resource") 437 OutputSupplier body = (IOSubchannel c) -> { 438 new CharBufferWriter(c, c.responsePipeline()) 439 .append(HttpRequest.simpleWwwFormUrlencode(params)).close(); 440 }; 441 fire(post.setAssociated(OutputSupplier.class, body).setAssociated(this, 442 ctx), httpClientChannel); 443 event.setResult(true); 444 event.stop(); 445 } 446 447 @SuppressWarnings({ "unchecked", "PMD.NPathComplexity", 448 "PMD.CognitiveComplexity", "PMD.CyclomaticComplexity" }) 449 private void processTokenResponse(DataInput<Map<String, Object>> event, 450 Context ctx, OidcProviderData provider) { 451 ctx.idToken = JsonWebToken.parse((String) event.data().get("id_token")); 452 var idData = ctx.idToken.payload(); 453 454 // Mandatory checks, see 455 // https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation 456 if (provider.issuer() != null 457 && !provider.issuer().toString().equals(idData.get("iss"))) { 458 fire(new OidcError(ctx.startEvent, Kind.INVALID_ISSUER, 459 "ID token has invalid issuer.")); 460 event.stop(); 461 return; 462 } 463 if (idData.get("aud") instanceof List auds && !auds.contains( 464 provider.clientId()) || idData.get("aud") instanceof String aud 465 && !aud.equals(provider.clientId())) { 466 fire(new OidcError(ctx.startEvent, Kind.INVALID_AUDIENCE, 467 "ID token has invalid audience.")); 468 event.stop(); 469 return; 470 } 471 if (idData.get("exp") instanceof Integer exp 472 && !Instant.now().isBefore(Instant.ofEpochSecond(exp))) { 473 fire(new OidcError(ctx.startEvent, Kind.ID_TOKEN_EXPIRED, 474 "ID token has expired.")); 475 event.stop(); 476 return; 477 } 478 if (!idData.containsKey("preferred_username")) { 479 fire(new OidcError(ctx.startEvent, Kind.PREFERRED_USERNAME_MISSING, 480 "ID token does not contain preferred_username.")); 481 event.stop(); 482 return; 483 } 484 485 // Check if allowed 486 var roles = Optional.ofNullable((List<String>) idData.get("roles")) 487 .orElse(Collections.emptyList()); 488 if (!(roles.isEmpty() && provider.authorizedRoles().contains("")) 489 && !roles.stream().filter(r -> provider.authorizedRoles() 490 .contains(r)).findAny().isPresent()) { 491 // Not allowed 492 fire(new OidcError(ctx.startEvent, Kind.ACCESS_DENIED, 493 "Access denied (no allowed role).")); 494 event.stop(); 495 return; 496 } 497 498 // Success 499 500 Subject subject = new Subject(); 501 var user = new ConsoleUser( 502 mapName((String) idData.get("preferred_username"), 503 provider.userMappings(), provider.patternCache()), 504 Optional.ofNullable((String) idData.get("name")) 505 .orElse((String) idData.get("preferred_username"))); 506 if (idData.containsKey("email")) { 507 try { 508 user.setEmail( 509 new InternetAddress((String) idData.get("email"))); 510 } catch (AddressException e) { 511 logger.log(Level.WARNING, e, 512 () -> "Failed to parse email address \"" 513 + idData.get("email") + "\": " + e.getMessage()); 514 } 515 } 516 subject.getPrincipals().add(user); 517 for (var role : roles) { 518 subject.getPrincipals().add(new ConsoleRole(mapName(role, 519 provider.roleMappings(), provider.patternCache()), role)); 520 } 521 fire(new UserAuthenticated(ctx.startEvent, subject).by( 522 "OIDC Provider " + provider.name())); 523 } 524 525 private String mapName(String name, List<Map<String, String>> mappings, 526 Map<String, Pattern> patternCache) { 527 for (var mapping : mappings) { 528 @SuppressWarnings("PMD.LambdaCanBeMethodReference") 529 var pattern = patternCache.computeIfAbsent(mapping.get("from"), 530 k -> Pattern.compile(k)); 531 var matcher = pattern.matcher(name); 532 if (matcher.matches()) { 533 return matcher.replaceFirst(mapping.get("to")); 534 } 535 } 536 return name; 537 } 538 539 /** 540 * The configuration information. 541 */ 542 public static class Configuration { 543 public String redirectUri; 544 } 545 546 /** 547 * The context information. 548 */ 549 @SuppressWarnings("PMD.DataClass") 550 private class Context { 551 private final Instant createdAt = Instant.now(); 552 public final StartOidcLogin startEvent; 553 public String code; 554 public JsonWebToken idToken; 555 556 /** 557 * Instantiates a new context. 558 * 559 * @param startEvent the start event 560 */ 561 public Context(StartOidcLogin startEvent) { 562 super(); 563 this.startEvent = startEvent; 564 } 565 566 /** 567 * Created at. 568 * 569 * @return the instant 570 */ 571 public Instant createdAt() { 572 return createdAt; 573 } 574 } 575 576}