Skip to content
Merged
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,20 @@ Configure your Authorization Server to allow `http://127.0.0.1/*` as a redirect
```java
// this library will just perform the Authorization Flow:
var httpResponse = oauthClient.authorizationCodeGrant(URI.create("https://login.example.com/oauth2/authorize"))
.authorize(httpClient, uri -> System.out.println("Please login on " + uri));
.authorize(httpClient, uri -> System.out.println("Please login on " + uri), "openid", "profile"); // optionally add scopes here);
```

If your authorization server doesn't allow wildcards, you can also configure a fixed path (and even port) via e.g. `setRedirectPath("/callback")` and `setRedirectPorts(8080)` before calling `authorize(...)`.

### Client Credentials Grant
Alternatively, if your client shall act on behalf of a service account, use the [Client Credentials Grant](https://datatracker.ietf.org/doc/html/rfc6749#section-4.4) type,
which allows the client to authenticate directly without further user interaction:

```java
var httpResponse = oauthClient.clientCredentialsGrant(UTF_8, "client secret")
.authorize(httpClient, "openid", "profile"); // optionally add scopes here
```

### Parsing the Response
For maximum flexibility and minimal attack surface, this library does not include or depend on a specific parser. Instead, use a JSON or JWT parser of your choice to parse the Authorization Server's response:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -225,13 +225,13 @@ class WithAuthorizationCode {

@VisibleForTesting
HttpRequest buildTokenRequest() {
return client.buildTokenRequest(Map.of( //
return client.createTokenRequest(Map.of( //
"grant_type", "authorization_code", //
"client_id", client.clientId, //
"code_verifier", pkce.getVerifier(), //
"code", authorizationCode, //
"redirect_uri", redirectUri //
));
)).build();
}

public CompletableFuture<HttpResponse<String>> getAccessTokenAsync(HttpClient httpClient) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package io.github.coffeelibs.tinyoauth2client;

import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.Blocking;
import org.jetbrains.annotations.NonBlocking;
import org.jetbrains.annotations.VisibleForTesting;

import java.io.IOException;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.CharBuffer;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Base64;
import java.util.Collection;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture;

/**
* Simple OAuth 2.0 Client Credentials Grant
*
* @see TinyOAuth2Client#clientCredentialsGrant(Charset, CharSequence) ()
* @see <a href="https://datatracker.ietf.org/doc/html/rfc6749#section-4.4">RFC 6749, Section 4.4</a>
* @see <a href="https://datatracker.ietf.org/doc/html/rfc6749#section-3.2.1">RFC 6749, Section 3.2.1</a>
*/
@ApiStatus.Experimental
public class ClientCredentialsGrant {

@VisibleForTesting
final TinyOAuth2Client client;

@VisibleForTesting
final String basicAuthHeader;

ClientCredentialsGrant(TinyOAuth2Client client, Charset charset, CharSequence clientSecret) {
this.client = client;
this.basicAuthHeader = buildBasicAuthHeader(charset, client.clientId, clientSecret);
}

/**
* Requests a new access token, using the pre-shared client credentials to authenticate against the authorization server.
*
* @param httpClient The http client used to recieve the authorization code
* @param scopes The desired <a href="https://datatracker.ietf.org/doc/html/rfc6749#section-3.3">scopes</a>
* @return The <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 #authorizeAsync(HttpClient, String...)
*/
@Blocking
public HttpResponse<String> authorize(HttpClient httpClient, String... scopes) throws IOException, InterruptedException {
var req = buildTokenRequest(Set.of(scopes));
return httpClient.send(req, HttpResponse.BodyHandlers.ofString());
}

/**
* Requests a new access token, using the pre-shared client credentials to authenticate against the authorization server.
*
* @param httpClient The http client used to recieve the authorization code
* @param scopes The desired <a href="https://datatracker.ietf.org/doc/html/rfc6749#section-3.3">scopes</a>
* @return The future <a href="https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.4">Access Token Response</a>
* @see #authorize(HttpClient, String...)
*/
@NonBlocking
public CompletableFuture<HttpResponse<String>> authorizeAsync(HttpClient httpClient, String... scopes) {
var req = buildTokenRequest(Set.of(scopes));
return httpClient.sendAsync(req, HttpResponse.BodyHandlers.ofString());
}

@VisibleForTesting
static String buildBasicAuthHeader(Charset charset, String clientId, CharSequence clientSecret) {
// while it is inevitable to have a String copy of the encoded header in memory during the http request,
// this is an attempt to at least avoid unnecessary copies of the clientSecret:
var userPassChars = CharBuffer.allocate(clientId.length() + 1 + clientSecret.length());
userPassChars.put(clientId).put(':').put(CharBuffer.wrap(clientSecret)).flip();
var userPassBytes = charset.encode(userPassChars);
var base64Bytes = Base64.getEncoder().encode(userPassBytes);
try {
return "Basic " + StandardCharsets.US_ASCII.decode(base64Bytes);
} finally {
Arrays.fill(userPassChars.array(), ' ');
Arrays.fill(userPassBytes.array(), (byte) 0x00);
Arrays.fill(base64Bytes.array(), (byte) 0x00);
}
}

@VisibleForTesting
HttpRequest buildTokenRequest(Collection<String> scopes) {
var params = scopes.isEmpty()
? Map.of("grant_type", "client_credentials")
: Map.of("grant_type", "client_credentials", "scope", String.join(" ", scopes));
var req = client.createTokenRequest(params);

// https://datatracker.ietf.org/doc/html/rfc6749#section-2.3.1:
// The authorization server MUST support the HTTP Basic authentication [...]
// Alternatively, the authorization server MAY support including the client credentials in the request-body [...]
// Including the client credentials in the request-body using the two parameters is NOT RECOMMENDED and
// SHOULD be limited to clients unable to directly utilize the HTTP Basic authentication scheme
req.setHeader("Authorization", basicAuthHeader);
return req.build();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.Charset;
import java.time.Duration;
import java.util.Map;
import java.util.Objects;
Expand Down Expand Up @@ -63,7 +64,7 @@ public TinyOAuth2Client withRequestTimeout(Duration requestTimeout) {
* Initializes a new <a href="https://datatracker.ietf.org/doc/html/rfc6749#section-4.1">Authorization Code Flow</a> with <a href="https://datatracker.ietf.org/doc/html/rfc7636">PKCE</a>
*
* @param authEndpoint The URI of the <a href="https://datatracker.ietf.org/doc/html/rfc6749#section-3.1">Authorization Endpoint</a>
* @return A new Authentication Flow
* @return A new Authorization Code Grant
* @see <a href="https://datatracker.ietf.org/doc/html/rfc6749#section-4.1">Authorization Code Flow</a>
* @deprecated Use {@link #authorizationCodeGrant(URI)} instead, will be removed in a future version
*/
Expand All @@ -76,13 +77,25 @@ public AuthorizationCodeGrant authFlow(URI authEndpoint) {
* Initializes a new <a href="https://datatracker.ietf.org/doc/html/rfc6749#section-4.1">Authorization Code Grant</a> with <a href="https://datatracker.ietf.org/doc/html/rfc7636">PKCE</a>
*
* @param authEndpoint The URI of the <a href="https://datatracker.ietf.org/doc/html/rfc6749#section-3.1">Authorization Endpoint</a>
* @return A new Authentication Flow
* @return A new Authorization Code Grant
* @see <a href="https://datatracker.ietf.org/doc/html/rfc6749#section-4.1">Authorization Code Flow</a>
*/
public AuthorizationCodeGrant authorizationCodeGrant(URI authEndpoint) {
return new AuthorizationCodeGrant(this, authEndpoint, new PKCE());
}

/**
* Initializes a new <a href="https://datatracker.ietf.org/doc/html/rfc6749#section-4.4">Client Credentials Grant</a>
*
* @param charset The <a href="https://datatracker.ietf.org/doc/html/rfc7617#section-2.1">charset used to encode the credentials</a>, as expected by the authorization server
* @param clientSecret The client secret
* @return A new Client Credentials Grant
* @see <a href="https://datatracker.ietf.org/doc/html/rfc6749#section-4.4">Client Credentials Grant</a>
*/
public ClientCredentialsGrant clientCredentialsGrant(Charset charset, CharSequence clientSecret) {
return new ClientCredentialsGrant(this, charset, clientSecret);
}

/**
* Refreshes an access token using the given {@code refreshToken}. The request is made by the default http client returned by
* {@link HttpClient#newHttpClient()} but on the given executor.
Expand Down Expand Up @@ -147,28 +160,28 @@ public HttpResponse<String> refresh(HttpClient httpClient, String refreshToken,

@VisibleForTesting
HttpRequest buildRefreshTokenRequest(String refreshToken, String... scopes) {
return buildTokenRequest(Map.of(//
// TODO: https://datatracker.ietf.org/doc/html/rfc6749#section-6: If the client type is confidential or the client was issued client credentials [...] the client MUST authenticate
return createTokenRequest(Map.of(//
"grant_type", "refresh_token", //
"refresh_token", refreshToken, //
"client_id", clientId, //
"scope", String.join(" ", scopes)
));
)).build();
}

/**
* Creates a new HTTP request targeting the {@link #tokenEndpoint}.
*
* @param parameters Parameters to send in an {@code application/x-www-form-urlencoded} request body
* @return A new http request
* @return A new http request builder
*/
@Contract("_ -> new")
HttpRequest buildTokenRequest(Map<String, String> parameters) {
HttpRequest.Builder createTokenRequest(Map<String, String> parameters) {
var urlencodedParams = URIUtil.buildQueryString(parameters);
return HttpRequest.newBuilder(tokenEndpoint) //
.header("Content-Type", "application/x-www-form-urlencoded") //
.POST(HttpRequest.BodyPublishers.ofString(urlencodedParams)) //
.timeout(requestTimeout) //
.build();
.timeout(requestTimeout);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -275,13 +275,15 @@ public class WithAuthCode {
@DisplayName("buildTokenRequest() builds new http request")
public void testBuildTokenRequest() {
var grantWithCode = grant.new WithAuthorizationCode("redirect-uri", "auth-code");
var requestBuilder = Mockito.mock(HttpRequest.Builder.class);
var request = Mockito.mock(HttpRequest.class);
Mockito.doReturn(request).when(client).buildTokenRequest(Mockito.any());
Mockito.doReturn(requestBuilder).when(client).createTokenRequest(Mockito.any());
Mockito.doReturn(request).when(requestBuilder).build();

var result = grantWithCode.buildTokenRequest();

Assertions.assertEquals(request, result);
Mockito.verify(client).buildTokenRequest(Map.of(//
Mockito.verify(client).createTokenRequest(Map.of(//
"grant_type", "authorization_code", //
"client_id", client.clientId, //
"code_verifier", pkce.getVerifier(), //
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package io.github.coffeelibs.tinyoauth2client;

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.mockito.Mockito;

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;

public class ClientCredentialsGrantTest {

private final TinyOAuth2Client client = Mockito.spy(new TinyOAuth2Client("Aladdin", URI.create("http://example.com/oauth2/token")));

@DisplayName("build basic auth header")
@ParameterizedTest(name = "{0}:{1} -> {2}")
@CsvSource({
// from https://datatracker.ietf.org/doc/html/rfc7617:
"Aladdin, open sesame, Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==",
"test, 123£, Basic dGVzdDoxMjPCow==",
})
public void testBuildBasicAuthHeader(String username, String password, String expectedResult) {
var result = ClientCredentialsGrant.buildBasicAuthHeader(StandardCharsets.UTF_8, username, password);

Assertions.assertEquals(expectedResult, result);
}

@Test
@DisplayName("buildTokenRequest() builds new http request")
public void testBuildTokenRequest() {
var grant = new ClientCredentialsGrant(client, StandardCharsets.UTF_8, "open sesame");
var requestBuilder = Mockito.mock(HttpRequest.Builder.class);
var request = Mockito.mock(HttpRequest.class);
Mockito.doReturn(requestBuilder).when(client).createTokenRequest(Mockito.any());
Mockito.doReturn(request).when(requestBuilder).build();

var result = grant.buildTokenRequest(List.of("foo", "bar"));

Assertions.assertEquals(request, result);
Mockito.verify(client).createTokenRequest(Map.of(//
"grant_type", "client_credentials", //
"scope", "foo bar"
));
Mockito.verify(requestBuilder).setHeader("Authorization", "Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==");
}

@Test
@DisplayName("authorize(...) sends access token request")
@SuppressWarnings("unchecked")
public void testAuthorize() throws IOException, InterruptedException {
var grant = Mockito.spy(new ClientCredentialsGrant(client, StandardCharsets.UTF_8, "open sesame"));
var httpClient = Mockito.mock(HttpClient.class);
var httpRequest = Mockito.mock(HttpRequest.class);
var httpRespone = Mockito.mock(HttpResponse.class);
Mockito.doReturn(httpRequest).when(grant).buildTokenRequest(Mockito.any());
Mockito.doReturn(httpRespone).when(httpClient).send(Mockito.any(), Mockito.any());

var result = grant.authorize(httpClient);

Assertions.assertEquals(httpRespone, result);
Mockito.verify(httpClient).send(httpRequest, HttpResponse.BodyHandlers.ofString());
}

@Test
@DisplayName("authorizeAsync(...) sends access token request")
@SuppressWarnings("unchecked")
public void testAuthorizeAsync() throws IOException, InterruptedException {
var grant = Mockito.spy(new ClientCredentialsGrant(client, StandardCharsets.UTF_8, "open sesame"));
var httpClient = Mockito.mock(HttpClient.class);
var httpRequest = Mockito.mock(HttpRequest.class);
var httpRespone = Mockito.mock(HttpResponse.class);
Mockito.doReturn(httpRequest).when(grant).buildTokenRequest(Mockito.any());
Mockito.doReturn(CompletableFuture.completedFuture(httpRespone)).when(httpClient).sendAsync(Mockito.any(), Mockito.any());

var result = grant.authorizeAsync(httpClient);

Assertions.assertEquals(httpRespone, result.join());
Mockito.verify(httpClient).sendAsync(httpRequest, HttpResponse.BodyHandlers.ofString());
}

}
Loading