1
0

Add Spring Authorization Server project: OAuth2/OIDC provider, client and resource server

Three modules on Spring Boot 4.1.1 with Spring Authorization Server 7.1.1: the provider
itself, a relying party, and an API that trusts its tokens. Client registration, PKCE,
a custom consent page and token customisation, with profiles that make each failure
reproducible.

Every claim is backed by captured output in docs/output/as-*.txt, regenerated by
authorization-server/scripts/run-all.sh. Notable findings, verified against the jars:

  - OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(HttpSecurity) was deleted
    in 7.0, and both configuration classes moved into spring-security-config
  - ClientSettings.requireProofKey flipped from false to true, on the authorization server
    (1.5.8 -> 7.1.1) and on the OAuth2 client (6.5.1 -> 7.1.1)
  - requireProofKey(false) does not make PKCE optional for a public client; the code
    verifier is that client's only authentication at the token endpoint
  - MediaTypeRequestMatcher(TEXT_HTML) matches Accept: */*, so the token endpoint answers
    API callers with 302 -> /login unless setIgnoredMediaTypes(ALL) is called

Also renames the repository to spring-auth-demo and cross-links the new chapter set from
the existing documentation.
This commit is contained in:
2026-08-24 08:12:36 +05:30
parent 4dc45d5e00
commit e9381dc5be
89 changed files with 5237 additions and 9 deletions

View File

@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.ankurm</groupId>
<artifactId>authorization-server-demo</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>oidc-client</artifactId>
<name>oidc-client</name>
<description>A relying party that logs in against auth-server and calls resource-server</description>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,17 @@
package com.ankurm.client;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* The relying party. Runs on :8080 and is the only piece a human touches.
*
* @see <a href="../../../../../../../../docs/authorization-server/08-client.md">
* docs/authorization-server/08-client.md</a>
*/
@SpringBootApplication
public class ClientApplication {
public static void main(String[] args) {
SpringApplication.run(ClientApplication.class, args);
}
}

View File

@@ -0,0 +1,39 @@
package com.ankurm.client;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
/**
* The whole client side of OIDC, in one method.
*
* <p>{@code oauth2Login()} makes this an OIDC relying party: it adds the redirect
* endpoint at {@code /login/oauth2/code/{registrationId}}, exchanges the code, validates
* the {@code id_token}, and creates a session. {@code oauth2Client()} additionally makes
* the access token available to outgoing HTTP calls.
*
* <p>The redirect URI is derived from the registration id, and it must match what the
* provider has registered <b>byte for byte</b>. This app is reached at
* {@code 127.0.0.1:8080} rather than {@code localhost:8080} for a reason: they are
* different origins to a browser cookie jar, and running the client on {@code localhost}
* next to an authorization server on {@code localhost} means one session cookie can
* clobber the other. Using {@code 127.0.0.1} for the client keeps the two sessions
* separate, which is the difference between a working demo and a login loop.
*/
@Configuration(proxyBeanMethods = false)
public class ClientSecurityConfig {
@Bean
public SecurityFilterChain chain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/error").permitAll()
.anyRequest().authenticated())
.oauth2Login(Customizer.withDefaults())
.oauth2Client(Customizer.withDefaults())
.logout(logout -> logout.logoutSuccessUrl("/"));
return http.build();
}
}

View File

@@ -0,0 +1,56 @@
package com.ankurm.client;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.annotation.RegisteredOAuth2AuthorizedClient;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.client.RestClient;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Renders what the client received, and calls the resource server with it.
*
* <p>{@code @RegisteredOAuth2AuthorizedClient} hands you the access token Spring already
* holds for this user and this registration. Reading it out of the {@code OidcUser} would
* give you the <i>id_token</i> instead - a mistake that produces a 401 from the resource
* server with a token that looks perfectly valid, because it is: it is just the wrong one.
*/
@Controller
public class HomeController {
private final RestClient restClient = RestClient.create();
@GetMapping("/")
public String home(@AuthenticationPrincipal OidcUser user, Model model) {
model.addAttribute("user", user);
if (user != null) {
model.addAttribute("idTokenClaims", user.getIdToken().getClaims());
}
return "home";
}
@GetMapping("/orders")
public String orders(@RegisteredOAuth2AuthorizedClient("demo-web") OAuth2AuthorizedClient client,
Model model) {
String token = client.getAccessToken().getTokenValue();
Map<String, Object> body;
try {
body = this.restClient.get()
.uri("http://localhost:8090/api/orders")
.header("Authorization", "Bearer " + token)
.retrieve()
.body(Map.class);
} catch (Exception ex) {
body = new LinkedHashMap<>(Map.of("error", String.valueOf(ex.getMessage())));
}
model.addAttribute("accessToken", token);
model.addAttribute("scopes", client.getAccessToken().getScopes());
model.addAttribute("orders", body);
return "orders";
}
}

View File

