Skip to content

Commit

Permalink
Merge branch 'release/0.3.0'
Browse files Browse the repository at this point in the history
  • Loading branch information
overheadhunter committed Apr 29, 2022
2 parents 981cdb1 + c79877e commit fa165c7
Show file tree
Hide file tree
Showing 5 changed files with 133 additions and 6 deletions.
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<modelVersion>4.0.0</modelVersion>
<groupId>io.github.coffeelibs</groupId>
<artifactId>tiny-oauth2-client</artifactId>
<version>0.2.0</version>
<version>0.3.0</version>
<name>Tiny OAuth2 Client</name>
<description>Zero Dependency RFC 8252 Authorization Flow</description>
<inceptionYear>2022</inceptionYear>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import io.github.coffeelibs.tinyoauth2client.http.RedirectTarget;
import io.github.coffeelibs.tinyoauth2client.http.Response;
import io.github.coffeelibs.tinyoauth2client.util.RandomUtil;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.Blocking;
import org.jetbrains.annotations.Contract;
import org.jetbrains.annotations.VisibleForTesting;
Expand All @@ -14,6 +15,7 @@
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Set;
import java.util.concurrent.ForkJoinPool;
import java.util.function.Consumer;
Expand All @@ -27,6 +29,7 @@
* @see <a href="https://datatracker.ietf.org/doc/html/rfc6749">RFC 6749</a>
* @see <a href="https://datatracker.ietf.org/doc/html/rfc7636">RFC 7636</a>
*/
@ApiStatus.Experimental
public class AuthFlow {

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

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

/**
* Refreshes an access token using the given {@code refreshToken}.
*
* @param tokenEndpoint The URI of the <a href="https://datatracker.ietf.org/doc/html/rfc6749#section-3.2">Token Endpoint</a>
* @param refreshToken The refresh token
* @param scopes The desired access token <a href="https://datatracker.ietf.org/doc/html/rfc6749#section-3.3">scopes</a>
* @return The raw <a href="https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.4">Access Token Response</a>
* @throws IOException In case of I/O errors when communicating with the token endpoint
* @throws InterruptedException When this thread is interrupted before a response is received
* @see <a href="https://datatracker.ietf.org/doc/html/rfc6749#section-6">RFC 6749 Section 6: Refreshing an Access Token</a>
*/
@Blocking
public String refresh(URI tokenEndpoint, String refreshToken, String... scopes) throws IOException, InterruptedException {
StringBuilder requestBody = new StringBuilder();
requestBody.append("grant_type=refresh_token");
requestBody.append("&client_id=").append(clientId);
requestBody.append("&refresh_token=").append(URLEncoder.encode(refreshToken, StandardCharsets.US_ASCII));
if (scopes.length > 0) {
requestBody.append("&scope=");
requestBody.append(Arrays.stream(scopes).map(s -> URLEncoder.encode(s, StandardCharsets.US_ASCII)).collect(Collectors.joining("+")));
}
var request = HttpRequest.newBuilder(tokenEndpoint) //
.header("Content-Type", "application/x-www-form-urlencoded") //
.POST(HttpRequest.BodyPublishers.ofString(requestBody.toString())) //
.build();
HttpResponse<String> response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
return response.body();
} else {
throw new IOException("Unexpected HTTP response code " + response.statusCode());
}
}

