Secure Your Micronaut App With FreshBooks

This post explains how you can secure your Micronaut app with FreshBooks. Using FreshBooks’ OAuth 2.0 implementation, I’ll show you how to use the Authorization Code grant to authenticate with FreshBooks.

FreshBooks is an invoice and accounting Software-as-a-Service (SaaS) for small business.

Using Micronaut OAuth 2.0

We’ll be using Micronaut and it’s OAuth 2.0 libraries. Micronaut is a dependency injection framework for Java. If that sounds a lot like Spring Framework, well, you’re close. Both play in the same space. The difference is Micronaut resolves its bean injections at compile time, instead of at runtime as Spring Framework does. The result is a much faster startup time and a smaller memory footprint, mainly because there is no runtime reflection or proxying.

This can be a big deal in cloud environments where fast start up times are important for rapid scaling and elasticity. To put some numbers on this, a Hello World web app written with Spring Framework took 10 seconds to start on my mid-2012 MackBook Pro. The same app rewritten using Micronaut took less than two seconds to start. Mind-blowing, eh?

FreshBooks OAuth 2.0

In contrast to the examples in the Micronaut Security Guides, FreshBooks does not use OpenID, nor does it return a JWT (as of Jan 2021). Rather, it returns the basic OAuth 2.0 access and refresh tokens in the standard JSON format:

{
  "access_token": "lots_of_letters_and_numbers",
  "token_type": "bearer",
  "expires_in": 43200,
  "refresh_token": "more_letters_and_numbers",
  "created_at": 1586211696
}

Part of our solution here will be to retrieve the user’s name and email address, much like what OpenID does. We’ll cobble this together in a JWT and give it to the browser as a cookie.

The Solution

If you want to skip to the end result, you can see the working code in my GitHub repo.

Getting Started

Begin by installing Micronaut. I’ll assume you are using macOS, you use Homebrew as your package manager, and you have version 11 of the Java JDK installed with JAVA_HOME configured correctly:

$ brew install micronaut

If you prefer SDKMAN! over Homebrew, just go:

$ sdk install micronaut

I also assume you know your way around Gradle. Micronaut installs the Gradle wrapper when you perform the next step.

Create the Application

Now the fun part – writing the application. With Micronaut you can create the application with either the Command Line Interface or Micronaut Launch.

Command Line Interface

$ mn create-app ca.airspeed.freshbooks-auth --features lombok,views-thymeleaf,security-oauth2,security-jwt

That command created the folder freshbooks-auth with the standard Java application folder structure , and a default package name of ca.airspeed. It also added some needed dependencies that I’ll mention shortly.

Micronaut Launch

Browse to https://micronaut.io/launch/, and fill out the form as shown below. Make sure you select the four Features shown:

Screen shot of Micronaut Launch
Micronaut Launch

Views

For this demo we’ll use ThymeLeaf as the template engine to render views. Creating the app with the views-thymeleaf feature added it as a dependency in build.gradle:

dependencies {
...
    implementation("io.micronaut.views:micronaut-views-thymeleaf")
...
}

Micronaut Security and Lombok Dependencies

I’m a Lombok fan boy, and I love the simplicity it brings to your code. We specified it as a Feature along with a couple Micronaut Security Features. The final dependencies block in build.gradle should look like this:

dependencies {
    annotationProcessor("org.projectlombok:lombok")
    annotationProcessor("io.micronaut.security:micronaut-security-annotations")
    implementation("io.micronaut:micronaut-validation")
    implementation("io.micronaut:micronaut-runtime")
    implementation("javax.annotation:javax.annotation-api")
    implementation("io.micronaut:micronaut-http-client")
    compileOnly("org.projectlombok:lombok")
    implementation("io.micronaut.security:micronaut-security-jwt")
    implementation("io.micronaut.security:micronaut-security-oauth2")
    implementation("io.micronaut.views:micronaut-views-thymeleaf")
    runtimeOnly("ch.qos.logback:logback-classic")
}

Since both Lombok and Micronaut use annotation processors, it is important we have Lombok as the first annotation processor in the dependencies block. The Command Line Interface and Micronaut Launch take care of this for us.

