Ticket #24925: oauth-auth-on-upload.patch

File oauth-auth-on-upload.patch, 13.8 KB (added by wangi, 6 weeks ago)
  • src/org/openstreetmap/josm/data/oauth/OAuthParameters.java

    diff --git src/org/openstreetmap/josm/data/oauth/OAuthParameters.java src/org/openstreetmap/josm/data/oauth/OAuthParameters.java
    index 73ed6ce10c..e82301e7ec 100644
    public final class OAuthParameters {  
    262262                if (remembered != null) {
    263263                    return remembered;
    264264                }
    265                 return createDefault(apiUrl, oAuthVersion);
     265                // Use the URL as it was given: apiUrl is the host by now, which is never a valid URL, so
     266                // createDefault() would silently fall back to the API currently configured in the preferences.
     267                return createDefault(originalApiUrl, oAuthVersion);
    266268            default:
    267269                throw new IllegalArgumentException("Unknown OAuth version: " + oAuthVersion);
    268270        }
  • src/org/openstreetmap/josm/io/OsmConnection.java

    diff --git src/org/openstreetmap/josm/io/OsmConnection.java src/org/openstreetmap/josm/io/OsmConnection.java
    index b2703c69e2..184cf2272d 100644
    import org.openstreetmap.josm.io.remotecontrol.RemoteControl;  
    3333import org.openstreetmap.josm.tools.HttpClient;
    3434import org.openstreetmap.josm.tools.JosmRuntimeException;
    3535import org.openstreetmap.josm.tools.Logging;
     36import org.openstreetmap.josm.tools.Utils;
    3637
    3738/**
    3839 * Base class that handles common things like authentication for the reader and writer
    … … public class OsmConnection {  
    146147     * @throws MissingOAuthAccessTokenException if the process cannot be completed successfully
    147148     */
    148149    private void obtainOAuth20Token() throws MissingOAuthAccessTokenException {
     150        if (Utils.isEmpty(this.oAuth20Parameters.getClientId())) {
     151            // Without a client id the authorization request is rejected by the server with "Missing required
     152            // parameter: client_id", so do not send the user to the browser at all. The message of the exception
     153            // is for the log only; what the user is shown already points at the preferences, which is where the
     154            // client id of an OAuth application registered on this server has to be entered.
     155            throw new MissingOAuthAccessTokenException(
     156                    "No OAuth client id for " + OsmApi.getOsmApi().getServerUrl() + ", cannot request a token");
     157        }
    149158        if (!Boolean.TRUE.equals(GuiHelper.runInEDTAndWaitAndReturn(() ->
    150159                ConditionalOptionPaneUtil.showConfirmationDialog("oauth.oauth20.obtain.automatically",
    151160                    MainApplication.getMainFrame(),
    … … public class OsmConnection {  
    200209     */
    201210    protected void addOAuth20AuthorizationHeader(HttpClient connection) throws OsmTransferException {
    202211        if (this.oAuth20Parameters == null) {
    203             this.oAuth20Parameters = OAuthParameters.createFromApiUrl(connection.getURL().getHost(), OAuthVersion.OAuth20);
     212            // The parameters are stored under the API URL, so look them up with the API URL and not with the
     213            // host of the request: OAuthParameters.createFromApiUrl() reduces it to the host itself where that
     214            // is what it needs.
     215            this.oAuth20Parameters = OAuthParameters.createFromApiUrl(OsmApi.getOsmApi().getServerUrl(), OAuthVersion.OAuth20);
    204216        }
    205217        OAuthAccessTokenHolder holder = OAuthAccessTokenHolder.getInstance();
    206218        IOAuthToken token = holder.getAccessToken(connection.getURL().toExternalForm(), OAuthVersion.OAuth20);
  • test/unit/org/openstreetmap/josm/data/oauth/OAuthParametersTest.java

    diff --git test/unit/org/openstreetmap/josm/data/oauth/OAuthParametersTest.java test/unit/org/openstreetmap/josm/data/oauth/OAuthParametersTest.java
    index a5ab49d8e9..24ab07f7fd 100644
    import static org.junit.jupiter.api.Assertions.assertEquals;  
    55import static org.junit.jupiter.api.Assertions.assertNotEquals;
    66import static org.junit.jupiter.api.Assertions.assertNotNull;
    77
     8import org.junit.jupiter.api.AfterEach;
    89import org.junit.jupiter.api.Test;
    910import org.openstreetmap.josm.TestUtils;
     11import org.openstreetmap.josm.io.NetworkManager;
     12import org.openstreetmap.josm.io.OnlineResource;
    1013import org.openstreetmap.josm.spi.preferences.Config;
     14import org.openstreetmap.josm.testutils.annotations.BasicPreferences;
    1115import org.openstreetmap.josm.tools.Logging;
    1216
    1317import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
    … … import nl.jqno.equalsverifier.EqualsVerifier;  
    1620/**
    1721 * Unit tests for class {@link OAuthParameters}.
    1822 */
     23@BasicPreferences
    1924class OAuthParametersTest {
     25
     26    @AfterEach
     27    void tearDown() {
     28        NetworkManager.setOnline(OnlineResource.ALL);
     29        // the API URL is static state shared with the other tests of this class
     30        Config.getPref().put("osm-server.url", null);
     31    }
     32
     33    /**
     34     * {@link OAuthParameters#createFromApiUrl} answers for the server it is asked about, whichever server is
     35     * configured in the preferences, and finds the parameters the user entered in the advanced OAuth settings.
     36     * <p>
     37     * Non-regression test for #24925: the URL was reduced to the host before the fallback to
     38     * {@link OAuthParameters#createDefault}, and a host is never a valid URL, so the defaults of the configured
     39     * API were returned instead of those of the server which was asked about.
     40     */
     41    @Test
     42    void testCreateFromApiUrlUsesTheRequestedServer() {
     43        // the RFC 8414 lookup must not make a network request
     44        NetworkManager.setOffline(OnlineResource.ALL);
     45        final String thirdParty = "https://nonprod-mapops.example.org/api";
     46        Config.getPref().put("osm-server.url", thirdParty);
     47
     48        // a server JOSM has a client id for is answered with that client id, not with the configured server's
     49        assertEquals("Hl5yIhFS-Egj6aY7A35ouLOuZl0EHjj8JJQQ46IO96E",
     50                OAuthParameters.createFromApiUrl("https://api.openhistoricalmap.org/api", OAuthVersion.OAuth20).getClientId());
     51        // a server JOSM knows nothing about has no client id until the user enters one
     52        assertEquals("", OAuthParameters.createFromApiUrl(thirdParty, OAuthVersion.OAuth20).getClientId());
     53
     54        // once the parameters are remembered - as the authentication preferences do, under the API URL - they
     55        // are found both by the API URL and by the host, which is what a connection has at hand
     56        new OAuth20Parameters("entered-client-id", null, thirdParty, thirdParty, "http://127.0.0.1:8111/oauth_authorization")
     57                .rememberPreferences();
     58        assertEquals("entered-client-id",
     59                OAuthParameters.createFromApiUrl(thirdParty, OAuthVersion.OAuth20).getClientId());
     60        assertEquals("https://api.openhistoricalmap.org/api",
     61                OAuthParameters.createFromApiUrl("https://api.openhistoricalmap.org/api", OAuthVersion.OAuth20).getApiUrl());
     62    }
     63
    2064    /**
    2165     * Unit test of method {@link OAuthParameters#createDefault}.
    2266     */
  • new file test/unit/org/openstreetmap/josm/io/OsmConnectionTest.java

    diff --git test/unit/org/openstreetmap/josm/io/OsmConnectionTest.java test/unit/org/openstreetmap/josm/io/OsmConnectionTest.java
    new file mode 100644
    index 0000000000..f742a38422
    - +  
     1// License: GPL. For details, see LICENSE file.
     2package org.openstreetmap.josm.io;
     3
     4import static org.junit.jupiter.api.Assertions.assertEquals;
     5import static org.junit.jupiter.api.Assertions.assertNotNull;
     6import static org.junit.jupiter.api.Assertions.assertNull;
     7import static org.junit.jupiter.api.Assertions.assertThrows;
     8import static org.junit.jupiter.api.Assertions.assertTrue;
     9
     10import java.net.MalformedURLException;
     11import java.net.URL;
     12
     13import javax.swing.JOptionPane;
     14
     15import org.junit.jupiter.api.AfterEach;
     16import org.junit.jupiter.api.BeforeEach;
     17import org.junit.jupiter.api.Test;
     18import org.openstreetmap.josm.data.oauth.IOAuthParameters;
     19import org.openstreetmap.josm.data.oauth.OAuth20Exception;
     20import org.openstreetmap.josm.data.oauth.OAuth20Parameters;
     21import org.openstreetmap.josm.data.oauth.OAuth20Token;
     22import org.openstreetmap.josm.data.oauth.OAuthAccessTokenHolder;
     23import org.openstreetmap.josm.data.oauth.OAuthVersion;
     24import org.openstreetmap.josm.spi.preferences.Config;
     25import org.openstreetmap.josm.testutils.annotations.BasicPreferences;
     26import org.openstreetmap.josm.tools.HttpClient;
     27
     28/**
     29 * Unit tests of {@link OsmConnection}.
     30 */
     31@BasicPreferences
     32class OsmConnectionTest {
     33
     34    private static final String API_URL = "https://nonprod-mapops.example.org/api";
     35    private static final String CLIENT_ID = "a-client-id-of-an-application-registered-on-that-server";
     36    private static final String REDIRECT_URI = "http://127.0.0.1:8111/oauth_authorization";
     37
     38    /** Gives the test access to the protected members of {@link OsmConnection} */
     39    private static final class TestConnection extends OsmConnection {
     40        IOAuthParameters parameters() {
     41            return this.oAuth20Parameters;
     42        }
     43    }
     44
     45    @BeforeEach
     46    void setUp() {
     47        // the RFC 8414 lookup of OAuthParameters must not make a network request
     48        NetworkManager.setOffline(OnlineResource.ALL);
     49        Config.getPref().put("osm-server.url", API_URL);
     50    }
     51
     52    @AfterEach
     53    void tearDown() {
     54        NetworkManager.setOnline(OnlineResource.ALL);
     55        OAuthAccessTokenHolder.getInstance().clear();
     56        // preferences and the token holder are static state shared with the other tests of this class
     57        Config.getPref().put("oauth.access-token.parameters." + OAuthVersion.OAuth20 + "." + API_URL, null);
     58        Config.getPref().put("osm-server.url", null);
     59    }
     60
     61    private static HttpClient apiRequest() throws MalformedURLException {
     62        return HttpClient.create(new URL(API_URL + "/0.6/changeset/create"), "PUT");
     63    }
     64
     65    /**
     66     * Stores the advanced OAuth parameters the way the authentication preferences do, that is under the API URL.
     67     */
     68    private static OAuth20Parameters rememberParameters() {
     69        OAuth20Parameters parameters = new OAuth20Parameters(CLIENT_ID, null, API_URL, API_URL, REDIRECT_URI);
     70        parameters.rememberPreferences();
     71        return parameters;
     72    }
     73
     74    /**
     75     * The OAuth parameters of a connection are the ones the user entered for the configured API.
     76     * <p>
     77     * Non-regression test for #24925: they were looked up with the host of the request although they are stored
     78     * under the API URL, so the lookup missed and the client id was empty - which made the authorization request
     79     * on upload fail with "Missing required parameter: client_id".
     80     * @throws Exception if the connection cannot be signed
     81     */
     82    @Test
     83    void testOAuthParametersComeFromTheApiUrl() throws Exception {
     84        OAuth20Parameters parameters = rememberParameters();
     85        // a token is needed, otherwise the connection would try to obtain one
     86        OAuthAccessTokenHolder.getInstance().setAccessToken(API_URL, new OAuth20Token(parameters,
     87                "{\"token_type\":\"bearer\", \"access_token\":\"a-token\"}"));
     88
     89        TestConnection connection = new TestConnection();
     90        assertNull(connection.parameters());
     91        HttpClient client = apiRequest();
     92        connection.addOAuth20AuthorizationHeader(client);
     93
     94        assertEquals(CLIENT_ID, connection.parameters().getClientId());
     95        assertEquals(API_URL, connection.parameters().getApiUrl());
     96        assertEquals("Bearer a-token", client.getRequestHeader("Authorization"));
     97    }
     98
     99    /**
     100     * Without a client id there is nothing to authorize with, so the user is told to configure one instead of
     101     * being sent to a browser which the server then rejects with "Missing required parameter: client_id".
     102     * <p>
     103     * The confirmation dialog is pre-answered with "No" so that the test still terminates if the check is ever
     104     * removed; what tells the two apart is that only the check names the server it gave up on.
     105     * @throws MalformedURLException never
     106     */
     107    @Test
     108    void testNoClientIdFailsBeforeAskingToOpenABrowser() throws MalformedURLException {
     109        Config.getPref().putBoolean("message.oauth.oauth20.obtain.automatically", false);
     110        Config.getPref().putInt("message.oauth.oauth20.obtain.automatically.value", JOptionPane.NO_OPTION);
     111
     112        // no parameters remembered for this API, and it has no client id built into JOSM
     113        HttpClient client = apiRequest();
     114        MissingOAuthAccessTokenException thrown = assertThrows(MissingOAuthAccessTokenException.class,
     115                () -> new TestConnection().addOAuth20AuthorizationHeader(client));
     116        assertNotNull(thrown.getMessage(), "gave up only after asking the user, rather than for the missing client id");
     117        assertTrue(thrown.getMessage().contains(API_URL), thrown.getMessage());
     118        assertNull(client.getRequestHeader("Authorization"));
     119    }
     120
     121    /**
     122     * An existing token is used without the parameters having to be stored, since the token carries its own.
     123     * @throws OAuth20Exception if the token cannot be built
     124     * @throws Exception if the connection cannot be signed
     125     */
     126    @Test
     127    void testExistingTokenSignsTheConnection() throws Exception {
     128        OAuth20Parameters parameters = new OAuth20Parameters(CLIENT_ID, null, API_URL, API_URL, REDIRECT_URI);
     129        OAuthAccessTokenHolder.getInstance().setAccessToken(API_URL, new OAuth20Token(parameters,
     130                "{\"token_type\":\"bearer\", \"access_token\":\"another-token\"}"));
     131
     132        HttpClient client = apiRequest();
     133        new TestConnection().addOAuth20AuthorizationHeader(client);
     134        assertEquals("Bearer another-token", client.getRequestHeader("Authorization"));
     135    }
     136}