OAuthReceiver.java

/*
 * onedrive-java-sdk - A Java SDK to access OneDrive drives and files.
 * Copyright © 2023-2025 Andy Miles (andy.miles@amilesend.com)
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program. If not, see <http://www.gnu.org/licenses/>.
 */
package com.amilesend.onedrive.connection.auth.oauth;

import com.google.common.annotations.VisibleForTesting;
import com.sun.net.httpserver.HttpServer;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.Validate;

import java.awt.*;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.URI;

/**
 * A customized OAuth receiver that handles the OAuth token exchange redirect by hosting a HTTP server
 * to capture the auth and/or error code specific to Box.net OAuth flow.
 *
 * This is based on <a href="https://t.ly/4YOIa">LocalServerReceiver</a> defined in the
 * <a href="https://t.ly/Ymraz">google-oauth-java-client</a> library.
 */
@Slf4j
public class OAuthReceiver implements AutoCloseable {
    private static final int DEFAULT_SYSTEM_BACKLOG = 0;

    /** Max allowed port range for listening for the OAuth redirect. */
    @VisibleForTesting
    static final int MAX_PORT_RANGE = 65535;

    @VisibleForTesting
    @Getter(value = AccessLevel.PACKAGE)
    private final OAuthReceiverCallback callback;
    /** The host of the receiver. */
    @Getter
    private final String host;
    /** The path to listen for the redirect. */
    @Getter
    private final String callbackPath;
    /** The port of the receiver to listen on. */
    @Getter
    private final int port;
    private HttpServer server;

    /**
     * Builds a new {@code OAuthReceiver}.
     *
     * @param host host of the receiver (Default: {@literal localhost})
     * @param port optional port of the receiver to listen on
     * @param callbackPath the path to listen for the redirect (Default: {@literal /Callback})
     * @param successLandingPageUrl optional URL for a custom successful landing page
     * @param failureLandingPageUrl optional URL for a custom failure landing page
     */
    @Builder(builderMethodName = "defaultOAuthReceiverBuilder")
    protected OAuthReceiver(
            final String host,
            final int port,
            final String callbackPath,
            final String successLandingPageUrl,
            final String failureLandingPageUrl) {
        this.host = StringUtils.isNotBlank(host) ? host : "localhost";
        this.callbackPath = StringUtils.isNotBlank(callbackPath) ? callbackPath : "/Callback";
        validatePort(port);
        this.port = port;
        callback = OAuthReceiverCallback.builder()
                .callbackPath(this.callbackPath)
                .successLandingPageUrl(successLandingPageUrl)
                .failureLandingPageUrl(failureLandingPageUrl)
                .build();
    }

    /**
     * Open a browser at the given URL using {@link Desktop} if available, or alternatively output the
     * URL to {@link System#out} for command-line applications.
     *
     * @param url URL to browse
     */
    public static void browse(final String url) {
        Validate.notBlank(url, "url must not be blank");

        // Ask user to open in their browser using copy-paste
        System.out.println("Please open the following address in your browser: " + url);
        try {
            if (!Desktop.isDesktopSupported()) {
                return;
            }

            final Desktop desktop = Desktop.getDesktop();
            if (!desktop.isSupported(Desktop.Action.BROWSE)) {
                return;
            }

            System.out.println("Attempting to open that address in the default browser now...");
            desktop.browse(URI.create(url));
        } catch (IOException | InternalError ex) {
            log.warn("Unable to open browser", ex);
        }
    }

    /**
     * Starts the HTTP server to handle OAuth callbacks.
     *
     * @return this OAuthReceiver instance
     * @throws OAuthReceiverException if an error occurred while starting the HTTP server
     */
    public <T> T start() throws OAuthReceiverException {
        if (server != null) {
            return (T) this;
        }

        try {
            server = HttpServer.create(new InetSocketAddress(port), DEFAULT_SYSTEM_BACKLOG);
            server.createContext(callbackPath, callback);
            server.setExecutor(null);
            server.start();
            return (T) this;
        } catch (final Exception ex) {
            throw new OAuthReceiverException("Error starting OAuthReceiver", ex);
        }
    }

    /**
     * Stops the running HTTP server.
     *
     * @throws OAuthReceiverException if an error occurred while stopping the server
     */
    public void stop() throws OAuthReceiverException {
        callback.releaseLock();
        if (server != null) {
            try {
                server.stop(0);
            } catch (final Exception ex) {
                throw new OAuthReceiverException("Error stopping OAuthReceiver", ex);
            }
            server = null;
        }
    }

    /**
     * Closes the HTTP server resource.
     *
     * @throws OAuthReceiverException if an error occurred while closing the HTTP server resource
     */
    @Override
    public void close() throws OAuthReceiverException {
        stop();
    }

    /**
     * Gets the redirect URI based on the running HTTP server resource.
     *
     * @return the redirect URI
     */
    public String getRedirectUri() {
        return new StringBuilder("http://")
                .append(getHost())
                .append(":")
                .append(port)
                .append(callbackPath)
                .toString();
    }

    /**
     * Blocks until the server receives a login result, or the server is stopped by {@link #stop()},
     * to return an authorization code.
     *
     * @return authorization code if login succeeds; may return {@code null} if the server is stopped
     *     by {@link #close()}
     * @throws OAuthReceiverException if the server receives an error code (through an HTTP request parameter
     *     {@code error})
     */
    public String waitForCode() throws OAuthReceiverException {
        return callback.waitForCode();
    }

    private static void validatePort(final int port) {
        Validate.isTrue(port > 0 && port <= MAX_PORT_RANGE,
                "Invalid port.  Must be between 0 and " + MAX_PORT_RANGE);
    }
}