Skip to content

Commit

Permalink
Use OAuth2AuthorizedClientProvider implementations relying on RestCli…
Browse files Browse the repository at this point in the history
…ent (and make the later configurable)
  • Loading branch information
ch4mpy committed Jan 29, 2025
1 parent 6144cd0 commit 84d577c
Show file tree
Hide file tree
Showing 13 changed files with 609 additions and 313 deletions.
2 changes: 1 addition & 1 deletion README.MD
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Ease OAuth2 / OpenID in Spring RESTful backends

`8.2.0` is :rocket:. It is designed to work with Spring Boot `3.4.2` (Security `6.4.2` and Cloud `2024.0.0`). See [the release notes](https://github.com/ch4mpy/spring-addons/blob/master/release-notes.md#800) for details.
`8.1.1` is :rocket:. It is designed to work with Spring Boot `3.4.2` (Security `6.4.2` and Cloud `2024.0.0`). See [the release notes](https://github.com/ch4mpy/spring-addons/blob/master/release-notes.md#800) for details.

The new [`spring-addons-starter-rest`](https://github.com/ch4mpy/spring-addons/tree/master/spring-addons-starter-rest) can be a game changer for inter-service calls when OAuth2 or an HTTP proxy is involved. Give it a try!

Expand Down
4 changes: 3 additions & 1 deletion release-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ For Spring Boot 3.4.x.

`spring-addons-starter-rest` provides auto-configuration for `RestClient`, `WebClient` and tooling for `@HttpExchange` proxy generation.

### `8.2.0`
### `8.1.1`
- Drop the multi-tenancy support for `oauth2Login`. Spring Security is way too targetted to single tenancy and the work-arounds are a nightmare to maintain. So, when configuring more than one `registration` with `authorization_code`, make sure that a user is logged out before allowing him to log in again. The `resource-server_with_ui` is modified with this new paradigm.
- Fix the URIs generated by the default `InvalidSessionStrategy` introduced in `8.1.0`
- Switch from the deprecated `OAuth2AuthorizedClientProvider` (using `RestTemplate`) to the recent ones using `RestClient`
- Make the `RestClient`/`WebClient` used by the `(Reactive)OAuth2AuthorizedClientProvider` [easily configurable](https://github.com/ch4mpy/spring-addons/tree/master/spring-addons-starter-oidc#2-13)

### `8.1.0`
- Change the default `InvalidSessionStrategy` in the auto-configured security filter chain for servlets with `oauth2Login`. The new behavior is to create a new (anonymous) session and redirect to the same URI (retry with a new session cookie). This sensible default in most cases (including the servlet version of Spring Cloud Gateway configured as an OAuth2 BFF) and can be modified with `invalid-session` properties. For instance, to return a `401` with a `Location` header pointing to the `/login` endpoint (which should be included among `security-matcher` and `permit-all`):
Expand Down
42 changes: 25 additions & 17 deletions samples/tutorials/resource-server_with_ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,20 +77,9 @@ The properties under `rest` define the configuration for a `RestClient` bean nam

Don't forget to update the issuer URIs as well as client ID & secrets with your own (or to override it with command line arguments, environment variables or whatever).

#### 4.2. OAuth2 Security Filter-Chain
### 4.2. OAuth2 Security Filter-Chain
**We have absolutely no Java code to write.**

### 4.3. RP-Initiated Logout
This one is tricky. It is important to have in mind that each user has a session on our client but also on each authorization server.

If we invalidate only the session on our client, it is very likely that the next login attempt with the same browser will complete silently. For a complete logout, **both client and authorization sessions should be terminated**.

OIDC specifies two logout protocols:
- [RP-Initiated Logout](https://openid.net/specs/openid-connect-rpinitiated-1_0.html) where a client asks the authorization-server to terminate a user session
- [back-channel logout](https://openid.net/specs/openid-connect-backchannel-1_0.html) where the authorization-server brodcasts a logout event to a list of registered clients so that each can terminate its own session for the user

Here, we cover only the RP-Initiated Logout.

## 5. Resource Server Components
As username and roles are already mapped, it's super easy to build a greeting containing both from the `Authentication` instance in the security-context:
```java
Expand All @@ -105,16 +94,35 @@ public class ApiController {
}
```

## 6. Client Components and Resources
We'll need a few resources: a static index as well as a a pair of templates with a controllers to serve it.
## 6. `oauth2Login` components
`oauth2Login` configures an OAuth2 client for authorization code and refresh token flows. As a reminder application with `oauth2Login` are stateful (rely on sessions, like any server-side application with "login"), and tokens are stored in session (on the server).

### 6.1. Logout
This one is tricky. It is important to have in mind that each user has a session on our Spring application with `oauth2Login`, and a different one on the OpenID Provider.

If we invalidate only the session on our client, it is very likely that the next login attempt with the same browser will complete silently (authorization servers usually don't ask for credentials when their session for the user is still active). For a complete logout, **both client and authorization sessions should be terminated**.

OIDC specifies two logout protocols:
- [RP-Initiated Logout](https://openid.net/specs/openid-connect-rpinitiated-1_0.html) where a client asks the authorization-server to terminate a user session
- [Back-Channel Logout](https://openid.net/specs/openid-connect-backchannel-1_0.html) where the authorization-server brodcasts a logout event to a list of registered clients so that each can terminate its own session for the user

Here, we cover only the RP-Initiated Logout in which:
1. The user-agent send a `POST` request to the `/logout` endpoint of the Relying Party (aka RP, the Spring app with `oauth2Login`)
2. The RP terminates the session it has for this user-agent and redirects it to the OpenID Provider `end_session_endpoint` (part of OpenID configuration at `/.well-known/openid-configuration`)
3. The OP terminates the session it has for this user-agent and redirects it to the `post_logout_redirect_uri` provided as parameter to the request

What we'll see here is specific to multi-tenancy needs. With a single identity provider, we'd redirect the user directly to the authentication endpoint instead of displaying a page to choose login options and configure the standard logout endpoint with a `LogoutSuccessHandler` adapted to the authorization server logout endpoint (see `SpringAddonsOAuth2LogoutSuccessHandler` Javadoc).
As the RP security is based on sessions, it should be protected against CSRF. And **as the initial logout request is a `POST`, it must contain a valid CSRF token**. The companion project is configured with a `javascript` Spring profile to switch between two scenarios:
- with the default profile, the `POST` request is sent with a plain HTML form. Spring Security's default CSRF token repository is kept (`HttpSessionCsrfTokenRepository`) and the token handling is done transparently by Spring's Thymleaf integration (a `hidden` input is added to the DOM with `_csrf` token value)
- when the `javascript` profile is active, the logout request is sent using JQuery `ajax` function. Some additional `application.yml` properties ask `spring-addons-starter-oidc` to:
- use a `CookieCsrfTokenRepository` and to set this cookies' `HttpOnly` flag to `false` (make the CSRF token value accessible to Javascript)
- switch the response status for the RP response (end of step `2.` above) from `302` to `202`. This enables the Javascript code to observe the response and to follow to the OP `end_session_endpoint` with a plain navigation (prevents from running into CORS errors with a cross-origin redirection of an ajax request)

Refer to sources for UI controllers and tempaltes.
### 6.2. Consuming REST micro-services
Once a session "authorized" (the user logged in), it contains an access token. A REST client can be configured to use this token a `Bearer` in the `Authorization` header to authorize its requests to OAuth2 resource servers. In this project, we use `spring-addons-starter-rest` to auto-configure a `RestClient` instance with an `OAuth2ClientHttpRequestInterceptor` to do so. We also use a generated proxy for an `@HttpExchange` interface describing the resource server to consume (see `RestClientsConfig` and `GreetApi`).

## 7. Conclusion
In this tutorial we saw how to configure different security filter-chains and select to which routes each applies. We set up
- an OAuth2 client filter-chain with login, logout and sessions (and CSRF protection) for UI
- a state-less (neither session nor CSRF protection) filter-chain for the REST API

We also saw how handy `spring-addons-webmvc-jwt-resource-server` and `spring-addons-webmvc-client` are when it comes to configuring respectively OAuth2 resource servers and OAuth2 clients.
We also saw how handy `spring-addons-starter-oidc` and `spring-addons-starter-rest` are when it comes to configuring RPs security and to consume REST micro-services secured with OAuth2.
Original file line number Diff line number Diff line change
@@ -1,21 +1,29 @@
package com.c4soft.springaddons.tutorials.ui;

import java.net.URISyntaxException;
import java.util.List;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.servlet.view.RedirectView;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Controller
@RequiredArgsConstructor
@Slf4j
public class UiController {
private final GreetApi greetApi;
private final List<String> activeProfiles;

public UiController(GreetApi greetApi,
@Value("${spring.profiles.active:[]}") List<String> activeProfiles) {
super();
this.greetApi = greetApi;
this.activeProfiles = activeProfiles;
}

@GetMapping({"", "/",})
public RedirectView getIndex() throws URISyntaxException {
Expand All @@ -38,6 +46,6 @@ public String getGreeting(HttpServletRequest request, Authentication auth, Model
} catch (Throwable e) {
model.addAttribute("greeting", e.getMessage());
}
return "greet";
return activeProfiles.contains("javascript") ? "greet-js" : "greet";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,18 @@ com:
# post-logout-uri-request-param: returnTo
# client-id-request-param: client_id

---
spring.config.activate.on-profile: javascript

com:
c4-soft:
springaddons:
oidc:
client:
csrf: cookie-accessible-from-js
oauth2-redirections:
rp-initiated-logout: accepted

---
spring.config.activate.on-profile: ssl

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">

<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="description" content="">
<meta name="author" content="">
<title>Greetings!</title>
<link href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-/Y6pD6FV/Vv2HJnA6t+vslU6fwYXjCFtcEpHbNJ0lyAFsXTsjBbfaDjzALeQsN6M" crossorigin="anonymous">
<link href="https://getbootstrap.com/docs/4.0/examples/signin/signin.css" rel="stylesheet"
crossorigin="anonymous" />
</head>

<body>
<div class="container">
<h1 class="form-signin-heading">Greetings from the REST API</h1>
<div th:utext="${greeting}">..!..</div>
<button type="submit" th:onclick="logout()">Logout with Javascript</button>
</div>
<script th:inline="javascript">
function getCsrfToken() {
var parts = document.cookie.split("XSRF-TOKEN=");
if (parts.length == 2) {
return parts.pop().split(";").shift();
}
}

function logout() {
$.ajax({
url: "/logout",
headers: {
"X-XSRF-TOKEN": getCsrfToken()
},
type: "POST",
async: false,
success: function (data, textStatus, response) {
const rpInitiatedLogoutLocation = response.getResponseHeader('location');
window.location = rpInitiatedLogoutLocation;
},
error: function (jqXHR, textStatus, errorThrown) {
console.log(textStatus, errorThrown)
}
});
}
</script>
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
</body>
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,23 @@
<html xmlns:th="http://www.thymeleaf.org">

<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="description" content="">
<meta name="author" content="">
<title>Greetings!</title>
<link href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-/Y6pD6FV/Vv2HJnA6t+vslU6fwYXjCFtcEpHbNJ0lyAFsXTsjBbfaDjzALeQsN6M" crossorigin="anonymous">
<link href="https://getbootstrap.com/docs/4.0/examples/signin/signin.css" rel="stylesheet"
crossorigin="anonymous" />
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="description" content="">
<meta name="author" content="">
<title>Greetings!</title>
<link href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-/Y6pD6FV/Vv2HJnA6t+vslU6fwYXjCFtcEpHbNJ0lyAFsXTsjBbfaDjzALeQsN6M" crossorigin="anonymous">
<link href="https://getbootstrap.com/docs/4.0/examples/signin/signin.css" rel="stylesheet"
crossorigin="anonymous" />
</head>

<body>
<div class="container">
<h1 class="form-signin-heading">Greetings from the REST API</h1>
<div th:utext="${greeting}">..!..</div>
<form method="POST" action="@{/logout}">
<button type="submit">Logout</button>
</form>
<div class="container">
<h1 class="form-signin-heading">Greetings from the REST API</h1>
<div th:utext="${greeting}">..!..</div>
<form method="POST" th:action="@{/logout}">
<button type="submit">Logout</button>
</form>
</div>
</body>
Original file line number Diff line number Diff line change
Expand Up @@ -2,33 +2,33 @@
<html lang="en">

<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="description" content="">
<meta name="author" content="">
<title>Login</title>
<link href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-/Y6pD6FV/Vv2HJnA6t+vslU6fwYXjCFtcEpHbNJ0lyAFsXTsjBbfaDjzALeQsN6M" crossorigin="anonymous">
<link href="https://getbootstrap.com/docs/4.0/examples/signin/signin.css" rel="stylesheet"
crossorigin="anonymous" />
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="description" content="">
<meta name="author" content="">
<title>Login</title>
<link href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-/Y6pD6FV/Vv2HJnA6t+vslU6fwYXjCFtcEpHbNJ0lyAFsXTsjBbfaDjzALeQsN6M" crossorigin="anonymous">
<link href="https://getbootstrap.com/docs/4.0/examples/signin/signin.css" rel="stylesheet"
crossorigin="anonymous" />
</head>

<body>
<div class="container">
<div th:if="${!isAuthenticated}">
<h2 class="form-signin-heading">Choose an authorization server</h2>
<table class="table table-striped">
<tr th:each="opt : ${loginOptions}">
<td><a th:href="${opt.loginPath}"><button type="button" th:utext="${opt.name}">..!..</button></a></td>
</tr>
</table>

</div>
<div th:if="${isAuthenticated}">
<p>You're already logged in. Logout before you can choose another authorization server.</p>
<a href="/bulk-logout-idps"><button type="button">Logout</button></a>
</div>
</div>
<div class="container">
<div th:if="${!isAuthenticated}">
<h2 class="form-signin-heading">Choose an authorization server</h2>
<table class="table table-striped">
<tr th:each="opt : ${loginOptions}">
<td><a th:href="${opt.loginPath}"><button type="button" th:utext="${opt.name}">..!..</button></a>
</td>
</tr>
</table>
</div>
<div th:if="${isAuthenticated}">
<p>You're already logged in. Logout before you can choose another authorization server.</p>
<a href="/bulk-logout-idps"><button type="button">Logout</button></a>
</div>
</div>
</body>

</html>
Loading

0 comments on commit 84d577c

Please sign in to comment.