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,39 @@
<?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>resource-server</artifactId>
<name>resource-server</name>
<description>An API that trusts tokens minted by auth-server</description>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<!-- Same rename as the authorization server starter: the canonical name in Boot 4.1
is spring-boot-starter-security-oauth2-resource-server. The older
spring-boot-starter-oauth2-resource-server is deprecated but still resolves. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-oauth2-resource-server</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,63 @@
package com.ankurm.rs;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/** Four endpoints, each guarded by a different consequence of the token's contents. */
@RestController
public class ApiController {
@GetMapping("/public")
public Map<String, Object> open() {
return Map.of("message", "no token required");
}
/** Requires SCOPE_orders.read. */
@GetMapping("/api/orders")
public Map<String, Object> orders(@AuthenticationPrincipal Jwt jwt) {
Map<String, Object> out = new LinkedHashMap<>();
out.put("orders", List.of(Map.of("id", 1, "total", "42.00")));
out.put("subject", jwt.getSubject());
out.put("clientId", jwt.getClaimAsString("azp") != null
? jwt.getClaimAsString("azp") : jwt.getClaimAsString("client_id"));
out.put("scopes", jwt.getClaimAsStringList("scope"));
out.put("roles", jwt.getClaimAsStringList("roles"));
out.put("tenant", jwt.getClaimAsString("tenant"));
out.put("audience", jwt.getAudience());
return out;
}
/** Requires SCOPE_orders.write. */
@PostMapping("/api/orders")
public Map<String, Object> create(@AuthenticationPrincipal Jwt jwt) {
return Map.of("created", true, "by", jwt.getSubject());
}
/** Requires ROLE_ADMIN, which only exists because of the provider's token customiser. */
@GetMapping("/api/admin")
public Map<String, Object> admin() {
return Map.of(
"message", "admin only",
"authorities", SecurityContextHolder.getContext().getAuthentication()
.getAuthorities().stream().map(Object::toString).toList());
}
/** Whatever the resource server actually decoded. Useful when a 403 makes no sense. */
@GetMapping("/whoami")
public Map<String, Object> whoami(@AuthenticationPrincipal Jwt jwt) {
Map<String, Object> out = new LinkedHashMap<>();
out.put("claims", jwt.getClaims());
out.put("headers", jwt.getHeaders());
out.put("authorities", SecurityContextHolder.getContext().getAuthentication()
.getAuthorities().stream().map(Object::toString).toList());
return out;
}
}

View File

@@ -0,0 +1,17 @@
package com.ankurm.rs;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* The API that trusts what the provider mints. Runs on :8090.
*
* @see <a href="../../../../../../../../docs/authorization-server/06-resource-server.md">
* docs/authorization-server/06-resource-server.md</a>
*/
@SpringBootApplication
public class ResourceServerApplication {
public static void main(String[] args) {
SpringApplication.run(ResourceServerApplication.class, args);
}
}

View File

@@ -0,0 +1,119 @@
package com.ankurm.rs;
import org.springframework.beans.factory.annotation.Value;
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.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtClaimNames;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtDecoders;
import org.springframework.security.oauth2.jwt.JwtValidators;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import org.springframework.security.web.SecurityFilterChain;
import java.util.List;
/**
* What a resource server has to do with the provider's tokens, and what it gets for free.
*
* <p>{@code spring.security.oauth2.resourceserver.jwt.issuer-uri} alone gives you
* signature verification, {@code exp}/{@code nbf}, and an {@code iss} check. It does
* <b>not</b> give you an audience check, and it does not map {@code scope} to anything
* other than {@code SCOPE_*} authorities. Both of those gaps are filled here.
*
* @see <a href="../../../../../../../../docs/authorization-server/06-resource-server.md">
* docs/authorization-server/06-resource-server.md</a>
*/
@Configuration(proxyBeanMethods = false)
public class SecurityConfig {
@Bean
public SecurityFilterChain api(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public").permitAll()
// SCOPE_ is the prefix JwtGrantedAuthoritiesConverter uses for the
// `scope` (or `scp`) claim. ROLE_ comes from our own mapping below.
.requestMatchers("/api/orders").hasAuthority("SCOPE_orders.read")
.requestMatchers("/api/orders/**").hasAuthority("SCOPE_orders.write")
.requestMatchers("/api/admin").hasRole("ADMIN")
.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
// No sessions, no CSRF - the token is the entire request context. Leaving CSRF
// on for a bearer-token API produces a 403 on every POST with no useful body,
// which is the single most-reported false alarm in this space.
.csrf(csrf -> csrf.disable())
.sessionManagement(s -> s.sessionCreationPolicy(
org.springframework.security.config.http.SessionCreationPolicy.STATELESS));
return http.build();
}
/**
* Built explicitly rather than left to auto-configuration, purely so the audience
* validator can be added. {@code JwtValidators.createDefaultWithIssuer} is the same
* stack Boot would have installed: timestamp + issuer.
*/
@Bean
public JwtDecoder jwtDecoder(
@Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}") String issuer,
@Value("${demo.required-audience:orders-api}") String audience,
@Value("${demo.validate-audience:true}") boolean validateAudience) {
NimbusJwtDecoder decoder = (NimbusJwtDecoder) JwtDecoders.fromIssuerLocation(issuer);
OAuth2TokenValidator<Jwt> withIssuer = JwtValidators.createDefaultWithIssuer(issuer);
if (!validateAudience) {
decoder.setJwtValidator(withIssuer);
return decoder;
}
OAuth2TokenValidator<Jwt> audienceValidator = jwt -> {
List<String> aud = jwt.getClaimAsStringList(JwtClaimNames.AUD);
if (aud != null && aud.contains(audience)) {
return OAuth2TokenValidatorResult.success();
}
// The error code matters: `invalid_token` is what puts the reason into the
// WWW-Authenticate header, which is the only place a client will see it.
return OAuth2TokenValidatorResult.failure(new OAuth2Error(
"invalid_token",
"the required audience " + audience + " is missing",
"https://tools.ietf.org/html/rfc6750#section-3.1"));
};
decoder.setJwtValidator(new org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator<>(
withIssuer, audienceValidator));
return decoder;
}
/**
* Maps the custom {@code roles} claim the provider's token customiser writes onto
* {@code ROLE_*} authorities, while keeping the default {@code SCOPE_*} mapping.
* Returning a converter that only handles {@code roles} silently deletes every
* scope authority, which turns {@code hasAuthority("SCOPE_orders.read")} into a 403
* on a perfectly valid token.
*/
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter scopes = new JwtGrantedAuthoritiesConverter();
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
var authorities = new java.util.ArrayList<org.springframework.security.core.GrantedAuthority>(
scopes.convert(jwt));
List<String> roles = jwt.getClaimAsStringList("roles");
if (roles != null) {
roles.forEach(r -> authorities.add(
new org.springframework.security.core.authority.SimpleGrantedAuthority("ROLE_" + r)));
}
return authorities;
});
return converter;
}
}

View File

@@ -0,0 +1,4 @@
# Audience validation off - the default Boot behaviour. A token minted for a different
# API but signed by the same issuer is now accepted here.
demo:
validate-audience: false

View File

@@ -0,0 +1,18 @@
server:
port: 8090
spring:
application:
name: resource-server
security:
oauth2:
resourceserver:
jwt:
# One property. From it Spring fetches /.well-known/openid-configuration at
# startup, reads jwks_uri out of it, and builds a decoder. If the auth server
# is not up when this one starts, startup fails - by design.
issuer-uri: http://localhost:9000
demo:
required-audience: orders-api
validate-audience: true