Skip to content

Commit

Permalink
Merge branch 'release/0.4.0'
Browse files Browse the repository at this point in the history
  • Loading branch information
overheadhunter committed Apr 30, 2022
2 parents fa165c7 + 12eb6f5 commit 74dd2cc
Show file tree
Hide file tree
Showing 26 changed files with 824 additions and 461 deletions.
28 changes: 17 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,25 +10,31 @@ This is a minimal zero-dependency implementation of the [RFC 8252 OAuth 2.0 for
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
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).

## Requirements

* Java 11+
* Ideally some JSON or JWT parser of your choice

## Usage

Configure your authorization server to allow `http://127.0.0.1/*` as a redirect target and look up these configuration values:

* client identifier
* token endpoint
* authorization endpoint

```java
// this library will just perform the Authorization Flow:
String tokenResponse = AuthFlow.asClient("oauth-client-id")
.authorize(URI.create("https://login.example.com/oauth2/authorize"), uri -> System.out.println("Please login on " + uri))
.getAccessToken(URI.create("https://login.example.com/oauth2/token"));
String tokenResponse = TinyOAuth2.client("oauth-client-id")
.withTokenEndpoint(URI.create("https://login.example.com/oauth2/token"))
.authFlow(URI.create("https://login.example.com/oauth2/authorize"))
.authorize(uri -> System.out.println("Please login on " + uri));

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

## Customization

The `authorize(...)` method optionally allows you to specify:

* custom [scopes](https://datatracker.ietf.org/doc/html/rfc6749#section-3.3)
* custom port(s) of your redirect_uri (default will be a system-assigned ephemeral port)
* a custom path for your redirect_uri (default is a random path)
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)`.

## Why this library?

Expand Down
5 changes: 4 additions & 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.3.0</version>
<version>0.4.0</version>
<name>Tiny OAuth2 Client</name>
<description>Zero Dependency RFC 8252 Authorization Flow</description>
<inceptionYear>2022</inceptionYear>
Expand Down Expand Up @@ -73,6 +73,9 @@
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.0.0-M5</version>
<configuration>
<useModulePath>false</useModulePath> <!-- avoid problems with Mockito -->
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
Expand Down
242 changes: 105 additions & 137 deletions src/main/java/io/github/coffeelibs/tinyoauth2client/AuthFlow.java

Large diffs are not rendered by default.

