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}