Okta is one of the leading authentication/authorization server of the market. It comes with an OAuth2 server and therefore our beloved authorization_code flow.

If you are not familiar with this flow, it enables to get authenticated on another webapp and come back on your own application. It is really close to the implicit flow which was originally designed for single page applications (SPA) but is today considered as not secured - since it does not imply the usage of a real (client) secret compared to the authorization_code one which is done server side - and therefore is not recommended.

Okta authentication/callback JAX-RS endpoints

In terms of code it requires two main endpoints:

  1. One to trigger the authentication which will generally end up as a redirection on the login page of Okta,
  2. One to get back the code from the Okta based on a redirection from okta to your application - whitelisted - and call /token endpoint to get an OAuth2 token (generally a RS256 JWT).

Here is what it can mean in terms of JAX-RS endpoints:

@Path("security")
@ApplicationScoped
public class AuthenticationEndpoint {
    @Inject
    private OktaConfiguration configuration;

    @Inject
    @HttpClient(OKTA) // CDI qualifier
    private WebTarget okta;

    @Inject
    private StateManager stateManager;

    @GET
    @Path("authentication") // 1
    public CompletionStage<Response> redirectOnOkta() {
        final String state = stateManager.next();
        return okta.path("authorize")
                .queryParam("client_id", configuration.clientId())
                .queryParam("response_type", "code")
                .queryParam("scope", "openid")
                .queryParam("redirect_uri", configuration.callback())
                .queryParam("state", state)
                .request(WILDCARD_TYPE)
                .rx()
                .get()
                .handle((response, error) -> {
                    if (error != null) {
                        throw new WebApplicationException(Response.status(UNAUTHORIZED).entity(new ErrorMessage(error.getMessage())).build());
                    }
                    if (response.getStatus() != Response.Status.FOUND.getStatusCode()) {
                        throw new WebApplicationException(Response.status(UNAUTHORIZED).entity(new ErrorMessage("Status: " + response.getStatus())).build());
                    }
                    return response;
                });
    }

    @GET
    @Path("callback") // 2
    public CompletionStage<Response> onAuthorizationCodeCallback(@QueryParam("code") final String code,
                                                                 @QueryParam("state") final String state) {
        if (!stateManager.isValid(state)) {
             throw new WebApplicationException(Response.status(BAD_REQUEST).entity(new ErrorMessage("Invalid request")).build());
        }
        return okta.path("token")
                .request(APPLICATION_JSON_TYPE)
                .header(HttpHeaders.AUTHORIZATION,
                       "Basic " + Base64.getEncoder().encodeToString((configuration.clientId() + ':' + configuration.clientSecret()).getBytes(StandardCharsets.UTF_8)))
                .rx()
                .post(entity(new Form()
                        .param("grant_type", "authorization_code")
                        .param("redirect_uri", configuration.callback())
                        .param("code", code), APPLICATION_FORM_URLENCODED_TYPE));
    }
}

Here the code is split in two endpoints:

  1. The /authentication endpoint is the one called on "login" action and will request a code to okta to be able to get a token. It can end up on a redirection on Okta login page. Don't forget to register configuration.callback() url in okta oauth2 server configuration and ensure it matches the endpoint 2.
  2. The /callback endpoint will be called by a redirection from Okta server and will return the result of the /token endpoint of Okta - so the access_token and its metadata.

This is the basic code needed to be able to get a token from Okta. However this code has a few important missing points:

  • The client will likely not understand out of the box that after a few redirections it gets a JWT so the /callback endpoint will generally need a few more work to grab the access token, redirect on a webapp page adding the JWT as a response header the client can gather and handle for future requests.
  • The okta WebTarget must be @Produces - code in a few lines,
  • The StateManager but generate states which can be used to guarantee the returning request is valid and avoid to abuse of the /callback endpoint to get tokens.

CDI producer for a JAX-RS client

Generating the client with JAX-RS can be as simple as this:

