Ticket #24890: 0001-OAuth-2-honour-user-supplied-parameters-for-servers-.patch

File 0001-OAuth-2-honour-user-supplied-parameters-for-servers-.patch, 14.4 KB (added by wangi, 4 days ago)
  • src/org/openstreetmap/josm/data/oauth/OAuth20Authorization.java

    From 8c652b61a172830e755acdaf93a112975c4d842a Mon Sep 17 00:00:00 2001
    Date: Sat, 12 Sep 2026 11:05:37 +0100
    Subject: [PATCH] OAuth 2: honour user-supplied parameters for servers JOSM
     does not know
    
    For an API URL without a built-in client id, the advanced OAuth panel lets
    the user enter the client id (and secret) of an OAuth 2 application they
    registered on that server - but nothing consumed them. Three defects:
    
    1. AuthoriseNowAction.updateEnabledState() asked createFromApiUrl() for a
       client id, which only knows stored tokens and the built-in list, so
       'Authorize now (Fully automatic)' stayed disabled however the panel was
       filled in - although the wizard is handed exactly the panel's parameters
       when pressed. The action now uses the panel's client id whenever 'Use
       default settings' is off, and is re-evaluated as the field or the
       checkbox changes.
    
    2. AdvancedOAuthPropertiesPanel.rememberPreferences() saved custom
       parameters under oauth.access-token.parameters.OAuth20.<apiUrl>, but no
       code read that key: on the next start initialize() got the empty default
       back and re-ticked 'Use default settings', losing the client id unless a
       token had been obtained. createFromApiUrl() now reads the remembered
       parameters before falling back to the defaults.
    
    3. The token exchange never sent the client secret, so a confidential
       application - the default when registering one on openstreetmap-website -
       was refused with a 401 whose empty body then failed as JSON in
       OAuth20Token. The secret is sent when the panel has one, and a rejected
       exchange now reports the HTTP status and the server's message.
    
    Tested against an openstreetmap-website instance not in the built-in list
    (lugus.opengeofiction.net) with user-registered applications, confidential
    with id and secret, and public with id alone: the button enables, the
    automatic flow completes and uploads work, and the parameters survive a
    restart.
    ---
     .../josm/data/oauth/OAuth20Authorization.java | 13 +++++
     .../josm/data/oauth/OAuthParameters.java      | 37 ++++++++++++++
     .../oauth/AdvancedOAuthPropertiesPanel.java   | 48 +++++++++++++++++++
     .../OAuthAuthenticationPreferencesPanel.java  | 36 ++++++++++----
     4 files changed, 124 insertions(+), 10 deletions(-)
    
    diff --git a/src/org/openstreetmap/josm/data/oauth/OAuth20Authorization.java b/src/org/openstreetmap/josm/data/oauth/OAuth20Authorization.java
    index f5d039f9ad..ac3c3f27c7 100644
    a b import org.openstreetmap.josm.gui.widgets.HtmlPanel;  
    2727import org.openstreetmap.josm.io.remotecontrol.handler.AuthorizationHandler;
    2828import org.openstreetmap.josm.io.remotecontrol.handler.RequestHandler;
    2929import org.openstreetmap.josm.tools.HttpClient;
     30import org.openstreetmap.josm.tools.Utils;
    3031import org.openstreetmap.josm.tools.JosmRuntimeException;
    3132import org.openstreetmap.josm.tools.OpenBrowser;
    3233
    public class OAuth20Authorization implements IOAuthAuthorization {  
    125126            String code = args.get("code");
    126127            try {
    127128                HttpClient tradeCodeForToken = HttpClient.create(new URL(parameters.getAccessTokenUrl()), "POST");
     129                // A confidential client (the default when registering an application on
     130                // openstreetmap-website) must present its secret at the token endpoint;
     131                // the built-in OSM application is a public client and has none.
     132                String clientSecret = parameters.getClientSecret();
    128133                tradeCodeForToken.setRequestBody(("grant_type=authorization_code&client_id=" + parameters.getClientId()
     134                        + (!Utils.isEmpty(clientSecret) ? "&client_secret=" + Utils.encodeUrl(clientSecret) : "")
    129135                        + "&redirect_uri=" + parameters.getRedirectUri()
    130136                        + "&code=" + code
    131137                        + (this.codeVerifier != null ? "&code_verifier=" + this.codeVerifier : "")
    public class OAuth20Authorization implements IOAuthAuthorization {  
    134140                try {
    135141                    tradeCodeForToken.connect();
    136142                    HttpClient.Response response = tradeCodeForToken.getResponse();
     143                    if (response.getResponseCode() >= 400) {
     144                        // Doorkeeper answers a rejected exchange with a JSON error body, but an
     145                        // authentication failure can come back with none - say what the server said
     146                        String body = response.fetchContent();
     147                        throw new OAuth20Exception(tr("The server refused the token request: HTTP {0} {1}",
     148                                response.getResponseCode(), Utils.isEmpty(body) ? response.getResponseMessage() : body));
     149                    }
    137150                    OAuth20Token oAuth20Token = new OAuth20Token(parameters, response.getContentReader());
    138151                    consumer.accept(Optional.of(oAuth20Token));
    139152                } catch (IOException | OAuth20Exception e) {
  • src/org/openstreetmap/josm/data/oauth/OAuthParameters.java

    diff --git a/src/org/openstreetmap/josm/data/oauth/OAuthParameters.java b/src/org/openstreetmap/josm/data/oauth/OAuthParameters.java
    index c829efbcac..dd7109337f 100644
    a b public final class OAuthParameters {  
    7878        }
    7979    }
    8080
     81    /**
     82     * Get the OAuth 2.0 parameters the user entered in the advanced OAuth settings and saved with
     83     * {@link IOAuthParameters#rememberPreferences()}. Until a token exists, this is the only place a
     84     * client id for a server not in {@link #createDefault(String, OAuthVersion)} can come from.
     85     * {@link OAuth20Parameters#rememberPreferences()} keys by the API URL as given; callers may pass
     86     * either the URL or its host, so both are tried.
     87     * @param keys The preference key suffixes to try, in order
     88     * @return The remembered parameters, or {@code null} if none are saved or they cannot be parsed
     89     * @since xxx
     90     */
     91    private static IOAuthParameters createFromPreferences(String... keys) {
     92        for (String key : keys) {
     93            if (Utils.isEmpty(key)) {
     94                continue;
     95            }
     96            final String json = Config.getPref().get("oauth.access-token.parameters." + OAuthVersion.OAuth20 + "." + key, null);
     97            if (Utils.isEmpty(json)) {
     98                continue;
     99            }
     100            try {
     101                OAuth20Parameters parameters = new OAuth20Parameters(json);
     102                if (!Utils.isEmpty(parameters.getClientId())) {
     103                    return parameters;
     104                }
     105            } catch (IllegalArgumentException | NullPointerException | JsonParsingException e) {
     106                // A preference written by hand, or by an older JOSM with different fields
     107                Logging.trace(e);
     108            }
     109        }
     110        return null;
     111    }
     112
    81113    private static JsonObject getRFC8414Parameters(String apiUrl) {
    82114        HttpClient client = null;
    83115        try {
    public final class OAuthParameters {  
    199231     * @since 18650
    200232     */
    201233    public static IOAuthParameters createFromApiUrl(String apiUrl, OAuthVersion oAuthVersion) {
     234        final String originalApiUrl = apiUrl;
    202235        // We actually need the host
    203236        if (apiUrl.startsWith("https://") || apiUrl.startsWith("http://")) {
    204237            try {
    public final class OAuthParameters {  
    218251                } catch (CredentialsAgentException e) {
    219252                    Logging.trace(e);
    220253                }
     254                IOAuthParameters remembered = createFromPreferences(originalApiUrl, apiUrl);
     255                if (remembered != null) {
     256                    return remembered;
     257                }
    221258                return createDefault(apiUrl, oAuthVersion);
    222259            default:
    223260                throw new IllegalArgumentException("Unknown OAuth version: " + oAuthVersion);
  • src/org/openstreetmap/josm/gui/oauth/AdvancedOAuthPropertiesPanel.java

    diff --git a/src/org/openstreetmap/josm/gui/oauth/AdvancedOAuthPropertiesPanel.java b/src/org/openstreetmap/josm/gui/oauth/AdvancedOAuthPropertiesPanel.java
    index a1c1cf8c0e..c07549bb62 100644
    a b import javax.swing.BorderFactory;  
    1414import javax.swing.JCheckBox;
    1515import javax.swing.JLabel;
    1616import javax.swing.JOptionPane;
     17import javax.swing.event.DocumentEvent;
     18import javax.swing.event.DocumentListener;
    1719
    1820import org.openstreetmap.josm.data.oauth.IOAuthParameters;
    1921import org.openstreetmap.josm.data.oauth.OAuth20Parameters;
    import org.openstreetmap.josm.tools.ImageProvider;  
    4749 * @since 2746
    4850 */
    4951public class AdvancedOAuthPropertiesPanel extends VerticallyScrollablePanel {
     52    /**
     53     * Property fired whenever the parameters this panel would return change: the client id was edited,
     54     * or "use default settings" was toggled. Listeners re-read {@link #getAdvancedParameters()}.
     55     * @since xxx
     56     */
     57    public static final String PARAMETERS_CHANGED_PROP = AdvancedOAuthPropertiesPanel.class.getName() + ".parametersChanged";
    5058
    5159    private final JCheckBox cbUseDefaults = new JCheckBox(tr("Use default settings"));
    5260    private final JosmTextField tfConsumerKey = new JosmTextField();
    public class AdvancedOAuthPropertiesPanel extends VerticallyScrollablePanel {  
    137145        ilUseDefault = new UseDefaultItemListener();
    138146        cbUseDefaults.addItemListener(ilUseDefault);
    139147        cbUseDefaults.setSelected(Config.getPref().getBoolean("oauth.settings.use-default", true));
     148        cbUseDefaults.addItemListener(e -> fireParametersChanged());
     149        tfConsumerKey.getDocument().addDocumentListener(new DocumentListener() {
     150            @Override
     151            public void insertUpdate(DocumentEvent e) {
     152                fireParametersChanged();
     153            }
     154
     155            @Override
     156            public void removeUpdate(DocumentEvent e) {
     157                fireParametersChanged();
     158            }
     159
     160            @Override
     161            public void changedUpdate(DocumentEvent e) {
     162                fireParametersChanged();
     163            }
     164        });
     165    }
     166
     167    private void fireParametersChanged() {
     168        firePropertyChange(PARAMETERS_CHANGED_PROP, null, null);
     169    }
     170
     171    /**
     172     * Determines if the panel is set to use the default parameters for the API URL rather than
     173     * the values entered in its fields.
     174     * @return {@code true} if "use default settings" is selected
     175     * @since xxx
     176     */
     177    public boolean isUseDefaultSettings() {
     178        return cbUseDefaults.isSelected();
     179    }
     180
     181    /**
     182     * Get the client id as entered in the panel, without falling back to the defaults.
     183     * @return the client id field's text
     184     * @since xxx
     185     */
     186    public String getEnteredClientId() {
     187        return tfConsumerKey.getText();
    140188    }
    141189
    142190    protected boolean hasCustomSettings() {
  • src/org/openstreetmap/josm/gui/preferences/server/OAuthAuthenticationPreferencesPanel.java

    diff --git a/src/org/openstreetmap/josm/gui/preferences/server/OAuthAuthenticationPreferencesPanel.java b/src/org/openstreetmap/josm/gui/preferences/server/OAuthAuthenticationPreferencesPanel.java
    index dde4ca262f..1ceeea0347 100644
    a b public class OAuthAuthenticationPreferencesPanel extends JPanel implements Prope  
    109109                )
    110110        );
    111111        pnlAdvancedProperties.setVisible(false);
     112        pnlAdvancedProperties.addPropertyChangeListener(this);
    112113        return pnl;
    113114    }
    114115
     116    /**
     117     * Re-evaluate whether the "Authorize now" buttons can be enabled, after the API URL or the
     118     * advanced OAuth parameters changed.
     119     */
     120    private void updateAuthoriseNowActions() {
     121        for (JPanel panel : Arrays.asList(this.pnlNotYetAuthorised, (JPanel) this.pnlAlreadyAuthorised.getComponent(6))) {
     122            for (Component component : panel.getComponents()) {
     123                if (component instanceof JButton && ((JButton) component).getAction() instanceof AuthoriseNowAction) {
     124                    ((AuthoriseNowAction) ((JButton) component).getAction()).updateEnabledState();
     125                }
     126            }
     127        }
     128    }
     129
    115130    /**
    116131     * builds the UI
    117132     */
    public class OAuthAuthenticationPreferencesPanel extends JPanel implements Prope  
    148163    public void setApiUrl(String apiUrl) {
    149164        this.apiUrl = apiUrl;
    150165        pnlAdvancedProperties.setApiUrl(apiUrl);
    151         for (JPanel panel : Arrays.asList(this.pnlNotYetAuthorised, (JPanel) this.pnlAlreadyAuthorised.getComponent(6))) {
    152             for (Component component : panel.getComponents()) {
    153                 if (component instanceof JButton && ((JButton) component).getAction() instanceof AuthoriseNowAction) {
    154                     ((AuthoriseNowAction) ((JButton) component).getAction()).updateEnabledState();
    155                 }
    156             }
    157         }
     166        updateAuthoriseNowActions();
    158167    }
    159168
    160169    /**
    public class OAuthAuthenticationPreferencesPanel extends JPanel implements Prope  
    318327        void updateEnabledState() {
    319328            if (procedure == AuthorizationProcedure.MANUALLY) {
    320329                this.setEnabled(true);
     330            } else if (!pnlAdvancedProperties.isUseDefaultSettings()) {
     331                // The user supplied the parameters - the client id of an OAuth application they
     332                // registered on the server themselves. The wizard is given exactly these parameters
     333                // when the button is pressed, so they decide whether it can be pressed.
     334                this.setEnabled(!Utils.isEmpty(pnlAdvancedProperties.getEnteredClientId()));
    321335            } else if (Utils.isValidUrl(apiUrl)) {
    322336                final URI apiURI;
    323337                try {
    public class OAuthAuthenticationPreferencesPanel extends JPanel implements Prope  
    420434
    421435    @Override
    422436    public void propertyChange(PropertyChangeEvent evt) {
    423         if (!evt.getPropertyName().equals(OsmApiUrlInputPanel.API_URL_PROP))
    424             return;
    425         setApiUrl((String) evt.getNewValue());
     437        if (AdvancedOAuthPropertiesPanel.PARAMETERS_CHANGED_PROP.equals(evt.getPropertyName())) {
     438            updateAuthoriseNowActions();
     439        } else if (OsmApiUrlInputPanel.API_URL_PROP.equals(evt.getPropertyName())) {
     440            setApiUrl((String) evt.getNewValue());
     441        }
    426442    }
    427443}