@@ -0,0 +1,82 @@
package com.ankurm.client;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository;
/**
* Reproduces the pre-7.0 client, so the mismatch can be observed rather than described.
*
* <h2>What changed, on both sides at once</h2>
*
* Read out of the jars with {@code javap} &mdash; see
* {@code docs/output/as-settings-defaults.txt}:
*
* <table>
* <tr><th></th><th>previous</th><th>current</th></tr>
* <tr><td>Authorization server &mdash; {@code ClientSettings.requireProofKey}</td>
* <td>{@code false} (SAS 1.5.8)</td><td>{@code true} (7.1.1)</td></tr>
* <tr><td>Client &mdash; {@code ClientRegistration.ClientSettings.requireProofKey}</td>
* <td>{@code false} (Spring Security 6.5.1)</td><td>{@code true} (7.1.1)</td></tr>
* </table>
*
* <p>Because both moved together, Spring-client-to-Spring-server keeps working. The
* combination that breaks is a <b>7.1 authorization server with anything older or
* anything hand-rolled</b> in front of it: a Spring Security 6.x confidential client, a
* bespoke server-side integration, a Postman collection someone saved last year. The
* server rejects the authorization request before login, and the user sees the client's
* error page rather than the provider's:
*
* <pre>
* error=invalid_request&amp;error_description=OAuth%202.0%20Parameter%3A%20code_challenge
* </pre>
*
* <p>Activating the {@code nopkce} profile rebuilds this client's registration with
* {@code requireProofKey(false)}, which is exactly what a 6.x client would have sent.
*
* @see <a href="../../../../../../../../docs/authorization-server/08-client.md">
* docs/authorization-server/08-client.md</a>
*/
@Configuration(proxyBeanMethods = false)
@Profile("nopkce")
public class PkceConfig {
/**
* Note that {@code DefaultOAuth2AuthorizationRequestResolver} consults
* {@code ClientRegistration}'s own {@code ClientSettings}, not the resolver's
* customizer &mdash; so setting a customizer cannot turn PKCE <i>off</i>. It has to be
* turned off on the registration itself.
*
* <p>A {@code @Bean} that takes {@code ClientRegistrationRepository} and returns one
* is a dependency cycle, and Boot refuses to start
* ({@code Relying upon circular references is discouraged and they are prohibited by
* default}). Post-processing the bean Boot already built avoids that.
*/
@Bean
static BeanPostProcessor downgradeToPreSevenClient() {
return new BeanPostProcessor() {
@Override
public Object postProcessAfterInitialization(Object bean, String name)
throws BeansException {
if (!(bean instanceof ClientRegistrationRepository repo)) {
return bean;
}
ClientRegistration original = repo.findByRegistrationId("demo-web");
if (original == null) {
return bean;
}
return new InMemoryClientRegistrationRepository(
ClientRegistration.withClientRegistration(original)
.clientSettings(ClientRegistration.ClientSettings.builder()
.requireProofKey(false)
.build())
.build());
}
};
}
}

View File

@@ -0,0 +1,3 @@
# PkceConfig is @Profile("nopkce"): it rebuilds the demo-web registration with
# requireProofKey(false), reproducing what a Spring Security 6.x confidential client
# sends. See docs/authorization-server/08-client.md.

View File

@@ -0,0 +1,27 @@
server:
port: 8080
spring:
application:
name: oidc-client
thymeleaf:
cache: false
security:
oauth2:
client:
# One provider entry. Spring reads
# http://localhost:9000/.well-known/openid-configuration at first use and fills in
# every endpoint from it - authorization, token, jwks, userinfo, issuer.
provider:
demo-provider:
issuer-uri: http://localhost:9000
registration:
demo-web:
provider: demo-provider
client-id: demo-web
client-secret: web-secret
authorization-grant-type: authorization_code
client-authentication-method: client_secret_basic
# Must equal the redirect-uri registered on the server, exactly.
redirect-uri: "http://127.0.0.1:8080/login/oauth2/code/demo-web"
scope: openid,profile,orders.read,orders.write

View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head><meta charset="utf-8"><title>OIDC client</title>
<style>body{font-family:system-ui,sans-serif;max-width:44rem;margin:3rem auto;color:#222}
pre{background:#f4f5f7;padding:1rem;border-radius:6px;overflow:auto}</style></head>
<body>
<h2>Relying party</h2>
<div th:if="${user == null}">
<p>Not signed in.</p>
<p><a href="/orders">Go to /orders</a> &mdash; this triggers the authorization code flow.</p>
</div>
<div th:if="${user != null}">
<p>Signed in as <b th:text="${user.name}">user</b>.</p>
<p><a href="/orders">/orders</a> &middot; <a href="/logout">log out</a></p>
<h3>id_token claims</h3>
<pre th:text="${idTokenClaims}"></pre>
</div>
</body></html>

View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head><meta charset="utf-8"><title>Orders</title>
<style>body{font-family:system-ui,sans-serif;max-width:44rem;margin:3rem auto;color:#222}
pre{background:#f4f5f7;padding:1rem;border-radius:6px;overflow:auto;word-break:break-all;white-space:pre-wrap}</style></head>
<body>
<h2>Resource server response</h2>
<pre th:text="${orders}"></pre>
<h3>Granted scopes</h3>
<pre th:text="${scopes}"></pre>
<h3>Access token (raw)</h3>
<pre th:text="${accessToken}"></pre>
<p><a href="/">back</a></p>
</body></html>