1 | // License: GPL. For details, see LICENSE file.
|
---|
2 | package org.openstreetmap.josm.data.oauth;
|
---|
3 |
|
---|
4 | import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
---|
5 | import static org.junit.jupiter.api.Assertions.assertEquals;
|
---|
6 | import static org.junit.jupiter.api.Assertions.assertNotNull;
|
---|
7 | import static org.junit.jupiter.api.Assertions.assertNull;
|
---|
8 | import static org.junit.jupiter.api.Assertions.assertTrue;
|
---|
9 |
|
---|
10 | import java.io.IOException;
|
---|
11 | import java.net.URL;
|
---|
12 | import java.util.HashMap;
|
---|
13 | import java.util.Map;
|
---|
14 | import java.util.Optional;
|
---|
15 | import java.util.concurrent.atomic.AtomicReference;
|
---|
16 | import java.util.stream.Collectors;
|
---|
17 | import java.util.stream.Stream;
|
---|
18 |
|
---|
19 | import org.junit.jupiter.api.AfterEach;
|
---|
20 | import org.junit.jupiter.api.BeforeEach;
|
---|
21 | import org.junit.jupiter.api.Test;
|
---|
22 | import org.junit.jupiter.api.extension.RegisterExtension;
|
---|
23 | import org.openstreetmap.josm.data.oauth.osm.OsmScopes;
|
---|
24 | import org.openstreetmap.josm.data.preferences.JosmUrls;
|
---|
25 | import org.openstreetmap.josm.io.OsmApi;
|
---|
26 | import org.openstreetmap.josm.io.remotecontrol.RemoteControl;
|
---|
27 | import org.openstreetmap.josm.spi.preferences.Config;
|
---|
28 | import org.openstreetmap.josm.testutils.annotations.BasicPreferences;
|
---|
29 | import org.openstreetmap.josm.testutils.annotations.HTTP;
|
---|
30 | import org.openstreetmap.josm.testutils.mockers.OpenBrowserMocker;
|
---|
31 | import org.openstreetmap.josm.tools.HttpClient;
|
---|
32 | import org.openstreetmap.josm.tools.Logging;
|
---|
33 |
|
---|
34 | import com.github.tomakehurst.wiremock.client.WireMock;
|
---|
35 | import com.github.tomakehurst.wiremock.core.WireMockConfiguration;
|
---|
36 | import com.github.tomakehurst.wiremock.extension.ResponseTransformerV2;
|
---|
37 | import com.github.tomakehurst.wiremock.http.FixedDelayDistribution;
|
---|
38 | import com.github.tomakehurst.wiremock.http.HttpHeader;
|
---|
39 | import com.github.tomakehurst.wiremock.http.HttpHeaders;
|
---|
40 | import com.github.tomakehurst.wiremock.http.QueryParameter;
|
---|
41 | import com.github.tomakehurst.wiremock.http.Request;
|
---|
42 | import com.github.tomakehurst.wiremock.http.Response;
|
---|
43 | import com.github.tomakehurst.wiremock.junit5.WireMockExtension;
|
---|
44 | import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
|
---|
45 | import com.github.tomakehurst.wiremock.matching.AnythingPattern;
|
---|
46 | import com.github.tomakehurst.wiremock.matching.EqualToPattern;
|
---|
47 | import com.github.tomakehurst.wiremock.matching.StringValuePattern;
|
---|
48 | import com.github.tomakehurst.wiremock.stubbing.ServeEvent;
|
---|
49 | import mockit.Mock;
|
---|
50 | import mockit.MockUp;
|
---|
51 |
|
---|
52 | @BasicPreferences
|
---|
53 | @HTTP
|
---|
54 | class OAuth20AuthorizationTest {
|
---|
55 | private static final String RESPONSE_TYPE = "response_type";
|
---|
56 | private static final String RESPONSE_TYPE_VALUE = "code";
|
---|
57 | private static final String CLIENT_ID = "client_id";
|
---|
58 | private static final String CLIENT_ID_VALUE = "edPII614Lm0_0zEpc_QzEltA9BUll93-Y-ugRQUoHMI";
|
---|
59 | private static final String REDIRECT_URI = "redirect_uri";
|
---|
60 | private static final String REDIRECT_URI_VALUE = "http://127.0.0.1:8111/oauth_authorization";
|
---|
61 | private static final String SCOPE = "scope";
|
---|
62 | private static final String STATE = "state";
|
---|
63 | private static final String CODE_CHALLENGE_METHOD = "code_challenge_method";
|
---|
64 | private static final String CODE_CHALLENGE_METHOD_VALUE = "S256";
|
---|
65 | private static final String CODE_CHALLENGE = "code_challenge";
|
---|
66 |
|
---|
67 | private enum ConnectionProblems {
|
---|
68 | NONE,
|
---|
69 | SOCKET_TIMEOUT
|
---|
70 | }
|
---|
71 |
|
---|
72 | private static final class OAuthServerWireMock implements ResponseTransformerV2 {
|
---|
73 | String stateToReturn;
|
---|
74 | ConnectionProblems connectionProblems = ConnectionProblems.NONE;
|
---|
75 |
|
---|
76 | @Override
|
---|
77 | public Response transform(Response response, ServeEvent serveEvent) {
|
---|
78 | final var request = serveEvent.getRequest();
|
---|
79 | try {
|
---|
80 | if (request.getUrl().startsWith("/oauth2/authorize")) {
|
---|
81 | return authorizationRequest(request, response);
|
---|
82 | } else if (request.getUrl().startsWith("/oauth2/token")) {
|
---|
83 | return tokenRequest(request, response);
|
---|
84 | }
|
---|
85 | return response;
|
---|
86 | } catch (Exception e) {
|
---|
87 | // Make certain we actually see the exception in logs -- WireMock returns the error, but then our code needs to print it
|
---|
88 | Logging.error(e);
|
---|
89 | throw e;
|
---|
90 | }
|
---|
91 | }
|
---|
92 |
|
---|
93 | private Response tokenRequest(Request request, Response response) {
|
---|
94 | Map<String, String> queryParameters = Stream.of(request.getBodyAsString().split("&", -1))
|
---|
95 | .map(string -> string.split("=", -1))
|
---|
96 | .collect(Collectors.toMap(strings -> strings[0], strings -> strings[1]));
|
---|
97 | if (!queryParameters.containsKey("grant_type")
|
---|
98 | || !queryParameters.containsKey(REDIRECT_URI) || !queryParameters.containsKey(CLIENT_ID)
|
---|
99 | || !queryParameters.containsKey("code") || !queryParameters.containsKey("code_verifier")) {
|
---|
100 | return Response.Builder.like(response).but().status(500).build();
|
---|
101 | }
|
---|
102 | switch (connectionProblems) {
|
---|
103 | case SOCKET_TIMEOUT:
|
---|
104 | return Response.Builder.like(response).but().configureDelay(null, null,
|
---|
105 | 10_000, new FixedDelayDistribution(0)).build();
|
---|
106 | case NONE:
|
---|
107 | default:
|
---|
108 | return Response.Builder.like(response).but()
|
---|
109 | .body("{\"token_type\": \"bearer\", \"access_token\": \"test_access_token\"}").build();
|
---|
110 | }
|
---|
111 | }
|
---|
112 |
|
---|
113 | private Response authorizationRequest(Request request, Response response) {
|
---|
114 | final QueryParameter state = request.queryParameter(STATE);
|
---|
115 | final QueryParameter codeChallenge = request.queryParameter(CODE_CHALLENGE);
|
---|
116 | final QueryParameter redirectUri = request.queryParameter(REDIRECT_URI);
|
---|
117 | final QueryParameter responseType = request.queryParameter(RESPONSE_TYPE);
|
---|
118 | final QueryParameter scope = request.queryParameter(SCOPE);
|
---|
119 | final QueryParameter clientId = request.queryParameter(CLIENT_ID);
|
---|
120 | final QueryParameter codeChallengeMethod = request.queryParameter(CODE_CHALLENGE_METHOD);
|
---|
121 | final boolean badRequest = !(state.isPresent() && state.isSingleValued());
|
---|
122 | if (badRequest || checkQueryParameter(redirectUri, REDIRECT_URI_VALUE) || checkQueryParameter(responseType, RESPONSE_TYPE_VALUE)
|
---|
123 | || checkQueryParameter(clientId, CLIENT_ID_VALUE) || checkQueryParameter(codeChallengeMethod, CODE_CHALLENGE_METHOD_VALUE)
|
---|
124 | || checkQueryParameter(scope, "read_gpx")
|
---|
125 | || !codeChallenge.isPresent()) {
|
---|
126 | return Response.Builder.like(response).but().status(500).build();
|
---|
127 | }
|
---|
128 | return Response.Builder.like(response).but().status(307)
|
---|
129 | .headers(new HttpHeaders(new HttpHeader("Location",
|
---|
130 | redirectUri.values().get(0)
|
---|
131 | + "?state=" + (this.stateToReturn != null ? stateToReturn : state.firstValue())
|
---|
132 | + "&code=test_code"))).build();
|
---|
133 | }
|
---|
134 |
|
---|
135 | private static boolean checkQueryParameter(QueryParameter parameter, String expected) {
|
---|
136 | return !parameter.isPresent() || !parameter.isSingleValued() || !parameter.containsValue(expected);
|
---|
137 | }
|
---|
138 |
|
---|
139 | @Override
|
---|
140 | public String getName() {
|
---|
141 | return "OAuthServerWireMock";
|
---|
142 | }
|
---|
143 | }
|
---|
144 |
|
---|
145 | private static final OAuthServerWireMock oauthServer = new OAuthServerWireMock();
|
---|
146 | @RegisterExtension
|
---|
147 | static WireMockExtension wml = WireMockExtension.newInstance()
|
---|
148 | .options(WireMockConfiguration.wireMockConfig().dynamicPort().dynamicHttpsPort().extensions(oauthServer))
|
---|
149 | .build();
|
---|
150 | @BeforeEach
|
---|
151 | @AfterEach
|
---|
152 | void setup() {
|
---|
153 | // Reset the mocker
|
---|
154 | OpenBrowserMocker.getCalledURIs().clear();
|
---|
155 | RemoteControl.stop(); // Ensure remote control is stopped
|
---|
156 | oauthServer.stateToReturn = null;
|
---|
157 | oauthServer.connectionProblems = ConnectionProblems.NONE;
|
---|
158 | }
|
---|
159 |
|
---|
160 | /**
|
---|
161 | * Set up the default wiremock information
|
---|
162 | */
|
---|
163 | @BeforeEach
|
---|
164 | void setupWireMock() {
|
---|
165 | final WireMockRuntimeInfo wireMockRuntimeInfo = wml.getRuntimeInfo();
|
---|
166 | Config.getPref().put("osm-server.url", wireMockRuntimeInfo.getHttpBaseUrl() + "/api/");
|
---|
167 | new MockUp<JosmUrls>() {
|
---|
168 | @Mock
|
---|
169 | public String getDefaultOsmApiUrl() {
|
---|
170 | return wireMockRuntimeInfo.getHttpBaseUrl() + "/api/";
|
---|
171 | }
|
---|
172 | };
|
---|
173 | new OpenBrowserMocker();
|
---|
174 | final Map<String, StringValuePattern> queryParams = new HashMap<>();
|
---|
175 | queryParams.put(RESPONSE_TYPE, new EqualToPattern(RESPONSE_TYPE_VALUE));
|
---|
176 | queryParams.put(CLIENT_ID, new EqualToPattern(CLIENT_ID_VALUE));
|
---|
177 | queryParams.put(REDIRECT_URI, new EqualToPattern(REDIRECT_URI_VALUE));
|
---|
178 | queryParams.put(SCOPE, new EqualToPattern("read_gpx"));
|
---|
179 | queryParams.put(STATE, new AnythingPattern()); // This is generated via a random UUID, and we have to return this in the redirect
|
---|
180 | queryParams.put(CODE_CHALLENGE_METHOD, new EqualToPattern(CODE_CHALLENGE_METHOD_VALUE));
|
---|
181 | queryParams.put(CODE_CHALLENGE, new AnythingPattern()); // This is generated via a random UUID
|
---|
182 | wireMockRuntimeInfo.getWireMock().register(WireMock.get(WireMock.urlPathEqualTo("/oauth2/authorize")).withQueryParams(queryParams));
|
---|
183 | wireMockRuntimeInfo.getWireMock().register(WireMock.post(WireMock.urlPathEqualTo("/oauth2/token")));
|
---|
184 | }
|
---|
185 |
|
---|
186 | private HttpClient generateClient(WireMockRuntimeInfo wireMockRuntimeInfo, AtomicReference<Optional<IOAuthToken>> consumer) {
|
---|
187 | final OAuth20Authorization authorization = new OAuth20Authorization();
|
---|
188 | OAuth20Parameters parameters = (OAuth20Parameters) OAuthParameters.createDefault(OsmApi.getOsmApi().getBaseUrl(), OAuthVersion.OAuth20);
|
---|
189 | RemoteControl.start();
|
---|
190 | authorization.authorize(new OAuth20Parameters(CLIENT_ID_VALUE, parameters.getClientSecret(),
|
---|
191 | wireMockRuntimeInfo.getHttpBaseUrl() + "/oauth2", wireMockRuntimeInfo.getHttpBaseUrl() + "/api",
|
---|
192 | parameters.getRedirectUri()), consumer::set, OsmScopes.read_gpx);
|
---|
193 | assertEquals(1, OpenBrowserMocker.getCalledURIs().size());
|
---|
194 | final URL url = assertDoesNotThrow(() -> OpenBrowserMocker.getCalledURIs().get(0).toURL());
|
---|
195 | return HttpClient.create(url);
|
---|
196 | }
|
---|
197 |
|
---|
198 | @Test
|
---|
199 | void testAuthorize() throws IOException {
|
---|
200 | final AtomicReference<Optional<IOAuthToken>> consumer = new AtomicReference<>();
|
---|
201 | final HttpClient client = generateClient(wml.getRuntimeInfo(), consumer);
|
---|
202 | try {
|
---|
203 | HttpClient.Response response = client.connect();
|
---|
204 | assertEquals(200, response.getResponseCode());
|
---|
205 | } finally {
|
---|
206 | client.disconnect();
|
---|
207 | }
|
---|
208 | assertNotNull(consumer.get());
|
---|
209 | assertTrue(consumer.get().isPresent());
|
---|
210 | assertEquals(OAuthVersion.OAuth20, consumer.get().get().getOAuthType());
|
---|
211 | OAuth20Token token = (OAuth20Token) consumer.get().get();
|
---|
212 | assertEquals("test_access_token", token.getBearerToken());
|
---|
213 | }
|
---|
214 |
|
---|
215 | @Test
|
---|
216 | void testAuthorizeBadState() throws IOException {
|
---|
217 | oauthServer.stateToReturn = "Bad_State";
|
---|
218 | final AtomicReference<Optional<IOAuthToken>> consumer = new AtomicReference<>();
|
---|
219 | final HttpClient client = generateClient(wml.getRuntimeInfo(), consumer);
|
---|
220 | try {
|
---|
221 | HttpClient.Response response = client.connect();
|
---|
222 | assertEquals(400, response.getResponseCode());
|
---|
223 | String content = response.fetchContent();
|
---|
224 | assertTrue(content.contains("Unknown state for authorization"));
|
---|
225 | } finally {
|
---|
226 | client.disconnect();
|
---|
227 | }
|
---|
228 | assertNull(consumer.get(), "The OAuth consumer should not be called since the state does not match");
|
---|
229 | }
|
---|
230 |
|
---|
231 | @Test
|
---|
232 | void testSocketTimeout() throws Exception {
|
---|
233 | // 1s before timeout
|
---|
234 | Config.getPref().putInt("socket.timeout.connect", 1);
|
---|
235 | Config.getPref().putInt("socket.timeout.read", 1);
|
---|
236 | oauthServer.connectionProblems = ConnectionProblems.SOCKET_TIMEOUT;
|
---|
237 |
|
---|
238 | final AtomicReference<Optional<IOAuthToken>> consumer = new AtomicReference<>();
|
---|
239 | final HttpClient client = generateClient(wml.getRuntimeInfo(), consumer)
|
---|
240 | .setConnectTimeout(15_000).setReadTimeout(30_000);
|
---|
241 | try {
|
---|
242 | HttpClient.Response response = client.connect();
|
---|
243 | assertEquals(500, response.getResponseCode());
|
---|
244 | String content = response.fetchContent();
|
---|
245 | assertTrue(content.contains("java.net.SocketTimeoutException: Read timed out"));
|
---|
246 | } finally {
|
---|
247 | client.disconnect();
|
---|
248 | }
|
---|
249 | assertEquals(Optional.empty(), consumer.get());
|
---|
250 | }
|
---|
251 | }
|
---|