@Slf4j // lombok
@ApplicationScoped
public class ClientFactory {
    @Produces // 1
    @ApplicationScoped
    public Client createClient(@Threads(Threads.Type.HTTP) final ExecutorService executorService,
                        final OktaConfiguration configuration) {
        // 2
        final ClientBuilder builder = ClientBuilder.newBuilder()
                .executorService(executorService)
                .connectTimeout(configuration.clientConnectTimeout(), TimeUnit.MILLISECONDS)
                .connectTimeout(configuration.clientReadTimeout(), TimeUnit.MILLISECONDS);

        // 3
        if (configuration.clientAcceptAnyCertificate()) {
            builder.hostnameVerifier((host, session) -> true);
            builder.sslContext(createUnsafeSSLContext());
        } else if (configuration.keystoreLocation().isPresent()) {
            builder.hostnameVerifier((host, session) -> configuration.hostnames().map(it -> it.contains(host)).orElse(false));
            builder.sslContext(createSSLContext(
                    configuration.keystoreLocation().orElseThrow(IllegalArgumentException::new),
                    configuration.keystoreType().orElseGet(KeyStore::getDefaultType),
                    configuration.keystorePassword().orElse("changeit"),
                    configuration.truststoreType().orElseGet(TrustManagerFactory::getDefaultAlgorithm)));
        }

        // 4
        configuration.clientProviders().ifPresent(s -> s.stream()
            .map(String::trim)
            .filter(v -> !v.isEmpty() && !"-".equals(v))
            .map(fqn -> {
                try {
                    return Thread.currentThread().getContextClassLoader().loadClass(fqn).getConstructor().newInstance();
                } catch (final Exception e) {
                    log.warn("Can't add provider " + fqn + ": " + e.getMessage(), e);
                    return null;
                }
            })
            .filter(Objects::nonNull)
            .forEach(builder::register));

        // 5
        return builder.build();
    }


    @Produces // 6
    @ApplicationScoped
    @HttpClient(HttpClient.Type.OKTA)
    public WebTarget oktaWebTarget(final Client client, final OktaConfiguration configuration) {
        return client.target(configuration.baseUrl());
    }

    // 7

    private SSLContext createUnsafeSSLContext() {
        final TrustManager[] trustManagers = { new X509TrustManager() {

            @Override
            public void checkClientTrusted(final X509Certificate[] x509Certificates, final String s) {
                // no-op
            }

            @Override
            public void checkServerTrusted(final X509Certificate[] x509Certificates, final String s) {
                // no-op
            }

            @Override
            public X509Certificate[] getAcceptedIssuers() {
                return null;
            }
        } };
        try {
            final SSLContext sslContext = SSLContext.getInstance("SSL");
            sslContext.init(null, trustManagers, new java.security.SecureRandom());
            return sslContext;
        } catch (final NoSuchAlgorithmException | KeyManagementException e) {
            throw new IllegalStateException(e);
        }
    }

    private SSLContext createSSLContext(final String keystoreLocation,
                                        final String keystoreType,
                                        final String keystorePassword,
                                        final String truststoreType) {
        final Path source = Paths.get(keystoreLocation);
        if (!Files.exists(source)) {
            throw new IllegalArgumentException(source + " does not exist");
        }
        final KeyStore keyStore;
        try (final InputStream stream = Files.newInputStream(source)) {
            keyStore = KeyStore.getInstance(keystoreType);
            keyStore.load(stream, keystorePassword.toCharArray());
        } catch (final KeyStoreException | NoSuchAlgorithmException e) {
            throw new IllegalStateException(e);
        } catch (final CertificateException | IOException e) {
            throw new IllegalArgumentException(e);
        }
        try {
            final TrustManagerFactory trustManagerFactory =
                    TrustManagerFactory.getInstance(truststoreType);
            trustManagerFactory.init(keyStore);
            final SSLContext sslContext = SSLContext.getInstance("TLS");
            sslContext.init(null, trustManagerFactory.getTrustManagers(), new java.security.SecureRandom());
            return sslContext;
        } catch (final KeyStoreException | NoSuchAlgorithmException | KeyManagementException e) {
            throw new IllegalStateException(e);
        }
    }
}

