# OAuth with SPAs and a Shared BFF

**GitHub:** [github.com/mnafshin/oauth](https://github.com/mnafshin/oauth)

* * *

## What we are building

A **backend-for-frontend (BFF)** that handles OAuth for two single-page apps:

1.  **React** (Vite, port 5173)
    
2.  **Angular** (port 4200)
    

Both SPAs share one Spring Boot BFF on port **8083**. The BFF:

*   Holds the `client_secret` and exchanges authorization codes
    
*   Stores tokens server-side and issues a **session cookie**
    
*   Exposes a small REST API the SPAs call with `credentials: 'include'`
    
*   Redirects back to whichever SPA started login (`returnTo` URL)
    

The SPAs never see access tokens, refresh tokens, or `client_secret`. That is the point.

**Repo:** [oauth-demo](https://github.com/mnafshin/oauth) → `modules/frontend/` (`bff/`, `react/`, `angular/`)

> **Server-rendered HTML?** See [post 6](./06-spring-security-oauth-client.md) — Thymeleaf on port 8080 uses the same Spring OAuth skills but no CORS or cross-origin cookies.

* * *

## Why not OAuth inside the browser?

Post 3 explained Authorization Code + PKCE for public clients. That works, but for typical product SPAs teams prefer a BFF because:

| Concern | Browser-only SPA | BFF |
| --- | --- | --- |
| `client_secret` | Cannot store safely | Stays on server |
| Refresh token | Risky in JS storage | Server-side only |
| Token exposure | XSS can steal tokens | HttpOnly session cookie |
| API calls | Bearer token in JS | Cookie + optional CSRF |

The BFF pattern trades a small backend for a simpler security story in the browser.

* * *

## Architecture

![](https://cdn.hashnode.com/uploads/covers/69722030e94f080c107ed87f/f036bb70-07e3-4547-9370-8965a2d8b289.png align="center")

* * *

## Repo layout

```text
modules/frontend/
├── bff/       # Gradle module :spa-bff — Spring Boot OAuth + REST
├── react/     # Vite + React + TypeScript
└── angular/   # Angular standalone components
```

Gradle registers the BFF as `:spa-bff` in `settings.gradle`. The Thymeleaf demo (`modules/spring-boot`) is intentionally separate — compare both patterns side by side.

* * *

## Run locally

```bash
# Terminal 1 — IdP (imports spa-bff client + app-user role for alice)
make start-keycloak

# Terminal 2 — shared BFF
export KEYCLOAK_SPA_BFF_SECRET=spa-bff-secret
make start-bff

# Terminal 3 — pick one SPA
make start-react      # http://localhost:5173
# or
make start-angular    # http://localhost:4200
```

1.  Open the SPA home page
    
2.  Click **Login with Keycloak**
    
3.  Sign in as `alice` / `password`
    
4.  Land on `/dashboard` with username and email
    

`/api/dashboard` requires the `spa-bff` client role `app-user` — same role name as the Thymeleaf demo, but on a different Keycloak client.

### Build everything

```bash
make clean-build   # Gradle test + build + both SPA production bundles
```

Each module has its own `Makefile` with `start`, `build`, and `clean` targets.

* * *

## BFF configuration

`modules/frontend/bff/src/main/resources/application.properties`:

```properties
spring.application.name=oauth-bff
server.port=8083

spring.security.oauth2.client.registration.keycloak.client-id=spa-bff
spring.security.oauth2.client.registration.keycloak.client-secret=${KEYCLOAK_SPA_BFF_SECRET:spa-bff-secret}
spring.security.oauth2.client.registration.keycloak.scope=openid,profile,email
spring.security.oauth2.client.registration.keycloak.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.keycloak.redirect-uri={baseUrl}/login/oauth2/code/{registrationId}

spring.security.oauth2.client.provider.keycloak.issuer-uri=http://localhost:8081/realms/oauth-demo

app.cors-allowed-origins[0]=http://localhost:5173
app.cors-allowed-origins[1]=http://localhost:4200
app.allowed-return-urls[0]=http://localhost:5173
app.allowed-return-urls[1]=http://localhost:4200
```

*   `app.cors-allowed-origins` — browsers on 5173/4200 may call the BFF with cookies
    
*   `app.allowed-return-urls` — whitelist for post-login redirect (open redirect protection)
    

* * *

## BFF security highlights

Compared to the Thymeleaf app (post 6), the BFF adds three SPA-specific concerns:

### 1\. CORS with credentials

```java
config.setAllowedOrigins(appProperties.corsAllowedOrigins());
config.setAllowCredentials(true);
```

Without `AllowCredentials`, the browser will not send the session cookie on cross-origin `fetch` calls.

### 2\. CSRF for state-changing requests

```java
.csrf(csrf -> csrf
    .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
)
```

SPAs read the `XSRF-TOKEN` cookie and send `X-XSRF-TOKEN` on `POST /logout`. Thymeleaf embeds CSRF in forms instead.

### 3\. API returns 401, not a login redirect

```java
.exceptionHandling(ex -> ex
    .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
)
```

SPAs handle 401 in JavaScript (show a login button). Server-rendered apps redirect to `/oauth2/authorization/keycloak` automatically.

### 4\. Custom login entry with `returnTo`

```java
@GetMapping("/api/auth/login")
public void login(@RequestParam("returnTo") String returnTo, ...) {
    if (!isAllowedReturnUrl(returnTo)) {
        throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "returnTo is not allowed");
    }
    session.setAttribute("OAUTH_RETURN_TO", returnTo);
    response.sendRedirect("/oauth2/authorization/keycloak");
}
```

After OAuth succeeds, a custom `AuthenticationSuccessHandler` reads `OAUTH_RETURN_TO` from the session and redirects the browser back to React or Angular.

### 5\. Role mapping from `spa-bff` claims

Same pattern as post 6, but the client ID in `resource_access` is `spa-bff`:

```java
Map<String, Object> clientAccess =
        (Map<String, Object>) resourceAccess.get("spa-bff");
```

* * *

## REST API

| Endpoint | Auth | Purpose |
| --- | --- | --- |
| `GET /api/auth/login?returnTo=...` | Public | Start OAuth, remember SPA URL |
| `GET /api/me` | Session | Current user JSON |
| `GET /api/dashboard` | Session + `ROLE_app-user` | Protected demo data |
| `POST /logout` | Session + CSRF | App logout + OIDC logout |

* * *

## React client

`modules/frontend/react/src/api/bff.ts` — thin wrapper around `fetch`:

```typescript
export const BFF_URL = import.meta.env.VITE_BFF_URL ?? 'http://localhost:8083'

export function login(returnTo: string): void {
  const url = new URL(`${BFF_URL}/api/auth/login`)
  url.searchParams.set('returnTo', returnTo)
  window.location.href = url.toString()
}

export async function fetchDashboard(): Promise<SessionUser> {
  const response = await fetch(`${BFF_URL}/api/dashboard`, {
    credentials: 'include',
  })
  if (!response.ok) throw new Error(`Dashboard request failed: ${response.status}`)
  return response.json()
}

export async function logout(): Promise<void> {
  const csrfToken = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]+)/)?.[1]
  await fetch(`${BFF_URL}/logout`, {
    method: 'POST',
    credentials: 'include',
    headers: csrfToken ? { 'X-XSRF-TOKEN': decodeURIComponent(csrfToken) } : {},
  })
  window.location.href = '/'
}
```

The dashboard page calls `fetchDashboard()` on mount. If the user is not logged in, the BFF returns **401** and the UI shows a login button.

* * *

## Angular client

`modules/frontend/angular/src/app/auth.service.ts` — same contract, Angular idioms:

```typescript
const BFF_URL = 'http://localhost:8083';

login(returnTo: string): void {
  const url = new URL(`${BFF_URL}/api/auth/login`);
  url.searchParams.set('returnTo', returnTo);
  window.location.href = url.toString();
}

fetchDashboard(): Observable<SessionUser> {
  return this.http.get<SessionUser>(`${BFF_URL}/api/dashboard`, {
    withCredentials: true,
  });
}
```

`provideHttpClient(withFetch())` in `app.config.ts` enables the Fetch API backend. Components inject `AuthService` — no OAuth library required because the BFF owns the protocol.

* * *

## Cross-origin cookies in local dev

React runs on `localhost:5173`, the BFF on `localhost:8083`. These are **different origins** (port matters). The session cookie is set for `:8083` during the OAuth callback; the SPA sends it back on API calls because:

1.  `fetch` / `HttpClient` uses `credentials: 'include'` / `withCredentials: true`
    
2.  CORS allows credentials from the SPA origin
    
3.  The cookie is **not** `SameSite=Strict` blocking cross-site subrequests in this dev setup
    

In production, teams often serve the SPA and BFF under one parent domain (e.g. `app.example.com` and `app.example.com/api`) to simplify cookies — or use a reverse proxy on a single origin.

* * *

## Thymeleaf vs BFF vs plain Java

|  | Thymeleaf (post 6) | BFF (this post) | Plain Java (post 7) |
| --- | --- | --- | --- |
| Port | 8080 | 8083 | 8082 |
| Keycloak client | `orders-web` | `spa-bff` | `orders-web` |
| UI | Server HTML | React / Angular | Hand-rolled HTML |
| OAuth code | Spring Security | Spring Security | Manual HttpClient |
| CORS | Not needed | Required | Not needed |
| CSRF | Form hidden field | Cookie + header | You implement |

All three enforce `app-user` — the authorization rule is the same; only the integration shape changes.

* * *

## Go deeper

### Serving the SPA from the BFF (single origin)

For production, build the React/Angular app and copy static files into `src/main/resources/static` on the BFF. One port, no CORS, simpler cookies. The OAuth redirect URI stays on the BFF host.

### Token relay to downstream APIs

The BFF can call microservices with the user's access token server-side:

```java
@RegisteredOAuth2AuthorizedClient("keycloak") OAuth2AuthorizedClient client
```

The SPA never sees the Bearer token.

### Adding a third SPA

Add its dev-server origin to `app.cors-allowed-origins` and `app.allowed-return-urls`. No new Keycloak client required — that is why the BFF is shared.

### Testing the BFF

`ApiControllerTest` uses MockMvc + `oauth2Login()` — same approach as post 6. End-to-end tests need Keycloak (docker-compose) or Testcontainers.

* * *

## What you should know after this post

*   \[ \] You understand why SPAs use a BFF instead of storing tokens in the browser
    
*   \[ \] You can run the shared BFF with React or Angular against local Keycloak
    
*   \[ \] You know the extra Spring Security pieces: CORS, CSRF cookie, 401 entry point, `returnTo` whitelist
    
*   \[ \] You can explain when to use Thymeleaf OAuth (post 6) vs a BFF (this post)
    
*   \[ \] You have seen the same authorization rule (`app-user`) across all demo apps
    

* * *