Your gradle.properties already specifies the Micronaut version to use:

micronautVersion=2.2.3

Setup A Client Application in FreshBooks

If you don’t have a FreshBooks account, set one up on their sign up page. Then, head over to their Developer Portal and click on “Create an App”.

screen shot of FreshBooks' Developer Portal
FreshBooks Developer Portal

Give it a name and description. Most importantly, specify a Redirect URI of https://localhost/oauth/callback/freshbooks. You just need the one Redirect URI:

screen shot showing how to create an app in FreshBooks.
Specify this Redirect URI.

Click the green arrow button, and click the green Save button at the top of the screen.

Back at the Developer Portal, click the down arrow next to the app you just added. FreshBooks has assigned the Client ID and Client Secret:

screen shot showing app details in FreshBooks
Copy the ClientID and Client Secret.

Create a setenv file in the project root, copy the Client ID and Client Secret from the Developer Portal and use them as values for OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET respectively.

Make up a 256-bit JWT key (I used RandomKeygen), and use this for the value of JWT_SECRET. Your setenv file should look like this:

export OAUTH_CLIENT_ID=1e70blahblahabunchoflettersandnumbersblahblah2b
export OAUTH_CLIENT_SECRET=3e25ablahblahmoreoflettersandnumbersbf45e7931
export JWT_SECRET=Piyadayadaevenmorelettersandnumberskwk

Configure Our Application

Next up, edit src/main/resources/application.yml so it looks like this:

micronaut:
  application:
    name: freshbooksAuth
  security:
    authentication: cookie
    oauth2:
      clients:
        freshbooks:
          client-id: '${OAUTH_CLIENT_ID}'
          client-secret: '${OAUTH_CLIENT_SECRET}'
          authorization:
            url: https://auth.freshbooks.com/service/auth/oauth/authorize
          scopes: admin:all:legacy
          token:
            url: https://api.freshbooks.com/auth/oauth/token
            auth-method: client-secret-post
          revocation:
            url: https://api.freshbooks.com/auth/oauth/revoke
            auth-method: client-secret-post
    endpoints:
      logout:
        get-allowed: true
    reject-not-found: false
    token:
      jwt:
        signatures:
          secret:
            generator:
              secret: '${JWT_SECRET}'
              jws-algorithm: HS256
        generator:
          access-token:
            expiration: 43200
  ssl:
    enabled: true
    build-self-signed: true
    port: 443

Here is what we just did:

  • We’ve defined an oauth2 client arbitrarily named freshbooks. That client name must be the same as the last part of the path you specified in the Redirect URI in FreshBooks.
  • We supplied the Client ID and Client Secret we set up in FreshBooks.
  • We defined the URL our app will use to request an authorization code, and the scopes we are asking for. Those scopes appear to be pre-defined by FreshBooks, and are the only ones I’ve got to work.
  • We defined the URL our browser will use so it can obtain an access token.
  • We’re allowing a GET on our app’s /logout endpoint.
  • We configured the signature our app will use to sign the JWT token it creates, and defined its expiration to match the FreshBooks access token.
  • We enabled self-signed TLS (SSL) certificate. FreshBooks requires us to use https, and rightly so.

Create the file src/main/resources/views/home.html:

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="en">
<head>
    <meta charset="UTF-8">
    <title>Home</title>
</head>
<body>
    <h1>Micronaut - FreshBooks example</h1>
    <h2 th:if="${security}">Welcome <span th:text="${security.attributes.get('firstName')}"></span>!</h2>
    <h2 th:unless="${security}">username: Anonymous</h2>
    <nav>
        <ul>
            <li th:unless="${security}"><a href="/oauth/login/freshbooks">Enter</a></li>
            <li th:if="${security}"><a href="/oauth/logout/freshbooks">Logout</a></li>
        </ul>
    </nav>
</body>
</html>

The URI /oauth/login/freshbooks will resolve to the micronaut.security.oauth2.clients.freshbooks.authorization.url setting we made earlier in application.yml.

Home

Create the file src/main/java/ca/airspeed/HomeController.java:

package ca.airspeed;

import static io.micronaut.security.rules.SecurityRule.IS_ANONYMOUS;