This code is pretty standard but here are the highlights:

  1. We produce a Client which will be used by the whole application, indeed we could do it per usages (Okta, Jira, Amazon, ....) but it is generally enough to do it once then the implementation handles the change of hosts. Doing that you have to use a single truststore for all clients which is generally an advantage in terms of maintenance. If you prefer to split it, ensure to split it thanks to a qualifier - by usages.
  2. The first important configuration for a Client concerns the timeouts and the thread pool for async() or rx() usages. In our cases we want to be rx() since we target a remote system and don't want to block HTTP threads - bulkhead pattern - so we configure a dedicated pool we produced elsewhere.
  3. Then, since we will rely on HTTPS - we are speaking of security here - we need to ensure the SSL context is correctly configured. We have mainly two modes: an unsafe mode for tests and a secured mode with a truststore.
  4. We also optionally add providers to our client to ensure we can read the responses of our requests, this is optional and for a Microprofile or JakartaEE instance this is generally useless but if you build your own JAX-RS/CDI instance you can need to register JSON-B/JSON-P providers.
  5. Now our client is fully configured we just build it.
  6. From a client, the Okta WebTarget is just setting the base path to the client.
  7. The rest of the code is about the SSLContext creation from a configuration, I put it there to ensure the completeness of the code but it is quite standard.

CDI producer for a thread pool

To produce the thread pool - and inject it in the previous client producer method - you can use this class:

@Slf4j
@ApplicationScoped
public class ThreadPoolFactory {
    @Produces
    @ApplicationScoped
    @Threads(Threads.Type.HTTP)
   public ExecutorService httpExecutorService(final ThreadConfiguration configuration) {
        return newPool(configuration.minThreads(), configuration.maxThreads(), "client-http-");
    }

    void releaseHttpPool(@Disposes @Threads(Threads.Type.HTTP) final ExecutorService executorService) {
        executorService.shutdownNow(); // we don't really need to await here for http calls
    }

    private ExecutorService newPool(final int min, final int max, final String prefix) {
        final ThreadPoolExecutor poolExecutor = new ThreadPoolExecutor(
                min, max, 1L, MINUTES, new LinkedBlockingQueue<>(), new ThreadFactory() {
            private final AtomicInteger counter = new AtomicInteger();

            @Override
            public Thread newThread(final Runnable r) {
                return new Thread(r, prefix + String.format("%03d", counter.incrementAndGet()));
            }
        }) {
            @Override
            public void execute(final Runnable command) {
                super.execute(new LoggingErrorRunnable(command));
            }
        };
        poolExecutor.setRejectedExecutionHandler((r, executor) -> log.error("Can't accept {}", r));
        return poolExecutor;
    }

    @AllArgsConstructor // lombok
    private static class LoggingErrorRunnable implements Runnable {
        private final Runnable delegate;

        @Override
        public void run() {
            try {
                delegate.run();
            } catch (final RuntimeException re) {
                log.error(re.getMessage());
                throw re;
            }
        }
    }
}

At that point the code misses three main elements:

  1. Qualifiers - @HttpClient and @Threads, they are important since clients, thread pool etc are common types and they enable to avoid the ambiguity of the beans in a strongly typed fashion.
  2. The configuration,
  3. The state handling.

Custom qualfiiers

To clarify the "type" of a bean, CDI enables to associate to the java type some qualifiers - actually a bean without a qualifier has the @Default qualifier.

A qualifier is just a custom annotation marked with @Qualifier. If it has some methods they are used as parameters and enter into the key of the bean for that particular qualifier. Here is one of our two qualifiers:

@Qualifier
@Retention(RUNTIME)
@Target({FIELD, METHOD, PARAMETER})
public @interface HttpClient {
    Type value();

    enum Type {
        GENERIC, OKTA
    }
}

It enables us to produce our WebTarget for okta and inject it adding to @Inject the annotation @HttpClient(OKTA).

We use the same kind of metadata for the thread pool.

Configuration

The configuration can use a tons of solutions from hardcoded values, through the plain system properties to libraries like owner or Microprofile config.

