Skip to main content

Command Palette

Search for a command to run...

OAuth in Plain Java

Updated
5 min readView as Markdown

GitHub: github.com/mnafshin/oauth


Why this post exists

Spring Security hides PKCE, state, discovery, and token exchange behind auto-configuration. That is good for productivity — bad for interviews and incident debugging.

Here we implement Authorization Code + PKCE with Java's built-in HttpClient and a minimal embedded HTTP server (or servlet). No Spring Security.

After this post, Spring's magic becomes named steps you already understand from the Authorization Code + PKCE walkthrough.


Demo repo

The oauth-demo monorepo ships a runnable module at modules/plain-java:

make start-keycloak
make start-plain-java
# or:
# docker compose -f modules/keycloak/docker-compose.yml up -d
# ./gradlew :plain-java:run
Setting Value
App URL http://localhost:8082
Redirect URI http://localhost:8082/callback
Client orders-web / orders-web-secret (same Keycloak realm as the Thymeleaf Spring Boot post)
Test user alice / password

Visit /dashboard — the app requires the app-user client role on orders-web, same as the Thymeleaf demo on port 8080. The SPA BFF demo (post 9) uses a separate Keycloak client spa-bff on port 8083.


Architecture

We store:

  • state + code_verifier in server-side session (in-memory ConcurrentHashMap for demo)

  • Session ID in httpOnly cookie


Step 1 — PKCE and state utilities

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Base64;

public final class Pkce {

    private static final SecureRandom RANDOM = new SecureRandom();

    public static String randomUrlSafe(int bytes) {
        byte[] buf = new byte[bytes];
        RANDOM.nextBytes(buf);
        return Base64.getUrlEncoder().withoutPadding().encodeToString(buf);
    }

    public static String codeChallengeS256(String verifier) {
        try {
            byte[] hash = MessageDigest.getInstance("SHA-256")
                .digest(verifier.getBytes(StandardCharsets.US_ASCII));
            return Base64.getUrlEncoder().withoutPadding().encodeToString(hash);
        } catch (Exception e) {
            throw new IllegalStateException(e);
        }
    }
}

Step 2 — Build authorization URL

public URI buildAuthorizeUrl(OidcDiscovery doc, String clientId, String redirectUri,
                             String scope, String state, String codeChallenge) {
    String query = "response_type=code"
        + "&client_id=" + urlEncode(clientId)
        + "&redirect_uri=" + urlEncode(redirectUri)
        + "&scope=" + urlEncode(scope)
        + "&state=" + urlEncode(state)
        + "&code_challenge=" + urlEncode(codeChallenge)
        + "&code_challenge_method=S256";
    return URI.create(doc.authorizationEndpoint() + "?" + query);
}

Fetch OidcDiscovery once from /.well-known/openid-configuration (parse JSON with Jackson).

public record OidcDiscovery(
    String issuer,
    String authorizationEndpoint,
    String tokenEndpoint,
    String jwksUri
) {}

Step 3 — Token exchange

public TokenResponse exchangeCode(OidcDiscovery doc, String clientId, String clientSecret,
                                  String redirectUri, String code, String codeVerifier)
        throws IOException, InterruptedException {

    String body = "grant_type=authorization_code"
        + "&code=" + urlEncode(code)
        + "&redirect_uri=" + urlEncode(redirectUri)
        + "&client_id=" + urlEncode(clientId)
        + "&code_verifier=" + urlEncode(codeVerifier);

    HttpRequest request = HttpRequest.newBuilder()
        .uri(URI.create(doc.tokenEndpoint()))
        .header("Content-Type", "application/x-www-form-urlencoded")
        .header("Authorization", basicAuth(clientId, clientSecret))
        .POST(HttpRequest.BodyPublishers.ofString(body))
        .build();

    HttpResponse<String> response = HttpClient.newHttpClient()
        .send(request, HttpResponse.BodyHandlers.ofString());

    if (response.statusCode() != 200) {
        throw new IOException("Token error: " + response.body());
    }
    return parseTokenResponse(response.body());
}

Step 4 — Validate ID token (do not skip)

public JWTClaimsSet validateIdToken(String idTokenJwt, OidcDiscovery doc, String clientId) {
    // Use com.nimbusds:nimbus-jose-jwt
    // 1. Parse JWT and verify RS256 signature against JWKS from doc.jwksUri() (cache keys)
    // 2. require iss == doc.issuer()
    // 3. require aud contains clientId
    // 4. require exp in future (with clock skew)
    return claims;
}

Do not parse the payload with Base64.decode and call it a day.


Step 5 — /login handler (conceptual)

void handleLogin(HttpExchange exchange, HttpSession session) throws IOException {
    String state = Pkce.randomUrlSafe(32);
    String verifier = Pkce.randomUrlSafe(64);
    String challenge = Pkce.codeChallengeS256(verifier);

    session.put("oauth_state", state);
    session.put("code_verifier", verifier);

    URI authorize = buildAuthorizeUrl(discovery, CLIENT_ID, REDIRECT_URI,
        "openid profile email", state, challenge);

    exchange.getResponseHeaders().add("Location", authorize.toString());
    exchange.sendResponseHeaders(302, -1);
}

Step 6 — /callback handler

void handleCallback(HttpExchange exchange, HttpSession session) throws Exception {
    Map<String, String> params = parseQuery(exchange.getRequestURI().getQuery());

    if (!params.get("state").equals(session.get("oauth_state"))) {
        sendError(exchange, 400, "Invalid state");
        return;
    }

    TokenResponse tokens = exchangeCode(discovery, CLIENT_ID, CLIENT_SECRET,
        REDIRECT_URI, params.get("code"), session.get("code_verifier"));

    JwtClaimsSet idToken = validateIdToken(tokens.idToken(), discovery, CLIENT_ID);

    if (!hasAppUserRole(idToken)) {
        sendError(exchange, 403, "Missing app-user role");
        return;
    }

    session.put("user", idToken.getSubject());
    session.put("username", idToken.getStringClaim("preferred_username"));
    session.remove("oauth_state");
    session.remove("code_verifier");
    // Store refresh token server-side only if you need offline access

    redirect(exchange, "/dashboard");
}

What you feel when it works

  1. Redirect URI must match character-for-character — one trailing slash breaks everything.

  2. Clock skew — laptop vs Docker VM time drift causes exp validation failures.

  3. Client auth method — Keycloak may expect POST body secret vs Basic auth; match client config.

  4. Session fixation — issue new session ID after login.


Comparison: plain Java vs Spring Security

Concern Plain Java Spring Security
PKCE You generate Automatic
State You store/verify Automatic
Discovery You fetch/cache issuer-uri
Token refresh You schedule OAuth2AuthorizedClientManager
CSRF on forms You add Built-in
Lines of code ~200–400 ~30 config

Use Spring in production unless you have a compelling reason not to.


Go deeper

Refresh token rotation

String body = "grant_type=refresh_token&refresh_token=" + urlEncode(storedRefresh);
// POST to token endpoint; replace stored refresh if server rotates

PAR (Pushed Authorization Requests)

POST authorize parameters to pushed_authorization_request_endpoint first; use returned request_uri in place of long query strings.

Embedding in non-servlet apps

Same flow works in Netty, Vert.x, or Lambda@Edge for BFF — HTTP redirects and server-side session storage are universal.


What you should know after this post

  • [ ] You can implement PKCE and state without a framework

  • [ ] You know ID token validation is mandatory

  • [ ] You appreciate what Spring Security automates

  • [ ] You can run the plain Java demo against local Keycloak (make start-plain-java)