import java.util.HashMap;
import java.util.Map;

import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Get;
import io.micronaut.security.annotation.Secured;
import io.micronaut.views.View;

@Controller
public class HomeController {

    @Secured(IS_ANONYMOUS)
    @View("home")
    @Get
    public Map<String, Object> index() {
        return new HashMap<>();
    }

}

The @View annotation resolves to the views/home.html file we created.

User Details

Write a client class to let us get user details and to logout using FreshBooks’s API. We’ll use Micronaut’s declarative HTTP client because it is so simple:

package ca.airspeed;

import io.micronaut.http.HttpResponse;
import io.micronaut.http.annotation.Body;
import io.micronaut.http.annotation.Get;
import io.micronaut.http.annotation.Header;
import io.micronaut.http.annotation.Post;
import io.micronaut.http.client.annotation.Client;
import io.reactivex.Flowable;
import io.reactivex.Single;

@Header(name="User-Agent", value="Micronaut")
@Client("https://api.freshbooks.com")
public interface FreshBooksApiClient {

    @Get("/auth/api/v1/users/me")
    Flowable<FreshBooksUser> getUser(@Header("Authorization") String authorization);

    @Post("/auth/oauth/revoke")
    Single<HttpResponse<String>> revokeToken(@Body RevokePayload body);
}

Code a simple src/main/java/ca/airspeed/FreshBooksUser.java class to hold the response from that getUser() call:

package ca.airspeed;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.PropertyNamingStrategy;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import io.micronaut.core.annotation.Introspected;
import lombok.Getter;
import lombok.Setter;

@Introspected
@JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy.class)
@Getter
@Setter
public class FreshBooksUser {

    @JsonProperty("response")
    private JsonNode response;
}

The response body in an https://api.freshbooks.com/auth/api/v1/users/me call is huge. For simplicity we’ll use a JsonNode object to let us fish out the few items we want.

Now, the real meat and potatoes of User Details. Code this src/main/java/ca/airspeed/FreshBooksUserDetailsMapper.java class:

package ca.airspeed;

import static java.lang.String.format;
import static java.util.Arrays.asList;

import java.util.HashMap;
import java.util.Map;

import javax.inject.Named;
import javax.inject.Singleton;

import org.reactivestreams.Publisher;

import com.fasterxml.jackson.databind.JsonNode;

import edu.umd.cs.findbugs.annotations.Nullable;
import io.micronaut.core.async.publisher.Publishers;
import io.micronaut.security.authentication.AuthenticationResponse;
import io.micronaut.security.authentication.UserDetails;
import io.micronaut.security.oauth2.endpoint.authorization.state.State;
import io.micronaut.security.oauth2.endpoint.token.response.OauthUserDetailsMapper;
import io.micronaut.security.oauth2.endpoint.token.response.TokenResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Named("freshbooks")
@Singleton
@Slf4j
@RequiredArgsConstructor
public class FreshBooksUserDetailsMapper implements OauthUserDetailsMapper {

    private final FreshBooksApiClient apiClient;

    @Override
    public Publisher<UserDetails> createUserDetails(TokenResponse tokenResponse) {
        return Publishers.just(new UnsupportedOperationException());
    }

    @Override
    public Publisher<AuthenticationResponse> createAuthenticationResponse(TokenResponse tokenResponse,
            @Nullable State state) {
        return apiClient.getUser(format("Bearer %s", tokenResponse.getAccessToken()))
                .map(user -> {
                    Map<String, Object> attributes = new HashMap<>();
                    attributes.put(ACCESS_TOKEN_KEY, tokenResponse.getAccessToken());
                    attributes.put(REFRESH_TOKEN_KEY, tokenResponse.getRefreshToken());
                    JsonNode responseNode = user.getResponse();
                    String email = responseNode.path("email").asText();
                    attributes.put("email", email);
                    attributes.put("firstName", responseNode.path("first_name").asText());
                    attributes.put("lastName", responseNode.path("last_name").asText());
                    return new UserDetails(email, asList(tokenResponse.getScope().split(":")), attributes);
                });
    }
}

The @Named annotation links up with the micronaut.security.oauth2.clients.freshbooks block in application.yml. Since it implements OAuthUserDetailsMapper, it gets called when we have retrieved an access token (the TokenResponse) from FreshBooks.

