Skip to content

Commit 74dd2cc

Browse files
Merge branch 'release/0.4.0'
2 parents fa165c7 + 12eb6f5 commit 74dd2cc

26 files changed

+824
-461
lines changed

README.md

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,25 +10,31 @@ This is a minimal zero-dependency implementation of the [RFC 8252 OAuth 2.0 for
1010
on [Loopback Interface Redirection](https://datatracker.ietf.org/doc/html/rfc8252#section-7.3) (i.e. no need to register a private-use URI scheme) with full
1111
support for [PKCE](https://datatracker.ietf.org/doc/html/rfc8252#section-8.1) and [CSRF Protection](https://datatracker.ietf.org/doc/html/rfc8252#section-8.9).
1212

13+
## Requirements
14+
15+
* Java 11+
16+
* Ideally some JSON or JWT parser of your choice
17+
1318
## Usage
1419

20+
Configure your authorization server to allow `http://127.0.0.1/*` as a redirect target and look up these configuration values:
21+
22+
* client identifier
23+
* token endpoint
24+
* authorization endpoint
25+
1526
```java
1627
// this library will just perform the Authorization Flow:
17-
String tokenResponse = AuthFlow.asClient("oauth-client-id")
18-
.authorize(URI.create("https://login.example.com/oauth2/authorize"), uri -> System.out.println("Please login on " + uri))
19-
.getAccessToken(URI.create("https://login.example.com/oauth2/token"));
28+
String tokenResponse = TinyOAuth2.client("oauth-client-id")
29+
.withTokenEndpoint(URI.create("https://login.example.com/oauth2/token"))
30+
.authFlow(URI.create("https://login.example.com/oauth2/authorize"))
31+
.authorize(uri -> System.out.println("Please login on " + uri));
2032

2133
// from this point onwards, please proceed with the JSON/JWT parser of your choice:
22-
String bearerToken = parse(tokenResponse);
34+
String bearerToken = parseJson(tokenResponse).get("access_token");
2335
```
2436

25-
## Customization
26-
27-
The `authorize(...)` method optionally allows you to specify:
28-
29-
* custom [scopes](https://datatracker.ietf.org/doc/html/rfc6749#section-3.3)
30-
* custom port(s) of your redirect_uri (default will be a system-assigned ephemeral port)
31-
* a custom path for your redirect_uri (default is a random path)
37+
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)`.
3238

3339
## Why this library?
3440

pom.xml

Lines changed: 4 additions & 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.3.0</version>
8+
<version>0.4.0</version>
99
<name>Tiny OAuth2 Client</name>
1010
<description>Zero Dependency RFC 8252 Authorization Flow</description>
1111
<inceptionYear>2022</inceptionYear>
@@ -73,6 +73,9 @@
7373
<groupId>org.apache.maven.plugins</groupId>
7474
<artifactId>maven-surefire-plugin</artifactId>
7575
<version>3.0.0-M5</version>
76+
<configuration>
77+
<useModulePath>false</useModulePath> <!-- avoid problems with Mockito -->
78+
</configuration>
7679
</plugin>
7780
<plugin>
7881
<groupId>org.apache.maven.plugins</groupId>

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

Lines changed: 105 additions & 137 deletions
Large diffs are not rendered by default.

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,23 @@ class PKCE {
1414

1515
public static final String METHOD = "S256";
1616

17-
public final String challenge;
18-
public final String verifier;
17+
private final String challenge;
18+
private final String verifier;
1919

2020
public PKCE() {
2121
// https://datatracker.ietf.org/doc/html/rfc7636#section-4
2222
this.verifier = RandomUtil.randomToken(43);
2323
this.challenge = Base64.getUrlEncoder().withoutPadding().encodeToString(sha256(verifier.getBytes(StandardCharsets.US_ASCII)));
2424
}
2525

26+
public String getChallenge() {
27+
return challenge;
28+
}
29+
30+
public String getVerifier() {
31+
return verifier;
32+
}
33+
2634
private static byte[] sha256(byte[] input) {
2735
try {
2836
var digest = MessageDigest.getInstance("SHA-256");
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package io.github.coffeelibs.tinyoauth2client;
2+
3+
import java.net.URI;
4+
5+
/**
6+
* Fluent builder for a {@link TinyOAuth2Client}
7+
*/
8+
public class TinyOAuth2 {
9+
10+
private TinyOAuth2() {
11+
}
12+
13+
/**
14+
* Begins building a new Tiny OAuth2 Client
15+
* @param clientId Public <a href="https://datatracker.ietf.org/doc/html/rfc6749#section-2.2">Client Identifier</a>
16+
* @return A new {@link TinyOAuth2Client} Builder
17+
*/
18+
public static TinyOAuth2ClientWithoutTokenEndpoint client(String clientId) {
19+
return tokenEndpoint -> new TinyOAuth2Client(clientId, tokenEndpoint);
20+
}
21+
22+
public interface TinyOAuth2ClientWithoutTokenEndpoint {
23+
24+
/**
25+
* @param tokenEndpoint The URI of the <a href="https://datatracker.ietf.org/doc/html/rfc6749#section-3.2">Token Endpoint</a>
26+
* @return A new client
27+
*/
28+
TinyOAuth2Client withTokenEndpoint(URI tokenEndpoint);
29+
}
30+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package io.github.coffeelibs.tinyoauth2client;
2+
3+
import io.github.coffeelibs.tinyoauth2client.util.URIUtil;
4+
import org.jetbrains.annotations.Blocking;
5+
6+
import java.io.IOException;
7+
import java.net.URI;
8+
import java.net.http.HttpClient;
9+
import java.net.http.HttpRequest;
10+
import java.net.http.HttpResponse;
11+
import java.util.Map;
12+
import java.util.Objects;
13+
14+
/**
15+
* An OAuth2 <a href="https://datatracker.ietf.org/doc/html/rfc6749#section-2.1">public client</a> capable of making requests to a token endpoint.
16+
*
17+
* @see TinyOAuth2#client(String)
18+
*/
19+
public class TinyOAuth2Client {
20+
21+
/**
22+
* @see <a href="https://datatracker.ietf.org/doc/html/rfc6749#section-2.2">Client Identifier</a>
23+
*/
24+
final String clientId;
25+
26+
/**
27+
* @see <a href="https://datatracker.ietf.org/doc/html/rfc6749#section-3.2">Token Endpoint</a>
28+
*/
29+
final URI tokenEndpoint;
30+
31+
TinyOAuth2Client(String clientId, URI tokenEndpoint) {
32+
this.clientId = Objects.requireNonNull(clientId);
33+
this.tokenEndpoint = Objects.requireNonNull(tokenEndpoint);
34+
}
35+
36+
/**
37+
* Initializes a new Authentication Code Flow with <a href="https://datatracker.ietf.org/doc/html/rfc7636">PKCE</a>
38+
*
39+
* @param authEndpoint The URI of the <a href="https://datatracker.ietf.org/doc/html/rfc6749#section-3.1">Authorization Endpoint</a>
40+
* @return A new Authentication Flow
41+
*/
42+
public AuthFlow authFlow(URI authEndpoint) {
43+
return new AuthFlow(this, authEndpoint, new PKCE());
44+
}
45+
46+
/**
47+
* Refreshes an access token using the given {@code refreshToken}.
48+
*
49+
* @param refreshToken The refresh token
50+
* @param scopes The desired access token <a href="https://datatracker.ietf.org/doc/html/rfc6749#section-3.3">scopes</a>
51+
* @return The raw <a href="https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.4">Access Token Response</a>
52+
* @throws IOException In case of I/O errors when communicating with the token endpoint
53+
* @throws InterruptedException When this thread is interrupted before a response is received
54+
* @see <a href="https://datatracker.ietf.org/doc/html/rfc6749#section-6">RFC 6749 Section 6: Refreshing an Access Token</a>
55+
*/
56+
@Blocking
57+
public String refresh(String refreshToken, String... scopes) throws IOException, InterruptedException {
58+
var requestBody = URIUtil.buildQueryString(Map.of(//
59+
"grant_type", "refresh_token", //
60+
"refresh_token", refreshToken, //
61+
"client_id", clientId, //
62+
"scope", String.join(" ", scopes)
63+
));
64+
var request = HttpRequest.newBuilder(tokenEndpoint) //
65+
.header("Content-Type", "application/x-www-form-urlencoded") //
66+
.POST(HttpRequest.BodyPublishers.ofString(requestBody)) //
67+
.build();
68+
HttpResponse<String> response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
69+
if (response.statusCode() == 200) {
70+
return response.body();
71+
} else {
72+
throw new IOException("Unexpected HTTP response code " + response.statusCode());
73+
}
74+
}
75+
76+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package io.github.coffeelibs.tinyoauth2client.http;
22

3+
import io.github.coffeelibs.tinyoauth2client.http.response.Response;
4+
35
public class InvalidRequestException extends Exception {
46
public final Response suggestedResponse;
57

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package io.github.coffeelibs.tinyoauth2client.http;
22

3+
import io.github.coffeelibs.tinyoauth2client.http.response.Response;
34
import io.github.coffeelibs.tinyoauth2client.util.RandomUtil;
45
import io.github.coffeelibs.tinyoauth2client.util.URIUtil;
6+
import org.jetbrains.annotations.Blocking;
57
import org.jetbrains.annotations.VisibleForTesting;
68

79
import java.io.BufferedReader;
@@ -37,8 +39,8 @@ public class RedirectTarget implements Closeable {
3739
private final String path;
3840
private final String csrfToken;
3941

40-
private Response successResponse = Response.html(Response.Status.OK, "<html><body>Success</body></html>");
41-
private Response errorResponse = Response.html(Response.Status.OK, "<html><body>Error</body></html>");
42+
private Response successResponse = Response.empty(Response.Status.OK);
43+
private Response errorResponse = Response.empty(Response.Status.OK);
4244

4345
private RedirectTarget(ServerSocketChannel serverChannel, String path) {
4446
this.serverChannel = serverChannel;
@@ -123,6 +125,7 @@ public String getCsrfToken() {
123125
* @see <a href="https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2">RFC 6749, 4.1.2. Authorization Response</a>
124126
* @see <a href="https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1">RFC 6749, 4.1.2.1 Error Response</a>
125127
*/
128+
@Blocking
126129
public String receive() throws IOException {
127130
var client = serverChannel.accept();
128131
try (var reader = new BufferedReader(Channels.newReader(client, StandardCharsets.US_ASCII));

src/main/java/io/github/coffeelibs/tinyoauth2client/http/EmptyResponse.java renamed to src/main/java/io/github/coffeelibs/tinyoauth2client/http/response/EmptyResponse.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
1-
package io.github.coffeelibs.tinyoauth2client.http;
1+
package io.github.coffeelibs.tinyoauth2client.http.response;
22

33
import java.io.IOException;
44
import java.io.Writer;
5+
import java.util.Objects;
56

67
class EmptyResponse implements Response {
78

89
private final Status status;
910

1011
public EmptyResponse(Response.Status status) {
11-
this.status = status;
12+
this.status = Objects.requireNonNull(status);
1213
}
1314

1415
@Override

src/main/java/io/github/coffeelibs/tinyoauth2client/http/HtmlResponse.java renamed to src/main/java/io/github/coffeelibs/tinyoauth2client/http/response/HtmlResponse.java

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
1-
package io.github.coffeelibs.tinyoauth2client.http;
1+
package io.github.coffeelibs.tinyoauth2client.http.response;
22

33
import java.io.IOException;
44
import java.io.Writer;
55
import java.nio.charset.StandardCharsets;
6+
import java.util.Objects;
67

78
class HtmlResponse implements Response {
89

910
private final Status status;
1011
private final String body;
1112

1213
public HtmlResponse(Status status, String body) {
13-
this.status = status;
14-
this.body = body;
14+
this.status = Objects.requireNonNull(status);
15+
this.body = Objects.requireNonNull(body);
1516
}
1617

1718
@Override

src/main/java/io/github/coffeelibs/tinyoauth2client/http/RedirectResponse.java renamed to src/main/java/io/github/coffeelibs/tinyoauth2client/http/response/RedirectResponse.java

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
1-
package io.github.coffeelibs.tinyoauth2client.http;
1+
package io.github.coffeelibs.tinyoauth2client.http.response;
22

33
import java.io.IOException;
44
import java.io.Writer;
55
import java.net.URI;
6+
import java.util.Objects;
67

78
class RedirectResponse implements Response {
89

910
private final Status status;
1011
private URI target;
1112

1213
public RedirectResponse(Status status, URI target) {
13-
this.status = status;
14-
this.target = target;
14+
this.status = Objects.requireNonNull(status);
15+
this.target = Objects.requireNonNull(target);
1516
}
1617

1718
@Override

src/main/java/io/github/coffeelibs/tinyoauth2client/http/Response.java renamed to src/main/java/io/github/coffeelibs/tinyoauth2client/http/response/Response.java

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
package io.github.coffeelibs.tinyoauth2client.http;
1+
package io.github.coffeelibs.tinyoauth2client.http.response;
2+
3+
import org.jetbrains.annotations.Contract;
24

35
import java.io.IOException;
46
import java.io.Writer;
@@ -8,14 +10,36 @@ public interface Response {
810

911
void write(Writer writer) throws IOException;
1012

13+
/**
14+
* Create a response without a body.
15+
*
16+
* @param status Http status code
17+
* @return A new response
18+
*/
19+
@Contract("!null -> new")
1120
static Response empty(Status status) {
1221
return new EmptyResponse(status);
1322
}
1423

24+
/**
25+
* Create a response without an html body.
26+
*
27+
* @param status Http status code
28+
* @param body content served with {@code Content-Type: text/html; charset=UTF-8}
29+
* @return A new response
30+
*/
31+
@Contract("!null, !null -> new")
1532
static Response html(Status status, String body) {
1633
return new HtmlResponse(status, body);
1734
}
1835

36+
/**
37+
* Create a HTTP status code 303 redirect response.
38+
*
39+
* @param target URI of page to redirect to
40+
* @return A new response
41+
*/
42+
@Contract("!null -> new")
1943
static Response redirect(URI target) {
2044
return new RedirectResponse(Status.SEE_OTHER, target);
2145
}

src/main/java/io/github/coffeelibs/tinyoauth2client/util/RandomUtil.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ public static String randomToken(int len) {
3838
* @param len Desired number of bytes
3939
* @return A random byte array
4040
*/
41-
public static byte[] randomBytes(int len) {
41+
private static byte[] randomBytes(int len) {
4242
byte[] bytes = new byte[len];
4343
CSPRNG.nextBytes(bytes);
4444
return bytes;

src/main/java/io/github/coffeelibs/tinyoauth2client/util/URIUtil.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import org.jetbrains.annotations.Nullable;
44

55
import java.net.URLDecoder;
6+
import java.net.URLEncoder;
67
import java.nio.charset.StandardCharsets;
78
import java.util.Map;
89
import java.util.function.Predicate;
@@ -38,4 +39,22 @@ public static Map<String, String> parseQueryString(@Nullable String rawQuery) {
3839
}).collect(Collectors.toUnmodifiableMap(Map.Entry::getKey, Map.Entry::getValue));
3940
}
4041

42+
/**
43+
* Builds a urlencoded query string from the given {@code queryParams}. Percent encoding will be applied to each
44+
* key and value.
45+
* <p>
46+
* The result can either be appended to an {@link java.net.URI} or used in
47+
* {@code application/x-www-form-urlencoded}-encoded request bodies.
48+
*
49+
* @param queryParams key-value pairs
50+
* @return A query string
51+
*/
52+
public static String buildQueryString(Map<String, String> queryParams) {
53+
return queryParams.entrySet().stream().map(entry -> {
54+
var encodedKey = URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8);
55+
var encodedValue = URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8);
56+
return encodedKey + (encodedValue.isBlank() ? "" : "=" + encodedValue);
57+
}).collect(Collectors.joining("&"));
58+
}
59+
4160
}

src/main/java/module-info.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@
33
requires java.net.http;
44

55
exports io.github.coffeelibs.tinyoauth2client;
6+
exports io.github.coffeelibs.tinyoauth2client.http.response;
67
}

0 commit comments

Comments
 (0)