/**
* The successfully authenticated authentication flow, ready to retrieve an access token.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
*/
public class RedirectTarget implements Closeable {

private static final InetAddress LOOPBACK_ADDR = InetAddress.getLoopbackAddress();
static final InetAddress LOOPBACK_ADDR = InetAddress.getLoopbackAddress();

private final ServerSocketChannel serverChannel;
private final String path;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,84 @@ public void testAuthorize() throws IOException {

}

@Nested
@DisplayName("refresh(...)")
public class RefreshTokens {

private AuthFlow authFlow;
private HttpClient httpClient;
private HttpResponse<String> httpRespone;
private MockedStatic<HttpClient> httpClientClass;

@BeforeEach
@SuppressWarnings("unchecked")
public void setup() throws IOException, InterruptedException {
authFlow = AuthFlow.asClient("my-client");

httpClient = Mockito.mock(HttpClient.class);
httpRespone = Mockito.mock(HttpResponse.class);
httpClientClass = Mockito.mockStatic(HttpClient.class);

httpClientClass.when(HttpClient::newHttpClient).thenReturn(httpClient);
Mockito.doReturn(httpRespone).when(httpClient).send(Mockito.any(), Mockito.any());
}

@AfterEach
public void tearDown() {
httpClientClass.close();
}

@Test
@DisplayName("body contains all params")
public void testRefresh() throws IOException, InterruptedException {
Mockito.doReturn(200).when(httpRespone).statusCode();
var tokenEndpoint = URI.create("http://example.com/oauth2/token");
var bodyCaptor = ArgumentCaptor.forClass(String.class);
var bodyPublisher = Mockito.mock(HttpRequest.BodyPublisher.class);
try (var bodyPublishersClass = Mockito.mockStatic(HttpRequest.BodyPublishers.class)) {
bodyPublishersClass.when(() -> HttpRequest.BodyPublishers.ofString(Mockito.any())).thenReturn(bodyPublisher);

authFlow.refresh(tokenEndpoint, "r3fr3sh70k3n", "offline_access");

bodyPublishersClass.verify(() -> HttpRequest.BodyPublishers.ofString(bodyCaptor.capture()));
}
var body = bodyCaptor.getValue();
var params = URIUtil.parseQueryString(body);
Assertions.assertEquals("refresh_token", params.get("grant_type"));
Assertions.assertEquals(authFlow.clientId, params.get("client_id"));
Assertions.assertEquals("r3fr3sh70k3n", params.get("refresh_token"));
Assertions.assertEquals("offline_access", params.get("scope"));
}

@Test
@DisplayName("send POST request to token endpoint")
public void testGetAccessToken200() throws IOException, InterruptedException {
Mockito.doReturn(200).when(httpRespone).statusCode();
Mockito.doReturn("BODY").when(httpRespone).body();
var requestCaptor = ArgumentCaptor.forClass(HttpRequest.class);
var tokenEndpoint = URI.create("http://example.com/oauth2/token");

var result = authFlow.refresh(tokenEndpoint, "r3fr3sh70k3n");

Assertions.assertEquals("BODY", result);
Mockito.verify(httpClient).send(requestCaptor.capture(), Mockito.any());
var request = requestCaptor.getValue();
Assertions.assertSame(tokenEndpoint, request.uri());
Assertions.assertEquals("POST", request.method());
Assertions.assertEquals("application/x-www-form-urlencoded", request.headers().firstValue("Content-Type").orElse(null));
}

@Test
@DisplayName("non-success response from token endpoint leads to IOException")
public void testGetAccessToken404() {
Mockito.doReturn(404).when(httpRespone).statusCode();
var tokenEndpoint = URI.create("http://example.com/oauth2/token");

Assertions.assertThrows(IOException.class, () -> authFlow.refresh(tokenEndpoint, "r3fr3sh70k3n"));
}

}

@Nested
@DisplayName("After receiving auth code")
public class WithAuthCode {
Expand Down Expand Up @@ -224,9 +302,9 @@ public void testGetAccessTokenQuery() throws IOException, InterruptedException {
Mockito.doReturn(200).when(httpRespone).statusCode();
var tokenEndpoint = URI.create("http://example.com/oauth2/token");
var bodyCaptor = ArgumentCaptor.forClass(String.class);
var replacementBody = HttpRequest.BodyPublishers.ofString("foo");
var bodyPublisher = Mockito.mock(HttpRequest.BodyPublisher.class);
try (var bodyPublishersClass = Mockito.mockStatic(HttpRequest.BodyPublishers.class)) {
bodyPublishersClass.when(() -> HttpRequest.BodyPublishers.ofString(Mockito.any())).thenReturn(replacementBody);
bodyPublishersClass.when(() -> HttpRequest.BodyPublishers.ofString(Mockito.any())).thenReturn(bodyPublisher);

authFlowWithCode.getAccessToken(tokenEndpoint);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,20 @@ public void testStartExceptionally() throws IOException {
Mockito.verify(ch).close();
}

@Test
@DisplayName("tryBind(...) uses fallback port")
public void testTryBind() throws IOException {
var ch = Mockito.mock(ServerSocketChannel.class);
Mockito.doThrow(new AlreadyBoundException()) // first attempt fails
.doThrow(new AlreadyBoundException()) // second attempt fails
.doReturn(ch) // third attempt succeeds
.when(ch).bind(Mockito.any());

Assertions.assertDoesNotThrow(() -> RedirectTarget.tryBind(ch, 17, 23, 42));

Mockito.verify(ch).bind(new InetSocketAddress(RedirectTarget.LOOPBACK_ADDR, 42));
}

@Test
@DisplayName("bind() to system-assigned port")
public void testBindToSystemAssignedPort() throws IOException {
Expand Down Expand Up @@ -257,7 +271,6 @@ public void testInterrupt() throws IOException, InterruptedException {
redirect.receive();
} catch (IOException e) {
exception.set(e);
throw new UncheckedIOException(e);
} finally {
threadExited.countDown();
}
Expand Down

0 comments on commit fa165c7

Please sign in to comment.