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:
40
authorization-server/oidc-client/pom.xml
Normal file
40
authorization-server/oidc-client/pom.xml
Normal 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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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} — see
|
||||
* {@code docs/output/as-settings-defaults.txt}:
|
||||
*
|
||||
* <table>
|
||||
* <tr><th></th><th>previous</th><th>current</th></tr>
|
||||
* <tr><td>Authorization server — {@code ClientSettings.requireProofKey}</td>
|
||||
* <td>{@code false} (SAS 1.5.8)</td><td>{@code true} (7.1.1)</td></tr>
|
||||
* <tr><td>Client — {@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&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 — 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());
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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> — 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> · <a href="/logout">log out</a></p>
|
||||
<h3>id_token claims</h3>
|
||||
<pre th:text="${idTokenClaims}"></pre>
|
||||
</div>
|
||||
</body></html>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user