# OAuth in Plain Java

**GitHub:** [github.com/mnafshin/oauth](https://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](https://github.com/mnafshin/oauth) monorepo ships a runnable module at `modules/plain-java`:

```bash
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

![](https://cdn.hashnode.com/uploads/covers/69722030e94f080c107ed87f/7e4c438c-375e-4205-b5b0-dddf93dfbb1e.png align="center")

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

```java
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

```java
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).

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

## Step 3 — Token exchange

```java
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)

```java
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)

```java
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

```java
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

```java
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`)
    

* * *