12 changes: 10 additions & 2 deletions src/main/java/io/github/coffeelibs/tinyoauth2client/PKCE.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,23 @@ class PKCE {

public static final String METHOD = "S256";

public final String challenge;
public final String verifier;
private final String challenge;
private final String verifier;

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

public String getChallenge() {
return challenge;
}

public String getVerifier() {
return verifier;
}

private static byte[] sha256(byte[] input) {
try {
var digest = MessageDigest.getInstance("SHA-256");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package io.github.coffeelibs.tinyoauth2client;

import java.net.URI;

/**
* Fluent builder for a {@link TinyOAuth2Client}
*/
public class TinyOAuth2 {

private TinyOAuth2() {
}

/**
* Begins building a new Tiny OAuth2 Client
* @param clientId Public <a href="https://datatracker.ietf.org/doc/html/rfc6749#section-2.2">Client Identifier</a>
* @return A new {@link TinyOAuth2Client} Builder
*/
public static TinyOAuth2ClientWithoutTokenEndpoint client(String clientId) {
return tokenEndpoint -> new TinyOAuth2Client(clientId, tokenEndpoint);
}

public interface TinyOAuth2ClientWithoutTokenEndpoint {

/**
* @param tokenEndpoint The URI of the <a href="https://datatracker.ietf.org/doc/html/rfc6749#section-3.2">Token Endpoint</a>
* @return A new client
*/
TinyOAuth2Client withTokenEndpoint(URI tokenEndpoint);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package io.github.coffeelibs.tinyoauth2client;

import io.github.coffeelibs.tinyoauth2client.util.URIUtil;
import org.jetbrains.annotations.Blocking;

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.util.Map;
import java.util.Objects;

/**
* 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.
*
* @see TinyOAuth2#client(String)
*/
public class TinyOAuth2Client {

/**
* @see <a href="https://datatracker.ietf.org/doc/html/rfc6749#section-2.2">Client Identifier</a>
*/
final String clientId;

/**
* @see <a href="https://datatracker.ietf.org/doc/html/rfc6749#section-3.2">Token Endpoint</a>
*/
final URI tokenEndpoint;

TinyOAuth2Client(String clientId, URI tokenEndpoint) {
this.clientId = Objects.requireNonNull(clientId);
this.tokenEndpoint = Objects.requireNonNull(tokenEndpoint);
}

/**
* Initializes a new Authentication Code Flow 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
*/
public AuthFlow authFlow(URI authEndpoint) {
return new AuthFlow(this, authEndpoint, new PKCE());
}

/**
* Refreshes an access token using the given {@code refreshToken}.
*
* @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(String refreshToken, String... scopes) throws IOException, InterruptedException {
var requestBody = URIUtil.buildQueryString(Map.of(//
"grant_type", "refresh_token", //
"refresh_token", refreshToken, //
"client_id", clientId, //
"scope", String.join(" ", scopes)
));
var request = HttpRequest.newBuilder(tokenEndpoint) //
.header("Content-Type", "application/x-www-form-urlencoded") //
.POST(HttpRequest.BodyPublishers.ofString(requestBody)) //
.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());
}
}

}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package io.github.coffeelibs.tinyoauth2client.http;

import io.github.coffeelibs.tinyoauth2client.http.response.Response;

public class InvalidRequestException extends Exception {
public final Response suggestedResponse;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package io.github.coffeelibs.tinyoauth2client.http;

import io.github.coffeelibs.tinyoauth2client.http.response.Response;
import io.github.coffeelibs.tinyoauth2client.util.RandomUtil;
import io.github.coffeelibs.tinyoauth2client.util.URIUtil;
import org.jetbrains.annotations.Blocking;
import org.jetbrains.annotations.VisibleForTesting;

import java.io.BufferedReader;
Expand Down Expand Up @@ -37,8 +39,8 @@ public class RedirectTarget implements Closeable {
private final String path;
private final String csrfToken;

private Response successResponse = Response.html(Response.Status.OK, "<html><body>Success</body></html>");
private Response errorResponse = Response.html(Response.Status.OK, "<html><body>Error</body></html>");
private Response successResponse = Response.empty(Response.Status.OK);
private Response errorResponse = Response.empty(Response.Status.OK);

private RedirectTarget(ServerSocketChannel serverChannel, String path) {
this.serverChannel = serverChannel;
Expand Down Expand Up @@ -123,6 +125,7 @@ public String getCsrfToken() {
* @see <a href="https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2">RFC 6749, 4.1.2. Authorization Response</a>
* @see <a href="https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1">RFC 6749, 4.1.2.1 Error Response</a>
*/
@Blocking
public String receive() throws IOException {
var client = serverChannel.accept();
try (var reader = new BufferedReader(Channels.newReader(client, StandardCharsets.US_ASCII));
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
package io.github.coffeelibs.tinyoauth2client.http;
package io.github.coffeelibs.tinyoauth2client.http.response;

import java.io.IOException;
import java.io.Writer;
import java.util.Objects;

class EmptyResponse implements Response {

private final Status status;

public EmptyResponse(Response.Status status) {
this.status = status;
this.status = Objects.requireNonNull(status);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
package io.github.coffeelibs.tinyoauth2client.http;
package io.github.coffeelibs.tinyoauth2client.http.response;

import java.io.IOException;
import java.io.Writer;
import java.nio.charset.StandardCharsets;
import java.util.Objects;

class HtmlResponse implements Response {

private final Status status;
private final String body;

public HtmlResponse(Status status, String body) {
this.status = status;
this.body = body;
this.status = Objects.requireNonNull(status);
this.body = Objects.requireNonNull(body);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
package io.github.coffeelibs.tinyoauth2client.http;
package io.github.coffeelibs.tinyoauth2client.http.response;

import java.io.IOException;
import java.io.Writer;
import java.net.URI;
import java.util.Objects;

class RedirectResponse implements Response {

private final Status status;
private URI target;

public RedirectResponse(Status status, URI target) {
this.status = status;
this.target = target;
this.status = Objects.requireNonNull(status);
this.target = Objects.requireNonNull(target);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
package io.github.coffeelibs.tinyoauth2client.http;
package io.github.coffeelibs.tinyoauth2client.http.response;

import org.jetbrains.annotations.Contract;

import java.io.IOException;
import java.io.Writer;
Expand All @@ -8,14 +10,36 @@ public interface Response {

void write(Writer writer) throws IOException;

/**
* Create a response without a body.
*
* @param status Http status code
* @return A new response
*/
@Contract("!null -> new")
static Response empty(Status status) {
return new EmptyResponse(status);
}

/**
* Create a response without an html body.
*
* @param status Http status code
* @param body content served with {@code Content-Type: text/html; charset=UTF-8}
* @return A new response
*/
@Contract("!null, !null -> new")
static Response html(Status status, String body) {
return new HtmlResponse(status, body);
}

/**
* Create a HTTP status code 303 redirect response.
*
* @param target URI of page to redirect to
* @return A new response
*/
@Contract("!null -> new")
static Response redirect(URI target) {
return new RedirectResponse(Status.SEE_OTHER, target);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public static String randomToken(int len) {
* @param len Desired number of bytes
* @return A random byte array
*/
public static byte[] randomBytes(int len) {
private static byte[] randomBytes(int len) {
byte[] bytes = new byte[len];
CSPRNG.nextBytes(bytes);
return bytes;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import org.jetbrains.annotations.Nullable;

import java.net.URLDecoder;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.function.Predicate;
Expand Down Expand Up @@ -38,4 +39,22 @@ public static Map<String, String> parseQueryString(@Nullable String rawQuery) {
}).collect(Collectors.toUnmodifiableMap(Map.Entry::getKey, Map.Entry::getValue));
}

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

}
1 change: 1 addition & 0 deletions src/main/java/module-info.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@
requires java.net.http;

exports io.github.coffeelibs.tinyoauth2client;
exports io.github.coffeelibs.tinyoauth2client.http.response;
}
Loading

0 comments on commit 74dd2cc

Please sign in to comment.