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;
|
| 27 | 27 | import org.openstreetmap.josm.io.remotecontrol.handler.AuthorizationHandler; |
| 28 | 28 | import org.openstreetmap.josm.io.remotecontrol.handler.RequestHandler; |
| 29 | 29 | import org.openstreetmap.josm.tools.HttpClient; |
| | 30 | import org.openstreetmap.josm.tools.Utils; |
| 30 | 31 | import org.openstreetmap.josm.tools.JosmRuntimeException; |
| 31 | 32 | import org.openstreetmap.josm.tools.OpenBrowser; |
| 32 | 33 | |
| … |
… |
public class OAuth20Authorization implements IOAuthAuthorization {
|
| 125 | 126 | String code = args.get("code"); |
| 126 | 127 | try { |
| 127 | 128 | 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(); |
| 128 | 133 | tradeCodeForToken.setRequestBody(("grant_type=authorization_code&client_id=" + parameters.getClientId() |
| | 134 | + (!Utils.isEmpty(clientSecret) ? "&client_secret=" + Utils.encodeUrl(clientSecret) : "") |
| 129 | 135 | + "&redirect_uri=" + parameters.getRedirectUri() |
| 130 | 136 | + "&code=" + code |
| 131 | 137 | + (this.codeVerifier != null ? "&code_verifier=" + this.codeVerifier : "") |
| … |
… |
public class OAuth20Authorization implements IOAuthAuthorization {
|
| 134 | 140 | try { |
| 135 | 141 | tradeCodeForToken.connect(); |
| 136 | 142 | 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 | } |
| 137 | 150 | OAuth20Token oAuth20Token = new OAuth20Token(parameters, response.getContentReader()); |
| 138 | 151 | consumer.accept(Optional.of(oAuth20Token)); |
| 139 | 152 | } catch (IOException | OAuth20Exception e) { |
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 {
|
| 78 | 78 | } |
| 79 | 79 | } |
| 80 | 80 | |
| | 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 | |
| 81 | 113 | private static JsonObject getRFC8414Parameters(String apiUrl) { |
| 82 | 114 | HttpClient client = null; |
| 83 | 115 | try { |
| … |
… |
public final class OAuthParameters {
|
| 199 | 231 | * @since 18650 |
| 200 | 232 | */ |
| 201 | 233 | public static IOAuthParameters createFromApiUrl(String apiUrl, OAuthVersion oAuthVersion) { |
| | 234 | final String originalApiUrl = apiUrl; |
| 202 | 235 | // We actually need the host |
| 203 | 236 | if (apiUrl.startsWith("https://") || apiUrl.startsWith("http://")) { |
| 204 | 237 | try { |
| … |
… |
public final class OAuthParameters {
|
| 218 | 251 | } catch (CredentialsAgentException e) { |
| 219 | 252 | Logging.trace(e); |
| 220 | 253 | } |
| | 254 | IOAuthParameters remembered = createFromPreferences(originalApiUrl, apiUrl); |
| | 255 | if (remembered != null) { |
| | 256 | return remembered; |
| | 257 | } |
| 221 | 258 | return createDefault(apiUrl, oAuthVersion); |
| 222 | 259 | default: |
| 223 | 260 | throw new IllegalArgumentException("Unknown OAuth version: " + oAuthVersion); |
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;
|
| 14 | 14 | import javax.swing.JCheckBox; |
| 15 | 15 | import javax.swing.JLabel; |
| 16 | 16 | import javax.swing.JOptionPane; |
| | 17 | import javax.swing.event.DocumentEvent; |
| | 18 | import javax.swing.event.DocumentListener; |
| 17 | 19 | |
| 18 | 20 | import org.openstreetmap.josm.data.oauth.IOAuthParameters; |
| 19 | 21 | import org.openstreetmap.josm.data.oauth.OAuth20Parameters; |
| … |
… |
import org.openstreetmap.josm.tools.ImageProvider;
|
| 47 | 49 | * @since 2746 |
| 48 | 50 | */ |
| 49 | 51 | public 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"; |
| 50 | 58 | |
| 51 | 59 | private final JCheckBox cbUseDefaults = new JCheckBox(tr("Use default settings")); |
| 52 | 60 | private final JosmTextField tfConsumerKey = new JosmTextField(); |
| … |
… |
public class AdvancedOAuthPropertiesPanel extends VerticallyScrollablePanel {
|
| 137 | 145 | ilUseDefault = new UseDefaultItemListener(); |
| 138 | 146 | cbUseDefaults.addItemListener(ilUseDefault); |
| 139 | 147 | 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(); |
| 140 | 188 | } |
| 141 | 189 | |
| 142 | 190 | protected boolean hasCustomSettings() { |
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
|
| 109 | 109 | ) |
| 110 | 110 | ); |
| 111 | 111 | pnlAdvancedProperties.setVisible(false); |
| | 112 | pnlAdvancedProperties.addPropertyChangeListener(this); |
| 112 | 113 | return pnl; |
| 113 | 114 | } |
| 114 | 115 | |
| | 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 | |
| 115 | 130 | /** |
| 116 | 131 | * builds the UI |
| 117 | 132 | */ |
| … |
… |
public class OAuthAuthenticationPreferencesPanel extends JPanel implements Prope
|
| 148 | 163 | public void setApiUrl(String apiUrl) { |
| 149 | 164 | this.apiUrl = apiUrl; |
| 150 | 165 | 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(); |
| 158 | 167 | } |
| 159 | 168 | |
| 160 | 169 | /** |
| … |
… |
public class OAuthAuthenticationPreferencesPanel extends JPanel implements Prope
|
| 318 | 327 | void updateEnabledState() { |
| 319 | 328 | if (procedure == AuthorizationProcedure.MANUALLY) { |
| 320 | 329 | 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())); |
| 321 | 335 | } else if (Utils.isValidUrl(apiUrl)) { |
| 322 | 336 | final URI apiURI; |
| 323 | 337 | try { |
| … |
… |
public class OAuthAuthenticationPreferencesPanel extends JPanel implements Prope
|
| 420 | 434 | |
| 421 | 435 | @Override |
| 422 | 436 | 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 | } |
| 426 | 442 | } |
| 427 | 443 | } |