Skip to content

Commit fa165c7

Browse files
Merge branch 'release/0.3.0'
2 parents 981cdb1 + c79877e commit fa165c7

File tree

5 files changed

+133
-6
lines changed

5 files changed

+133
-6
lines changed

pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
<modelVersion>4.0.0</modelVersion>
66
<groupId>io.github.coffeelibs</groupId>
77
<artifactId>tiny-oauth2-client</artifactId>
8-
<version>0.2.0</version>
8+
<version>0.3.0</version>
99
<name>Tiny OAuth2 Client</name>
1010
<description>Zero Dependency RFC 8252 Authorization Flow</description>
1111
<inceptionYear>2022</inceptionYear>

src/main/java/io/github/coffeelibs/tinyoauth2client/AuthFlow.java

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import io.github.coffeelibs.tinyoauth2client.http.RedirectTarget;
44
import io.github.coffeelibs.tinyoauth2client.http.Response;
55
import io.github.coffeelibs.tinyoauth2client.util.RandomUtil;
6+
import org.jetbrains.annotations.ApiStatus;
67
import org.jetbrains.annotations.Blocking;
78
import org.jetbrains.annotations.Contract;
89
import org.jetbrains.annotations.VisibleForTesting;
@@ -14,6 +15,7 @@
1415
import java.net.http.HttpRequest;
1516
import java.net.http.HttpResponse;
1617
import java.nio.charset.StandardCharsets;
18+
import java.util.Arrays;
1719
import java.util.Set;
1820
import java.util.concurrent.ForkJoinPool;
1921
import java.util.function.Consumer;
@@ -27,6 +29,7 @@
2729
* @see <a href="https://datatracker.ietf.org/doc/html/rfc6749">RFC 6749</a>
2830
* @see <a href="https://datatracker.ietf.org/doc/html/rfc7636">RFC 7636</a>
2931
*/
32+
@ApiStatus.Experimental
3033
public class AuthFlow {
3134

3235
@VisibleForTesting
@@ -127,6 +130,7 @@ public AuthFlowWithCode authorize(URI authEndpoint, Consumer<URI> browser, Strin
127130
* @param ports TCP port(s) to attempt to bind to and use in the loopback redirect URI
128131
* @return The authentication flow that is now in possession of an authorization code
129132
* @throws IOException In case of I/O errors during communication between browser and this application
133+
* @see <a href="https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1">RFC 6749 Section 4.1.1: Authorization Request</a>
130134
*/
131135
@Blocking
132136
public AuthFlowWithCode authorize(URI authEndpoint, Consumer<URI> browser, Set<String> scopes, String path, int... ports) throws IOException {
@@ -139,7 +143,6 @@ public AuthFlowWithCode authorize(URI authEndpoint, Consumer<URI> browser, Set<S
139143
}
140144
var encodedRedirectUri = URLEncoder.encode(redirectTarget.getRedirectUri().toASCIIString(), StandardCharsets.US_ASCII);
141145

142-
// https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1
143146
StringBuilder queryString = new StringBuilder();
144147
if (authEndpoint.getRawQuery() != null) {
145148
queryString.append(authEndpoint.getRawQuery());
@@ -163,6 +166,39 @@ public AuthFlowWithCode authorize(URI authEndpoint, Consumer<URI> browser, Set<S
163166
}
164167
}
165168

169+
/**
170+
* Refreshes an access token using the given {@code refreshToken}.
171+
*
172+
* @param tokenEndpoint The URI of the <a href="https://datatracker.ietf.org/doc/html/rfc6749#section-3.2">Token Endpoint</a>
173+
* @param refreshToken The refresh token
174+
* @param scopes The desired access token <a href="https://datatracker.ietf.org/doc/html/rfc6749#section-3.3">scopes</a>
175+
* @return The raw <a href="https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.4">Access Token Response</a>
176+
* @throws IOException In case of I/O errors when communicating with the token endpoint
177+
* @throws InterruptedException When this thread is interrupted before a response is received
178+
* @see <a href="https://datatracker.ietf.org/doc/html/rfc6749#section-6">RFC 6749 Section 6: Refreshing an Access Token</a>
179+
*/
180+
@Blocking
181+
public String refresh(URI tokenEndpoint, String refreshToken, String... scopes) throws IOException, InterruptedException {
182+
StringBuilder requestBody = new StringBuilder();
183+
requestBody.append("grant_type=refresh_token");
184+
requestBody.append("&client_id=").append(clientId);
185+
requestBody.append("&refresh_token=").append(URLEncoder.encode(refreshToken, StandardCharsets.US_ASCII));
186+
if (scopes.length > 0) {
187+
requestBody.append("&scope=");
188+
requestBody.append(Arrays.stream(scopes).map(s -> URLEncoder.encode(s, StandardCharsets.US_ASCII)).collect(Collectors.joining("+")));
189+
}
190+
var request = HttpRequest.newBuilder(tokenEndpoint) //
191+
.header("Content-Type", "application/x-www-form-urlencoded") //
192+
.POST(HttpRequest.BodyPublishers.ofString(requestBody.toString())) //
193+
.build();
194+
HttpResponse<String> response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
195+
if (response.statusCode() == 200) {
196+
return response.body();
197+
} else {
198+
throw new IOException("Unexpected HTTP response code " + response.statusCode());
199+
}
200+
}
201+
166202
/**
167203
* The successfully authenticated authentication flow, ready to retrieve an access token.
168204
*/

src/main/java/io/github/coffeelibs/tinyoauth2client/http/RedirectTarget.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
*/
3232
public class RedirectTarget implements Closeable {
3333

34-
private static final InetAddress LOOPBACK_ADDR = InetAddress.getLoopbackAddress();
34+
static final InetAddress LOOPBACK_ADDR = InetAddress.getLoopbackAddress();
3535

3636
private final ServerSocketChannel serverChannel;
3737
private final String path;

src/test/java/io/github/coffeelibs/tinyoauth2client/AuthFlowTest.java

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,84 @@ public void testAuthorize() throws IOException {
189189

190190
}
191191

192+
@Nested
193+
@DisplayName("refresh(...)")
194+
public class RefreshTokens {
195+
196+
private AuthFlow authFlow;
197+
private HttpClient httpClient;
198+
private HttpResponse<String> httpRespone;
199+
private MockedStatic<HttpClient> httpClientClass;
200+
201+
@BeforeEach
202+
@SuppressWarnings("unchecked")
203+
public void setup() throws IOException, InterruptedException {
204+
authFlow = AuthFlow.asClient("my-client");
205+
206+
httpClient = Mockito.mock(HttpClient.class);
207+
httpRespone = Mockito.mock(HttpResponse.class);
208+
httpClientClass = Mockito.mockStatic(HttpClient.class);
209+
210+
httpClientClass.when(HttpClient::newHttpClient).thenReturn(httpClient);
211+
Mockito.doReturn(httpRespone).when(httpClient).send(Mockito.any(), Mockito.any());
212+
}
213+
214+
@AfterEach
215+
public void tearDown() {
216+
httpClientClass.close();
217+
}
218+
219+
@Test
220+
@DisplayName("body contains all params")
221+
public void testRefresh() throws IOException, InterruptedException {
222+
Mockito.doReturn(200).when(httpRespone).statusCode();
223+
var tokenEndpoint = URI.create("http://example.com/oauth2/token");
224+
var bodyCaptor = ArgumentCaptor.forClass(String.class);
225+
var bodyPublisher = Mockito.mock(HttpRequest.BodyPublisher.class);
226+
try (var bodyPublishersClass = Mockito.mockStatic(HttpRequest.BodyPublishers.class)) {
227+
bodyPublishersClass.when(() -> HttpRequest.BodyPublishers.ofString(Mockito.any())).thenReturn(bodyPublisher);
228+
229+
authFlow.refresh(tokenEndpoint, "r3fr3sh70k3n", "offline_access");
230+
231+
bodyPublishersClass.verify(() -> HttpRequest.BodyPublishers.ofString(bodyCaptor.capture()));
232+
}
233+
var body = bodyCaptor.getValue();
234+
var params = URIUtil.parseQueryString(body);
235+
Assertions.assertEquals("refresh_token", params.get("grant_type"));
236+
Assertions.assertEquals(authFlow.clientId, params.get("client_id"));
237+
Assertions.assertEquals("r3fr3sh70k3n", params.get("refresh_token"));
238+
Assertions.assertEquals("offline_access", params.get("scope"));
239+
}
240+
241+
@Test
242+
@DisplayName("send POST request to token endpoint")
243+
public void testGetAccessToken200() throws IOException, InterruptedException {
244+
Mockito.doReturn(200).when(httpRespone).statusCode();
245+
Mockito.doReturn("BODY").when(httpRespone).body();
246+
var requestCaptor = ArgumentCaptor.forClass(HttpRequest.class);
247+
var tokenEndpoint = URI.create("http://example.com/oauth2/token");
248+
249+
var result = authFlow.refresh(tokenEndpoint, "r3fr3sh70k3n");
250+
251+
Assertions.assertEquals("BODY", result);
252+
Mockito.verify(httpClient).send(requestCaptor.capture(), Mockito.any());
253+
var request = requestCaptor.getValue();
254+
Assertions.assertSame(tokenEndpoint, request.uri());
255+
Assertions.assertEquals("POST", request.method());
256+
Assertions.assertEquals("application/x-www-form-urlencoded", request.headers().firstValue("Content-Type").orElse(null));
257+
}
258+
259+
@Test
260+
@DisplayName("non-success response from token endpoint leads to IOException")
261+
public void testGetAccessToken404() {
262+
Mockito.doReturn(404).when(httpRespone).statusCode();
263+
var tokenEndpoint = URI.create("http://example.com/oauth2/token");
264+
265+
Assertions.assertThrows(IOException.class, () -> authFlow.refresh(tokenEndpoint, "r3fr3sh70k3n"));
266+
}
267+
268+
}
269+
192270
@Nested
193271
@DisplayName("After receiving auth code")
194272
public class WithAuthCode {
@@ -224,9 +302,9 @@ public void testGetAccessTokenQuery() throws IOException, InterruptedException {
224302
Mockito.doReturn(200).when(httpRespone).statusCode();
225303
var tokenEndpoint = URI.create("http://example.com/oauth2/token");
226304
var bodyCaptor = ArgumentCaptor.forClass(String.class);
227-
var replacementBody = HttpRequest.BodyPublishers.ofString("foo");
305+
var bodyPublisher = Mockito.mock(HttpRequest.BodyPublisher.class);
228306
try (var bodyPublishersClass = Mockito.mockStatic(HttpRequest.BodyPublishers.class)) {
229-
bodyPublishersClass.when(() -> HttpRequest.BodyPublishers.ofString(Mockito.any())).thenReturn(replacementBody);
307+
bodyPublishersClass.when(() -> HttpRequest.BodyPublishers.ofString(Mockito.any())).thenReturn(bodyPublisher);
230308

231309
authFlowWithCode.getAccessToken(tokenEndpoint);
232310

src/test/java/io/github/coffeelibs/tinyoauth2client/http/RedirectTargetTest.java

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,20 @@ public void testStartExceptionally() throws IOException {
8585
Mockito.verify(ch).close();
8686
}
8787

88+
@Test
89+
@DisplayName("tryBind(...) uses fallback port")
90+
public void testTryBind() throws IOException {
91+
var ch = Mockito.mock(ServerSocketChannel.class);
92+
Mockito.doThrow(new AlreadyBoundException()) // first attempt fails
93+
.doThrow(new AlreadyBoundException()) // second attempt fails
94+
.doReturn(ch) // third attempt succeeds
95+
.when(ch).bind(Mockito.any());
96+
97+
Assertions.assertDoesNotThrow(() -> RedirectTarget.tryBind(ch, 17, 23, 42));
98+
99+
Mockito.verify(ch).bind(new InetSocketAddress(RedirectTarget.LOOPBACK_ADDR, 42));
100+
}
101+
88102
@Test
89103
@DisplayName("bind() to system-assigned port")
90104
public void testBindToSystemAssignedPort() throws IOException {
@@ -257,7 +271,6 @@ public void testInterrupt() throws IOException, InterruptedException {
257271
redirect.receive();
258272
} catch (IOException e) {
259273
exception.set(e);
260-
throw new UncheckedIOException(e);
261274
} finally {
262275
threadExited.countDown();
263276
}

0 commit comments

Comments
 (0)