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:
39
authorization-server/resource-server/pom.xml
Normal file
39
authorization-server/resource-server/pom.xml
Normal 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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user