Notice we’re calling the getUser() method in the FreshBooksApiClient class we coded earlier. We’re passing the access token as an argument so that our HTTP client will stick this in the Authorization header when it calls FreshBooks’ /users/me endpoint.

We’re essentially building an AuthenticationResponse that will be available in the ThymeLeaf template as the security object.

Logging Out

To logout we’ll use our FreshBooksApiClient class to make a call to FreshBooks’ /auth/oauth/revoke endpoint. This will revoke both our access token and refresh token.

First, code this src/main/java/ca/airspeed/RevokePayload.java class:

package ca.airspeed;

import com.fasterxml.jackson.annotation.JsonProperty;

import io.micronaut.core.annotation.Introspected;
import lombok.Builder;
import lombok.Getter;

@Builder
@Getter
@Introspected
public class RevokePayload {
    @JsonProperty("client_id")
    private String clientId;

    @JsonProperty("client_secret")
    private String clientSecret;

    private String token;
}

Second, code this src/main/java/ca/airspeed/FreshBooksLogoutController.java class:

package ca.airspeed;

import static io.micronaut.security.oauth2.endpoint.token.response.OauthUserDetailsMapper.ACCESS_TOKEN_KEY;
import static io.micronaut.security.rules.SecurityRule.IS_AUTHENTICATED;

import java.net.URISyntaxException;
import java.util.Map;

import io.micronaut.http.HttpRequest;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Get;
import io.micronaut.security.annotation.Secured;
import io.micronaut.security.authentication.Authentication;
import io.micronaut.security.handlers.LogoutHandler;
import io.micronaut.security.oauth2.configuration.OauthClientConfiguration;
import io.reactivex.Single;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Controller
@RequiredArgsConstructor
@Slf4j
public class FreshBooksLogoutController {

    private final OauthClientConfiguration clientConfiguration;
    private final FreshBooksApiClient freshBooksApiClient;
    private final LogoutHandler logoutHandler;

    @Get("/oauth/logout/freshbooks")
    @Secured(IS_AUTHENTICATED)
    public Single<HttpResponse<?>> logout(HttpRequest<?> request,
                                          Authentication auth) throws URISyntaxException {
        Map<String, Object> attributes = auth.getAttributes();
        log.debug("Revoking the FreshBooks access token.");
        RevokePayload body = RevokePayload.builder()
                .clientId(clientConfiguration.getClientId())
                .clientSecret(clientConfiguration.getClientSecret())
                .token((String) attributes.get(ACCESS_TOKEN_KEY))
                .build();
        return freshBooksApiClient.revokeToken(body)
                .map(httpResponse -> {
                    log.debug("Response {} - {}.", httpResponse.getStatus().getCode(), httpResponse.getStatus().getReason());
                    return logoutHandler.logout(request);
                });
    }
}

The OauthClientConfiguration and LogoutHandler are beans that Micronaut made and are available for injection here.

The @Get("/oauth/logout/freshbooks") annotation matches up with the logout link in views/home.html.

With the @Secured(IS_AUTHENTICATED), we’re restricting this to authenticated users, obviously.

Though we re not using OpenID, we rely on the default redirect-uri of “/” from openid.end-session.

Run the Application

Let’s kick the tires and light the fires. First, set those shell environment variables we defined earlier. Make sure setenv is executable (chmod 755 setenv), and go:

$ . ./setenv

Second, build and run the application:

$ ./gradlew build run

Finally, browse to https://localhost, accept the warnings about insecurity and a self-signed cert, and follow along.

If you used the code from my GitHub repository, you’ll notice a lot of DEBUG-level messages on the console. Obviously you would not use these in a production environment because of the sensitive information they have such as access tokens.

Turn on the developer tools in your browser, and you’ll see a “JWT” cookie. Copy its value and use https://jwt.io to see its constituent parts. Notice it disappears when you click the logout link.

Wrapping Up

There you have it. This is how you secure your app with FreshBooks using the OAuth 2.0 authorization code grant.

1 thought on “Secure Your Micronaut App With FreshBooks”

Comments are closed.