Here, I will use a Microprofile config extension of Apache Geronimo Config implementation: the proxy based mapping. It is nicer than the plain injections of the specification and enables to reload the bound values consistently to their usage compared to the spec itself since the caching of the value can be evicted at once (and avoid to use the old host with the new port for instance ;)):

@ConfigProperty(name = "company.application.")
public interface OktaConfiguration {
    @ConfigProperty(name = "base")
    String baseUrl();

    @ConfigProperty(name = "callback")
    String oktaCallback();

    @ConfigProperty(name = "client.id")
    String clientId();

    @ConfigProperty(name = "client.secret")
    String clientSecret();
}

This configuration defines the keys;

  • company.application.base: Okta base URL (until the API version),
  • company.application.callback: the redirect_uri (callback endpoint) Okta should use - and has registered for authorization_code flow,
  • company.application.client.[id|secret]: the OAuth2 client id/secret.

If you use CDI scanning mode all then this will be a bean out of the box, if you rely on annotated mode - and don't want to change - you need to register this as a bean since interfaces are skipped by default in annotated mode. This can be done through a CDI extension:

// don't forget META-INF/services registration as well
public class EnsureConfigurationIsDeployed implements Extension {
    public void register(@Observes final BeforeBeanDiscovery beforeBeanDiscovery,
                         final BeanManager beanManager) {
        beforeBeanDiscovery.addAnnotatedType(beanManager.createAnnotatedType(OktaConfiguration.class));
    }
}

State generation/validation

Finally the last missing piece of our solution is what is our StateManager. Its role is to generate state strings and be able to validate them.

The most trivial implementation can be to generate UUID and not validate them - isValid would always return true. This works and relies on the fact the code is secured by itself.

However, you can do a bit better with a few investment. A more trivial implementation can store the generated state into a database and delete it when it comes back (+ a timeout to ensure error case does not lead to fill the database).

I would call this last solution a step 2. It would be nice to use the HTTP session but this is quite hard since the clients can differ and therefore the session is not guaranteed to be inherited. And this is likely the key to implement it properly: use a local secret.

Long story short the idea is to implement a signed string generator, exactly as an OAuth2/JWT server would do to enable to send a string we can validate with a simple SHA256withRSA based algorithm. If we stick to JWT logic, here is what we can do:

  1. Generate a random string (an UUID is ok),
  2. Put it in a JSON object,
  3. Append an expiration date (for instance now + 5 minutes assuming a login must take less than 5 minutes),
  4. Encode that JSON as a base64 string (or another URL friendly format),
  5. Sign that base64 and encode it as a base64 string too,
  6. Concatenate both base64 strings with a separator outside base64 alphabet (like '#' or '.').

Validation is the symmetric process:

  1. Split the string based on the separator,
  2. If there are not exactly 2 strings then it fails,
  3. Decode last strong (base64 decoder),
  4. Ensure last decoded string if the signature of the first string (if not it is considered as invalid).

Here is a potential implementation:

@Slf4j
@ApplicationScoped
public class StateManager {
    @Inject
    private StateConfiguration configuration;

    private final JsonBuilderFactory jsonBuilderFactory = Json.createBuilderFactory(emptyMap());
    private final JsonReaderFactory jsonReaderFactory = Json.createReaderFactory(emptyMap());
    private final Clock clock = Clock.systemUTC();
    private final Base64.Encoder encoder = Base64.getUrlEncoder().withoutPadding();
    private final Base64.Decoder decoder = Base64.getUrlDecoder();
    private RSAPrivateKey privateKey;
    private PublicKey publicKey;

    public void onStart(@Observes @Initialized(ApplicationScoped.class) final Object init) {
        privateKey = RSAPrivateKey.class.cast(loadKey(configuration.jwtPrivateKey()));
        try { // get the public key from the private one, == simpler config
            publicKey = KeyFactory.getInstance("RSA").generatePublic(new RSAPublicKeySpec(
                    privateKey.getModulus(), RSAPrivateCrtKey.class.cast(privateKey).getPublicExponent()));
        } catch (final InvalidKeySpecException | NoSuchAlgorithmException e) {
            throw new IllegalStateException(e);
        }
    }

    public String next() {
        final String state = encoder.encodeToString(
            nextState().toString().getBytes(StandardCharsets.UTF_8));
        final String signature = sign(privateKey, state);
        return state + '#' + signature;
    }

    public boolean verify(final String input) {
        final String[] parts = input.split("#");
        if (parts.length != 2) {
            log.warn("Invalid state token: '{}'", input);
            return false;
        }
        try {
            final Signature signature = Signature.getInstance("SHA256withRSA");
            signature.initVerify(publicKey);
            signature.update(parts[0].getBytes(StandardCharsets.UTF_8));
            final boolean verified = signature.verify(decoder.decode(parts[1]));
            if (!verified) {
                log.warn("Invalid state token: '{}'", input);
                return false;
            }

            try (final JsonReader reader = jsonReaderFactory.createReader(new ByteArrayInputStream(decoder.decode(parts[0])))) {
                final JsonObject object = reader.readObject();
                final long expiresAt = object.getJsonNumber("expiresAt").longValue();
                if (clock.instant().isAfter(Instant.ofEpochMilli(expiresAt))) {
                    log.warn("Expired state token: '{}'", input);
                    return false;
                }
            }

            return true;
        } catch (final Exception e) {
            log.warn("Invalid state token: '{}'", input, e);
            return false;
        }
    }

    private JsonObject nextState() {
        return jsonBuilderFactory.createObjectBuilder()
                .add("marker", marker())
                .add("expiresAt", getExpirationMs())
                .build();
    }

    private long getExpirationMs() {
        return clock.instant().plusMillis(configuration.oktaStateGenerationExpiration()).toEpochMilli();
    }

    private String marker() {
        return UUID.randomUUID().toString().replace("-", "");
    }


    private PrivateKey loadKey(final String key) {
        if ("auto".equals(key)) { // generate a key - for tests!
            try {
                final KeyPairGenerator rsa = KeyPairGenerator.getInstance("RSA");
                rsa.initialize(1024);
                final KeyPair keyPair = rsa.generateKeyPair();
                return keyPair.getPrivate();
            } catch (final NoSuchAlgorithmException e) {
                throw new IllegalStateException(e);
            }
        }

        final byte[] decoded = Base64.getDecoder().decode(key)
                .replace("-----BEGIN RSA KEY-----", "")
                .replace("-----END RSA KEY-----", "")
                .replace("-----BEGIN PRIVATE KEY-----", "")
                .replace("-----END PRIVATE KEY-----", "")
                .replace("-----BEGIN RSA PRIVATE KEY-----", "")
                .replace("-----END RSA PRIVATE KEY-----", "")
                .replace("\n", "")
                .trim());
        final PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(decoded);
        try {
            return KeyFactory.getInstance("RSA").generatePrivate(keySpec);
        } catch (final NoSuchAlgorithmException | InvalidKeySpecException e) {
            throw new IllegalStateException(e);
        }
    }

    private String sign(final PrivateKey privateKey, final String signingString) {
        try {
            final Signature signature = Signature.getInstance("SHA256withRSA");
            signature.initSign(privateKey);
            signature.update(signingString.getBytes(StandardCharsets.US_ASCII));
            return encoder.encodeToString(signature.sign());
        } catch (final NoSuchAlgorithmException | SignatureException | InvalidKeyException e) {
            throw new IllegalStateException(e);
        }
    }
}
  1. next() implements the generation logic,
  2. verify() implements the validation logic.

Conclusion

If you remove the boilerplate code which is mainly ensuring we are stateless and configurable, the integration of Okta OAuth2 server is pretty trivial from an application perspective.

It is also the opportunity to see that Microprofile 1.0 (and it is important to note that this is the core version and that more recent ones are way more oppinatied) perfectly matches modern world and its technologies (HTTP, JSON, Config) and enable productive development (IoC/CDI).

Finally, the important lesson learnt is that you don't need to run on a library for the security but must start by understanding what mecanism you use and what you need. Generally you have everything handy already.

From the same author:

In the same category: