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,65 @@
<?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>auth-server</artifactId>
<name>auth-server</name>
<description>The OAuth2 / OIDC provider itself</description>
<dependencies>
<!--
Boot 4.1 ships TWO starters that pull exactly the same four dependencies:
spring-boot-starter-oauth2-authorization-server (deprecated)
spring-boot-starter-security-oauth2-authorization-server (this one)
The deprecated one still resolves and still works; its own POM description reads
"deprecated in favor of spring-boot-starter-security-oauth2-authorization-server".
Both drag in spring-boot-starter-webmvc, so you do not need spring-boot-starter-web
as well. See docs/authorization-server/01-versions.md.
-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-oauth2-authorization-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Boot 4 split the test slices out of spring-boot-starter-test; @AutoConfigureMockMvc
lives in spring-boot-starter-webmvc-test now. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-test</artifactId>
<scope>test</scope>
</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,28 @@
package com.ankurm.authserver;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* The provider. Runs on :9000 and is the issuer for everything else in this module.
*
* <p>Start it with {@code ../scripts/run.sh auth} (default profile) or with one of the
* variant profiles that deliberately break something:
*
* <ul>
* <li>{@code noconsent} &mdash; consent turned off for the confidential client</li>
* <li>{@code nopkce} &mdash; the public client no longer requires PKCE</li>
* <li>{@code noclaims} &mdash; the token customiser is not registered</li>
* <li>{@code opaque} &mdash; the service client gets reference tokens, not JWTs</li>
* </ul>
*
* @see <a href="../../../../../../../../docs/authorization-server/02-minimum-provider.md">
* docs/authorization-server/02-minimum-provider.md</a>
*/
@SpringBootApplication
public class AuthServerApplication {
public static void main(String[] args) {
SpringApplication.run(AuthServerApplication.class, args);
}
}

View File

@@ -0,0 +1,206 @@
package com.ankurm.authserver.config;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.MediaType;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
import org.springframework.security.config.annotation.web.configurers.oauth2.server.authorization.OAuth2AuthorizationServerConfigurer;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.server.authorization.InMemoryOAuth2AuthorizationConsentService;
import org.springframework.security.oauth2.server.authorization.InMemoryOAuth2AuthorizationService;
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService;
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.util.Set;
import java.util.UUID;
/**
* The protocol filter chain &mdash; the one that owns {@code /oauth2/**} and the OIDC
* endpoints, and nothing else.
*
* <h2>The two import lines that break every tutorial</h2>
*
* Up to Spring Authorization Server 1.5.x these two classes lived in the
* {@code spring-security-oauth2-authorization-server} jar:
*
* <pre>
* org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration
* org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer
* </pre>
*
* As of 7.0 they live in {@code spring-security-config}, under different packages:
*
* <pre>
* org.springframework.security.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration
* org.springframework.security.config.annotation.web.configurers.oauth2.server.authorization.OAuth2AuthorizationServerConfigurer
* </pre>
*
* And {@code OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http)} &mdash; the
* static method essentially every blog post calls &mdash; no longer exists. It was public
* and static in 1.5.8; in 7.1.1 the class exposes only {@code @Bean} methods. The
* replacement is {@code http.with(new OAuth2AuthorizationServerConfigurer(), ...)}, which
* is what this class does.
*
* @see <a href="../../../../../../../../../docs/authorization-server/01-versions.md">
* docs/authorization-server/01-versions.md</a>
* @see <a href="../../../../../../../../../docs/authorization-server/02-minimum-provider.md">
* docs/authorization-server/02-minimum-provider.md</a>
*/
@Configuration(proxyBeanMethods = false)
public class AuthorizationServerConfig {
/**
* Order matters and the ordering is not cosmetic. This chain carries a
* {@code securityMatcher} restricted to the protocol endpoints, so it must be
* consulted before the catch-all form-login chain in {@link DefaultSecurityConfig}.
* If the two are swapped, the form-login chain matches {@code /oauth2/token} first,
* the token endpoint is never reached, and a token request 302s to {@code /login}.
* That redirect is the fingerprint of a mis-ordered pair of chains.
*/
@Bean
@Order(Ordered.HIGHEST_PRECEDENCE)
public SecurityFilterChain authorizationServerChain(
HttpSecurity http,
@Value("${demo.ignore-accept-all:true}") boolean ignoreAcceptAll) throws Exception {
OAuth2AuthorizationServerConfigurer authorizationServer =
new OAuth2AuthorizationServerConfigurer();
http
.securityMatcher(authorizationServer.getEndpointsMatcher())
.with(authorizationServer, server -> server
// Turning OIDC on is one line, and it is not on by default: without it
// there is no /userinfo, no id_token, and no
// /.well-known/openid-configuration - only the OAuth2 metadata document
// at /.well-known/oauth-authorization-server.
.oidc(Customizer.withDefaults())
// Replace the built-in consent page with our own. The value is a path the
// authorization endpoint redirects the browser to; it is *our* MVC
// controller, protected by the form-login chain, and it must POST back to
// /oauth2/authorize. See ConsentController.
.authorizationEndpoint(endpoint -> endpoint.consentPage("/oauth2/consent"))
)
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
// An unauthenticated GET /oauth2/authorize must send a *browser* to the login
// page, and must not do that to a machine calling /oauth2/token.
//
// Scoping the entry point with MediaTypeRequestMatcher(TEXT_HTML) is the
// documented way to express that, and on its own it does not work. curl,
// most HTTP clients and anything that does not set Accept send `*/*`, and
// `*/*` matches text/html, so the matcher fires and the token endpoint
// answers a failed public-client authentication with `302 -> /login`
// instead of a JSON 401. setIgnoredMediaTypes(ALL) is the line that fixes
// it. Run with the `acceptall` profile to see the unfixed behaviour, and
// compare docs/output/as-entrypoint-accept.txt.
.exceptionHandling(ex -> ex.defaultAuthenticationEntryPointFor(
new LoginUrlAuthenticationEntryPoint("/login"),
htmlOnly(ignoreAcceptAll)))
.oauth2ResourceServer(rs -> rs.jwt(Customizer.withDefaults()));
return http.build();
}
private static MediaTypeRequestMatcher htmlOnly(boolean ignoreAcceptAll) {
MediaTypeRequestMatcher matcher = new MediaTypeRequestMatcher(MediaType.TEXT_HTML);
if (ignoreAcceptAll) {
matcher.setIgnoredMediaTypes(Set.of(MediaType.ALL));
}
return matcher;
}
/**
* These two look optional and are not, the moment you write a custom consent page.
*
* <p>{@code OAuth2AuthorizationServerConfigurer} creates in-memory implementations for
* its own use when you do not supply them, but they are not published as beans you can
* inject. A {@code ConsentController} that constructor-injects
* {@code OAuth2AuthorizationConsentService} therefore fails the context at startup with
* {@code No qualifying bean of type ...OAuth2AuthorizationConsentService available} -
* the exact message is committed in
* {@code docs/output/as-missing-consent-service.txt}. Declaring them here fixes it and,
* more usefully, makes the storage decision explicit: both of these are per-instance
* memory, so a second replica of the authorization server cannot complete a code
* exchange started on the first.
*/
@Bean
public OAuth2AuthorizationService authorizationService() {
return new InMemoryOAuth2AuthorizationService();
}
@Bean
public OAuth2AuthorizationConsentService authorizationConsentService() {
return new InMemoryOAuth2AuthorizationConsentService();
}
/**
* The issuer identifier. Everything downstream keys off this string: it is the
* {@code iss} claim, the base of the discovery document, and the value a resource
* server compares against. {@code http://localhost:9000} works only because both
* sides agree on it exactly &mdash; a trailing slash here and no trailing slash on
* the resource server is a mismatch, and produces
* {@code The iss claim is not valid} at validation time, not at startup.
*/
@Bean
public AuthorizationServerSettings authorizationServerSettings() {
return AuthorizationServerSettings.builder()
.issuer("http://localhost:9000")
.build();
}
/**
* A fresh RSA keypair per boot. That is deliberate for a demo &mdash; restart the
* server and every previously issued token stops verifying, which is exactly the
* behaviour you want to notice now rather than in production. A real deployment
* loads a persistent key (or a rotating set) and serves both the current and the
* previous public key from the JWK Set so in-flight tokens survive rotation; that is
* the subject of docs/15-jwks-caching-and-rotation.md in the resource-server project.
*/
@Bean
public JWKSource<SecurityContext> jwkSource() {
KeyPair keyPair = generateRsaKey();
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
RSAKey rsaKey = new RSAKey.Builder(publicKey)
.privateKey(privateKey)
.keyID(UUID.randomUUID().toString())
.build();
return new ImmutableJWKSet<>(new JWKSet(rsaKey));
}
private static KeyPair generateRsaKey() {
try {
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(2048);
return generator.generateKeyPair();
} catch (Exception ex) {
throw new IllegalStateException("cannot generate an RSA keypair", ex);
}
}
/**
* The authorization server is also a resource server for its own UserInfo endpoint.
* This static helper is one of the few things that survived the 7.0 package move
* unchanged in shape - only its package changed.
*/
@Bean
public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
}
}

View File

@@ -0,0 +1,63 @@
package com.ankurm.authserver.config;
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.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
/**
* The second chain: everything that is not a protocol endpoint. The login form, the
* consent page, static assets, the diagnostics endpoint.
*
* <p>Two chains is the shape, not an optimisation. The protocol chain in
* {@link AuthorizationServerConfig} carries a {@code securityMatcher} and therefore
* declines every request that is not an OAuth2 or OIDC endpoint; something has to pick
* those up, and it needs a completely different authentication mechanism (a browser
* session, not a bearer token).
*
* @see <a href="../../../../../../../../../docs/authorization-server/03-clients-and-pkce.md">
* docs/authorization-server/03-clients-and-pkce.md</a>
*/
@Configuration(proxyBeanMethods = false)
public class DefaultSecurityConfig {
@Bean
public SecurityFilterChain defaultChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/diag/**", "/error", "/favicon.ico").permitAll()
.anyRequest().authenticated())
.formLogin(Customizer.withDefaults());
return http.build();
}
/**
* Two users so the consent screen and the token claims have something to differ on.
* {@code alice} is an admin, {@code bob} is not, and the token customiser copies
* their authorities into a {@code roles} claim the resource server reads.
*
* <p>{@code {noop}} is not a placeholder for a password hash - it is a
* {@code DelegatingPasswordEncoder} prefix meaning "stored in clear". Useful for a
* demo, an incident in production.
*/
@Bean
public UserDetailsService users() {
PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
UserDetails alice = User.withUsername("alice")
.password(encoder.encode("password"))
.roles("USER", "ADMIN")
.build();
UserDetails bob = User.withUsername("bob")
.password(encoder.encode("password"))
.roles("USER")
.build();
return new InMemoryUserDetailsManager(alice, bob);
}
}

View File

@@ -0,0 +1,126 @@
package com.ankurm.authserver.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
import org.springframework.security.oauth2.core.oidc.OidcScopes;
import org.springframework.security.oauth2.server.authorization.client.InMemoryRegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
import org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat;
import org.springframework.security.oauth2.server.authorization.settings.TokenSettings;
import java.time.Duration;
import java.util.UUID;
/**
* Client registration. Three clients, each demonstrating a different half of the
* {@link ClientSettings} / {@link TokenSettings} surface.
*
* <p>A {@code RegisteredClient} is not a user and not a credential - it is a *policy*.
* It says which grants this caller may use, which redirect URIs are acceptable, which
* scopes it may ask for, whether the user has to consent, whether PKCE is mandatory, and
* how long the resulting tokens live. Almost every "it works in Postman but not in the
* browser" report is one of these fields.
*
* @see <a href="../../../../../../../../../docs/authorization-server/03-clients-and-pkce.md">
* docs/authorization-server/03-clients-and-pkce.md</a>
*/
@Configuration(proxyBeanMethods = false)
public class RegisteredClientConfig {
/**
* Set to {@code false} by the {@code noconsent} profile, and to {@code false} for PKCE
* by the {@code nopkce} profile. Both exist so the failure is one flag away and can be
* captured as real output rather than described.
*/
@Bean
public RegisteredClientRepository registeredClientRepository(
@Value("${demo.require-consent:true}") boolean requireConsent,
@Value("${demo.require-pkce:true}") boolean requirePkce,
@Value("${demo.service-token-format:jwt}") String serviceTokenFormat) {
PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
// ---- 1. A confidential web application: authorization_code + refresh_token ----
RegisteredClient webClient = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("demo-web")
// The secret is *hashed*. Registering a bare string here and then sending
// it from the client produces "invalid_client" with no further detail,
// because the server bcrypt-compares the presented secret against what it
// believes is a hash. This is the single most common first-hour failure.
.clientSecret(encoder.encode("web-secret"))
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
// Exact-match, including scheme, port and path. No wildcards, no prefix
// matching. A mismatch is rejected *before* login, with the error rendered
// by the authorization server rather than sent to the client - by design,
// since redirecting to an unvalidated URI is the vulnerability.
.redirectUri("http://127.0.0.1:8080/login/oauth2/code/demo-web")
.postLogoutRedirectUri("http://127.0.0.1:8080/")
.scope(OidcScopes.OPENID)
.scope(OidcScopes.PROFILE)
.scope("orders.read")
.scope("orders.write")
.clientSettings(ClientSettings.builder()
.requireAuthorizationConsent(requireConsent)
.build())
.tokenSettings(TokenSettings.builder()
.accessTokenTimeToLive(Duration.ofMinutes(5))
.refreshTokenTimeToLive(Duration.ofMinutes(60))
// false means every refresh returns a *new* refresh token and
// invalidates the old one. That is rotation, and it is how a
// stolen refresh token becomes detectable.
.reuseRefreshTokens(false)
.build())
.build();
// ---- 2. A public client (SPA / native): no secret, PKCE mandatory ----
RegisteredClient spaClient = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("demo-spa")
.clientAuthenticationMethod(ClientAuthenticationMethod.NONE)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.redirectUri("http://127.0.0.1:8080/authorized")
.scope(OidcScopes.OPENID)
.scope("orders.read")
.clientSettings(ClientSettings.builder()
// With NONE authentication this is the only thing standing between
// an intercepted authorization code and an access token. Spring
// Authorization Server does not infer it: a public client with
// requireProofKey(false) will happily complete a code exchange
// with no verifier at all. The `nopkce` profile shows that.
.requireProofKey(requirePkce)
.requireAuthorizationConsent(requireConsent)
.build())
.build();
// ---- 3. A machine client: client_credentials, no user, no refresh token ----
RegisteredClient serviceClient = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("demo-service")
.clientSecret(encoder.encode("service-secret"))
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
.scope("orders.read")
.tokenSettings(TokenSettings.builder()
.accessTokenTimeToLive(Duration.ofMinutes(10))
// SELF_CONTAINED is a signed JWT the resource server verifies
// offline. REFERENCE is an opaque string that only means anything
// to /oauth2/introspect - which turns every API call into a
// network round trip to the authorization server, and gives you
// instant revocation in exchange. The `opaque` profile flips it.
.accessTokenFormat("reference".equals(serviceTokenFormat)
? OAuth2TokenFormat.REFERENCE
: OAuth2TokenFormat.SELF_CONTAINED)
.build())
.build();
return new InMemoryRegisteredClientRepository(webClient, spaClient, serviceClient);
}
}

View File

@@ -0,0 +1,105 @@
package com.ankurm.authserver.diag;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
import org.springframework.security.web.FilterChainProxy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Runtime state that is otherwise invisible: the resolved endpoint paths, the registered
* clients as the server actually holds them, and the live filter chains in order.
*
* <p>This exists because the interesting configuration in an authorization server is
* spread across three builders and two filter chains, and the effective result is not
* printed anywhere at startup. Reading the beans back is faster than reasoning about them.
*
* <p><b>Delete this before shipping.</b> It exposes client ids, scopes, grant types and
* your chain ordering to anyone who can reach {@code /diag}.
*
* @see <a href="../../../../../../../../../docs/authorization-server/07-diagnostics.md">
* docs/authorization-server/07-diagnostics.md</a>
*/
@RestController
@RequestMapping("/diag")
public class ProviderDiagnostics {
private final RegisteredClientRepository clients;
private final AuthorizationServerSettings settings;
private final FilterChainProxy filterChainProxy;
public ProviderDiagnostics(RegisteredClientRepository clients,
AuthorizationServerSettings settings,
FilterChainProxy filterChainProxy) {
this.clients = clients;
this.settings = settings;
this.filterChainProxy = filterChainProxy;
}
/** Every endpoint path the server resolved, including the ones you did not set. */
@GetMapping("/settings")
public Map<String, Object> settings() {
return new LinkedHashMap<>(this.settings.getSettings());
}
/**
* The registered clients, flattened. Note that the client secret is not returned -
* it is a hash, and printing it invites people to try to use it as a secret.
*/
@GetMapping("/clients")
public List<Map<String, Object>> clients() {
List<Map<String, Object>> out = new ArrayList<>();
for (String id : List.of("demo-web", "demo-spa", "demo-service")) {
RegisteredClient c = this.clients.findByClientId(id);
if (c == null) {
continue;
}
Map<String, Object> m = new LinkedHashMap<>();
m.put("clientId", c.getClientId());
m.put("authenticationMethods", c.getClientAuthenticationMethods().stream()
.map(a -> a.getValue()).toList());
m.put("grantTypes", c.getAuthorizationGrantTypes().stream()
.map(g -> g.getValue()).toList());
m.put("redirectUris", c.getRedirectUris());
m.put("scopes", c.getScopes());
m.put("requireProofKey", c.getClientSettings().isRequireProofKey());
m.put("requireAuthorizationConsent",
c.getClientSettings().isRequireAuthorizationConsent());
m.put("accessTokenFormat",
c.getTokenSettings().getAccessTokenFormat().getValue());
m.put("accessTokenTtlSeconds",
c.getTokenSettings().getAccessTokenTimeToLive().toSeconds());
m.put("reuseRefreshTokens", c.getTokenSettings().isReuseRefreshTokens());
out.add(m);
}
return out;
}
/**
* The filter chains in the order Spring Security will consult them, with the matcher
* each one carries. If the authorization server chain is not first, the token endpoint
* is unreachable - and this is where you see that, rather than inferring it from a 302.
*/
@GetMapping("/chains")
public List<Map<String, Object>> chains() {
List<Map<String, Object>> out = new ArrayList<>();
int i = 0;
for (SecurityFilterChain chain : this.filterChainProxy.getFilterChains()) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("position", i++);
m.put("matcher", String.valueOf(chain));
m.put("filters", chain.getFilters().stream()
.map(f -> f.getClass().getSimpleName()).toList());
out.add(m);
}
return out;
}
}

View File

@@ -0,0 +1,81 @@
package com.ankurm.authserver.token;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.server.authorization.OAuth2TokenType;
import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames;
import org.springframework.security.oauth2.server.authorization.token.JwtEncodingContext;
import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Token customisation &mdash; the hook that decides what a downstream service can
* authorise on without calling back.
*
* <p>One bean of type {@code OAuth2TokenCustomizer<JwtEncodingContext>} is picked up
* automatically by the JWT generator. There is no registration step and no annotation;
* if the bean exists with that exact generic type, it runs. Declare it as
* {@code OAuth2TokenCustomizer<OAuth2TokenClaimsContext>} by mistake &mdash; the type used
* for *opaque* tokens &mdash; and it will be silently ignored, because the JWT generator
* resolves the bean by generic type and simply will not find it. Nothing logs. Your
* claims are just absent.
*
* <p>The {@code noclaims} profile disables this bean, so the difference is a diff of two
* decoded tokens rather than a paragraph.
*
* @see <a href="../../../../../../../../../docs/authorization-server/05-token-customisation.md">
* docs/authorization-server/05-token-customisation.md</a>
*/
@Configuration(proxyBeanMethods = false)
@Profile("!noclaims")
public class TokenClaimsCustomizer {
@Bean
public OAuth2TokenCustomizer<JwtEncodingContext> jwtCustomizer() {
return context -> {
// context.getTokenType() distinguishes the access token, the id_token and the
// refresh token. Writing the same claim into all three is a common accident:
// an id_token is for the client, an access token is for the API, and putting
// authorisation data in the id_token invites the client to make security
// decisions it has no business making.
if (OAuth2TokenType.ACCESS_TOKEN.equals(context.getTokenType())) {
context.getClaims().claim("tenant", "acme");
// client_credentials has no user: getPrincipal() is the client's own
// authentication, and asking it for roles yields nothing useful. Guard on
// the grant type or on whether a user is present, or a machine token
// silently inherits whatever authorities the client authentication carries.
if (context.getPrincipal() != null
&& context.getAuthorizationGrantType() != null
&& !"client_credentials".equals(
context.getAuthorizationGrantType().getValue())) {
Set<String> roles = context.getPrincipal().getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.filter(a -> a.startsWith("ROLE_"))
.map(a -> a.substring("ROLE_".length()))
.collect(Collectors.toCollection(java.util.LinkedHashSet::new));
context.getClaims().claim("roles", roles);
}
// The audience is what stops a token minted for one API being replayed
// against another. Spring Authorization Server does not populate `aud` on
// access tokens by default - there is no per-client audience setting - so
// if your resource servers validate audience (they should), this line or
// something like it is mandatory.
context.getClaims().audience(List.of("orders-api"));
}
if (OidcParameterNames.ID_TOKEN.equals(context.getTokenType().getValue())) {
// Profile-ish claims for the relying party to render. Deliberately not
// roles: see above.
context.getClaims().claim("preferred_username",
context.getPrincipal().getName());
}
};
}
}

View File

@@ -0,0 +1,104 @@
package com.ankurm.authserver.web;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
import org.springframework.security.oauth2.core.oidc.OidcScopes;
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsent;
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.security.Principal;
import java.util.LinkedHashSet;
import java.util.Set;
/**
* The consent page.
*
* <p>Wiring a custom one is deceptively small: {@code .consentPage("/oauth2/consent")} on
* the authorization endpoint, plus a controller that renders a form. What the
* documentation does not spell out is the contract the form has to satisfy, and getting
* any part of it wrong produces a redirect loop rather than an error:
*
* <ul>
* <li>the form POSTs back to <b>{@code /oauth2/authorize}</b>, not to the consent path</li>
* <li>it must echo {@code client_id} and {@code state} exactly as received</li>
* <li>each approved scope goes back as a separate {@code scope} parameter</li>
* <li>CSRF token included &mdash; this is the browser chain, not the protocol chain</li>
* <li>{@code openid} is <b>not</b> shown as a checkbox: it is requested implicitly and
* the server does not require consent for it</li>
* </ul>
*
* <p>Miss the {@code state} parameter and the authorization endpoint cannot correlate the
* approval with the pending request, so it starts a new one &mdash; which redirects to the
* consent page again. The loop looks like a session problem and is not.
*
* @see <a href="../../../../../../../../../docs/authorization-server/04-consent-page.md">
* docs/authorization-server/04-consent-page.md</a>
*/
@Controller
public class ConsentController {
private final RegisteredClientRepository registeredClientRepository;
private final OAuth2AuthorizationConsentService authorizationConsentService;
public ConsentController(RegisteredClientRepository registeredClientRepository,
OAuth2AuthorizationConsentService authorizationConsentService) {
this.registeredClientRepository = registeredClientRepository;
this.authorizationConsentService = authorizationConsentService;
}
@GetMapping("/oauth2/consent")
public String consent(Principal principal, Model model,
@RequestParam(OAuth2ParameterNames.CLIENT_ID) String clientId,
@RequestParam(OAuth2ParameterNames.SCOPE) String scope,
@RequestParam(OAuth2ParameterNames.STATE) String state,
@RequestParam(name = "user_code", required = false) String userCode,
HttpServletRequest request) {
RegisteredClient registeredClient = this.registeredClientRepository.findByClientId(clientId);
if (registeredClient == null) {
throw new IllegalArgumentException("unknown client_id: " + clientId);
}
// Scopes the user has already approved in an earlier authorization are pre-ticked
// and, in the default flow, would not have brought us here at all - the consent
// page only appears for scopes not yet consented to.
OAuth2AuthorizationConsent currentConsent =
this.authorizationConsentService.findById(registeredClient.getId(), principal.getName());
Set<String> alreadyApproved = currentConsent != null
? currentConsent.getScopes()
: Set.of();
Set<String> toApprove = new LinkedHashSet<>();
Set<String> previouslyApproved = new LinkedHashSet<>();
for (String requested : scope.split(" ")) {
// openid is requested implicitly by every OIDC client and the authorization
// endpoint never asks for consent on it. Rendering it as a checkbox is
// harmless but misleading, and unticking it does nothing.
if (OidcScopes.OPENID.equals(requested)) {
continue;
}
if (alreadyApproved.contains(requested)) {
previouslyApproved.add(requested);
} else {
toApprove.add(requested);
}
}
model.addAttribute("clientId", clientId);
model.addAttribute("clientName", registeredClient.getClientName());
model.addAttribute("state", state);
model.addAttribute("scopes", toApprove);
model.addAttribute("previouslyApprovedScopes", previouslyApproved);
model.addAttribute("principalName", principal.getName());
model.addAttribute("userCode", userCode);
model.addAttribute("requestURI", "/oauth2/authorize");
return "consent";
}
}

View File

@@ -0,0 +1,5 @@
# The entry-point matcher is left as MediaTypeRequestMatcher(TEXT_HTML) with no ignored
# types. `Accept: */*` then matches text/html and the token endpoint redirects API callers
# to the HTML login page. See docs/authorization-server/09-entry-point.md.
demo:
ignore-accept-all: false

View File

@@ -0,0 +1,3 @@
# The OAuth2TokenCustomizer bean is not registered (see TokenClaimsCustomizer's @Profile).
# Diff a token from this profile against a default one to see exactly what the default
# access token does and does not carry.

View File

@@ -0,0 +1,4 @@
# Consent off. The authorization endpoint issues the code immediately after login.
# Correct for a first-party client you own; wrong the moment a third party registers.
demo:
require-consent: false

View File

@@ -0,0 +1,4 @@
# The public client no longer requires a code_verifier. The code exchange then succeeds
# with nothing but the authorization code - which is the whole attack PKCE prevents.
demo:
require-pkce: false

View File

@@ -0,0 +1,4 @@
# demo-service gets REFERENCE (opaque) access tokens instead of self-contained JWTs.
# The string is meaningless to a resource server without a call to /oauth2/introspect.
demo:
service-token-format: reference

View File

@@ -0,0 +1,5 @@
logging:
level:
org.springframework.security: TRACE
org.springframework.security.oauth2.server.authorization: TRACE
org.springframework.security.web.FilterChainProxy: DEBUG

View File

@@ -0,0 +1,19 @@
server:
port: 9000
spring:
application:
name: auth-server
thymeleaf:
cache: false
# Flags the RegisteredClientRepository reads. Every variant profile below flips exactly
# one of them, so the resulting failure is attributable to one line.
demo:
require-consent: true
require-pkce: true
service-token-format: jwt
logging:
level:
org.springframework.security: INFO

View File

@@ -0,0 +1,71 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<title>Approve access</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 34rem; margin: 4rem auto; color: #222; }
.card { border: 1px solid #e2e5ea; border-radius: 8px; padding: 1.5rem 1.75rem; }
.scope { display: block; margin: .4rem 0; }
code { background: #f4f5f7; padding: .1rem .3rem; border-radius: 3px; }
button { padding: .5rem 1rem; border-radius: 6px; border: 1px solid #b7bec9; cursor: pointer; }
.primary { background: #2f6fdb; color: #fff; border-color: #2f6fdb; }
.muted { color: #667; font-size: .9rem; }
</style>
</head>
<body>
<div class="card">
<h2>Approve access</h2>
<p>
Signed in as <b th:text="${principalName}">user</b>.
The application <code th:text="${clientId}">client</code> wants to act on your behalf.
</p>
<!--
The form POSTs to /oauth2/authorize, NOT to /oauth2/consent. The authorization
endpoint owns both halves of the exchange; the consent page is only a renderer.
-->
<form method="post" th:action="@{${requestURI}}">
<!-- Echoed back verbatim. Drop `state` and the endpoint cannot correlate this
approval with the pending authorization request, and you get a redirect loop. -->
<input type="hidden" name="client_id" th:value="${clientId}">
<input type="hidden" name="state" th:value="${state}">
<input type="hidden" name="user_code" th:value="${userCode}" th:if="${userCode}">
<p><b>This application will be able to:</b></p>
<label class="scope" th:each="s : ${scopes}">
<!-- One `scope` parameter per approved scope. A single space-joined value is
accepted by the parameter binder and then silently treated as one unknown
scope name, so consent appears to succeed and the token comes back short. -->
<input type="checkbox" name="scope" th:value="${s}" checked>
<code th:text="${s}">scope</code>
</label>
<div th:if="${!previouslyApprovedScopes.isEmpty()}">
<p class="muted">Already approved previously:</p>
<label class="scope" th:each="s : ${previouslyApprovedScopes}">
<input type="checkbox" disabled checked>
<code th:text="${s}">scope</code>
</label>
</div>
<p class="muted">
<code>openid</code> is requested implicitly and is not listed &mdash; the
authorization server never asks for consent on it.
</p>
<p>
<button class="primary" type="submit" id="approve">Approve</button>
</p>
</form>
<form method="post" th:action="@{${requestURI}}">
<input type="hidden" name="client_id" th:value="${clientId}">
<input type="hidden" name="state" th:value="${state}">
<!-- No `scope` parameters at all is how you say "denied". The endpoint then redirects
back to the client with error=access_denied. -->
<button type="submit" id="deny">Deny</button>
</form>
</div>
</body>
</html>

View File

@@ -0,0 +1,102 @@
package com.ankurm.authserver;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
import org.springframework.test.web.servlet.MockMvc;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Contract tests, not happy-path tests. Each one pins a decision that would otherwise be
* silently reversible by a config change.
*/
@SpringBootTest
@AutoConfigureMockMvc
class ProviderContractTests {
@Autowired MockMvc mvc;
@Autowired RegisteredClientRepository clients;
@Test
void discoveryAdvertisesTheConfiguredIssuer() throws Exception {
this.mvc.perform(get("/.well-known/openid-configuration"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.issuer").value("http://localhost:9000"))
// Present only because .oidc(...) was switched on.
.andExpect(jsonPath("$.userinfo_endpoint").exists());
}
@Test
void jwkSetNeverExposesPrivateMaterial() throws Exception {
String body = this.mvc.perform(get("/oauth2/jwks"))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
// "d" is the RSA private exponent. Its presence here would be a full compromise.
assertThat(body).doesNotContain("\"d\"").doesNotContain("\"p\"").doesNotContain("\"q\"");
}
@Test
void thePublicClientRequiresProofKey() {
RegisteredClient spa = this.clients.findByClientId("demo-spa");
assertThat(spa).isNotNull();
assertThat(spa.getClientSettings().isRequireProofKey())
.as("a public client without PKCE can have its authorization code replayed")
.isTrue();
}
@Test
void requireProofKeyIsOnByDefaultInSevenPointOne() {
// demo-service never touches ClientSettings. In Spring Authorization Server 1.5.8
// this was false; in 7.1.1 the default flipped to true, which silently makes PKCE
// mandatory for every client you did not think about. See
// docs/output/as-settings-defaults.txt.
RegisteredClient service = this.clients.findByClientId("demo-service");
assertThat(service.getClientSettings().isRequireProofKey()).isTrue();
}
@Test
void refreshTokensRotate() {
RegisteredClient web = this.clients.findByClientId("demo-web");
assertThat(web.getTokenSettings().isReuseRefreshTokens())
.as("reuse means a stolen refresh token stays valid until it expires")
.isFalse();
}
@Test
void aFailedTokenRequestFromAnApiClientIsNotAnHtmlRedirect() throws Exception {
// Accept: */* is what curl and most HTTP clients send. Without
// setIgnoredMediaTypes(ALL) on the entry-point matcher this is a 302 to /login.
this.mvc.perform(post("/oauth2/token")
.accept(MediaType.ALL)
.param("grant_type", "authorization_code")
.param("code", "bogus")
.param("client_id", "demo-spa"))
.andExpect(status().isUnauthorized());
}
@Test
void aBrowserStillGetsRedirectedToTheLoginPage() throws Exception {
this.mvc.perform(get("/oauth2/authorize")
.accept(MediaType.TEXT_HTML)
.queryParam("response_type", "code")
.queryParam("client_id", "demo-web")
.queryParam("redirect_uri", "http://127.0.0.1:8080/login/oauth2/code/demo-web")
.queryParam("scope", "openid")
// Required even for a confidential client, because requireProofKey
// now defaults to true. Omit it and the authorization endpoint
// redirects to the client with error=invalid_request rather than
// sending the browser to the login page.
.queryParam("code_challenge", "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM")
.queryParam("code_challenge_method", "S256"))
.andExpect(status().is3xxRedirection());
}
}

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>

View File

@@ -0,0 +1,44 @@
<?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>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>authorization-server-demo</artifactId>
<version>1.0.0</version>
<packaging>pom</packaging>
<name>authorization-server-demo</name>
<description>Spring Authorization Server 7.1 on Spring Boot 4.1 - runnable companion for ankurm.com</description>
<!--
Three modules, three JVMs, three ports. Running your own provider means running all
three: nothing about the auth server is observable without a client that drives the
browser redirect and a resource server that accepts (or rejects) what comes out.
auth-server :9000 the provider
resource-server :8090 validates its tokens
oidc-client :8080 the relying party
Every dependency version comes from spring-boot-dependencies 4.1.1. Nothing here
pins a Spring Authorization Server version explicitly, because as of Spring Security
7.0 there is no separate version to pin - see docs/authorization-server/01-versions.md.
-->
<modules>
<module>auth-server</module>
<module>resource-server</module>
<module>oidc-client</module>
</modules>
<properties>
<java.version>25</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
</project>

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

View File

@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# The gap between "the signature is valid" and "this token was meant for me".
set -u
cd "$(dirname "$0")/.."
. scripts/lib.sh
OUT=../docs/output/as-audience.txt
mkdir -p ../docs/output
{
section "A token for a DIFFERENT audience, signed by the SAME issuer"
echo "demo-service's tokens carry aud=[orders-api] thanks to the token customiser."
echo "Here we ask for one and then present it to a resource server configured to"
echo "require a different audience - and to one that does not check at all."
TOKEN=$(curl -s -u demo-service:service-secret -d grant_type=client_credentials \
-d scope=orders.read "$AS/oauth2/token" \
| python3 -c 'import sys,json;print(json.load(sys.stdin).get("access_token",""))')
echo
echo "aud claim in the token:"
jwt_payload "$TOKEN" | grep -A3 '"aud"'
section "Resource server running with demo.validate-audience=false"
echo "This is the Spring Boot default: issuer-uri alone validates signature, exp/nbf"
echo "and iss. Audience is not checked unless you add a validator."
curl -s -o /tmp/b -w 'GET /api/orders -> %{http_code}\n' -H "Authorization: Bearer $TOKEN" "$RS/api/orders"
head -c 300 /tmp/b; echo
section "The same token with a deliberately mangled signature"
BAD="${TOKEN%?}X"
curl -s -D - -o /dev/null -H "Authorization: Bearer $BAD" "$RS/api/orders" \
| sed -n '1p;/^WWW-Authenticate/p'
section "No token at all"
curl -s -D - -o /dev/null "$RS/api/orders" | sed -n '1p;/^WWW-Authenticate/p'
} > "$OUT" 2>&1
sed -i 's/[[:space:]]*$//' "$OUT"
echo "wrote $OUT"

View File

@@ -0,0 +1,256 @@
#!/usr/bin/env bash
# The full authorization code flow with PKCE, driven entirely by curl so that every
# redirect, form and parameter is visible. A browser hides all of this.
#
# ./scripts/authcode-pkce.sh [output-name] [client]
#
# client defaults to demo-spa (public, PKCE required). Pass demo-web for the confidential
# client with a secret.
set -u
cd "$(dirname "$0")/.."
. scripts/lib.sh
NAME="${1:-as-authcode-pkce}"
CLIENT="${2:-demo-spa}"
# NO_CHALLENGE=1 sends an authorization request with no code_challenge at all. That is a
# different thing from sending one and then omitting the verifier: the server only demands
# a verifier if the authorization request carried a challenge, OR if the client is
# registered with requireProofKey(true).
NO_CHALLENGE="${NO_CHALLENGE:-0}"
OUT="../docs/output/${NAME}.txt"
mkdir -p ../docs/output
if [ "$CLIENT" = "demo-web" ]; then
REDIRECT="http://127.0.0.1:8080/login/oauth2/code/demo-web"
SCOPE="openid orders.read orders.write"
else
REDIRECT="http://127.0.0.1:8080/authorized"
SCOPE="openid orders.read"
fi
JAR=$(mktemp)
trap 'rm -f "$JAR" /tmp/as-page.html' EXIT
# --- PKCE parameters. RFC 7636: verifier is 43-128 chars of unreserved characters,
# --- challenge is BASE64URL(SHA256(verifier)) with the padding stripped.
read -r VERIFIER CHALLENGE <<<"$(python3 - <<'PY'
import base64, hashlib, secrets
v = base64.urlsafe_b64encode(secrets.token_bytes(48)).decode().rstrip('=')
c = base64.urlsafe_b64encode(hashlib.sha256(v.encode()).digest()).decode().rstrip('=')
print(v, c)
PY
)"
{
section "PKCE parameters (RFC 7636)"
echo "code_verifier ${VERIFIER} (${#VERIFIER} chars)"
echo "code_challenge ${CHALLENGE}"
echo "code_challenge_method S256"
echo
echo "The verifier never leaves the client until the token request. The challenge is"
echo "all the authorization request carries, and it is a one-way hash of the verifier."
section "1. Log in to the authorization server (browser session)"
# The login page carries a CSRF token; the form chain has CSRF enabled, as it should.
curl -s -c "$JAR" "$AS/login" -o /tmp/as-page.html
CSRF=$(form_value /tmp/as-page.html _csrf)
echo "\$ curl -c jar -d username=alice -d password=password -d _csrf=<token> $AS/login"
curl -s -i -b "$JAR" -c "$JAR" \
-d "username=alice" -d "password=password" -d "_csrf=$CSRF" \
"$AS/login" | sed -n '1p;/^[Ll]ocation:/p'
section "2. GET /oauth2/authorize (client=$CLIENT)"
PKCE_PARAMS="&code_challenge=$CHALLENGE&code_challenge_method=S256"
if [ "$NO_CHALLENGE" = "1" ]; then
PKCE_PARAMS=""
echo "NO_CHALLENGE=1: the authorization request carries no code_challenge."
echo
fi
AUTHZ="$AS/oauth2/authorize?response_type=code&client_id=$CLIENT&redirect_uri=$(python3 -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1],safe=''))" "$REDIRECT")&scope=$(python3 -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1]))" "$SCOPE")&state=xyz123${PKCE_PARAMS}"
echo "\$ curl -b jar '$AUTHZ'"
LOC=$(curl -s -i -b "$JAR" -c "$JAR" "$AUTHZ" | tr -d '\r' | grep -i '^location:' | head -1 | sed 's/^[Ll]ocation: *//')
echo "-> 302 $LOC"
if [ -z "$LOC" ]; then
echo "no redirect - the authorization endpoint rendered a page instead:"
curl -s -b "$JAR" "$AUTHZ" | head -30
exit 1
fi
case "$LOC" in
*/oauth2/consent*)
section "3. The consent page"
echo "The authorization endpoint redirected to OUR page, at the path given to"
echo ".consentPage(\"/oauth2/consent\"). Note the query string it hands over:"
echo "$LOC" | tr '&' '\n' | sed 's/^/ /'
curl -s -b "$JAR" -c "$JAR" "$AS${LOC#*9000}" -o /tmp/as-page.html
echo
echo "Scopes rendered as checkboxes (openid deliberately not among them):"
form_values /tmp/as-page.html scope | sed 's/^/ /'
CSRF=$(form_value /tmp/as-page.html _csrf)
STATE=$(form_value /tmp/as-page.html state)
echo
echo "The hidden state the form must echo back: $STATE"
echo "(this is NOT the client's state=xyz123 - it is the server's own correlation"
echo " handle for the pending authorization request, and sending the client's value"
echo " instead is what produces the consent redirect loop)"
section "4. POST the approval to /oauth2/authorize"
ARGS=(-d "client_id=$CLIENT" -d "state=$STATE" -d "_csrf=$CSRF")
for s in $(form_values /tmp/as-page.html scope); do
ARGS+=(-d "scope=$s")
done
echo "\$ curl -b jar -X POST ${ARGS[*]} $AS/oauth2/authorize"
LOC=$(curl -s -i -b "$JAR" -c "$JAR" "${ARGS[@]}" "$AS/oauth2/authorize" \
| tr -d '\r' | grep -i '^location:' | head -1 | sed 's/^[Ll]ocation: *//')
echo "-> 302 $LOC"
;;
*)
section "3. No consent page"
echo "The authorization endpoint went straight back to the client. Either consent is"
echo "off for this client, or every requested scope was already approved."
;;
esac
CODE=$(echo "$LOC" | sed -n 's/.*[?&]code=\([^&]*\).*/\1/p')
RETURNED_STATE=$(echo "$LOC" | sed -n 's/.*[?&]state=\([^&]*\).*/\1/p')
section "5. The authorization code"
echo "code = $CODE"
echo "state = $RETURNED_STATE (the client's own value, returned untouched - compare it)"
if [ -z "$CODE" ]; then
echo "no code in the redirect. The error was:"
echo "$LOC" | tr '&' '\n' | sed 's/^/ /'
exit 1
fi
section "6a. Exchange the code WITHOUT the verifier"
echo "This is the request an attacker who stole the code can make."
AUTH_ARGS=()
[ "$CLIENT" = "demo-web" ] && AUTH_ARGS=(-u demo-web:web-secret)
NOVERIFIER=$(curl -s -w '\n<<HTTP %{http_code}>>' "${AUTH_ARGS[@]}" \
-d grant_type=authorization_code -d "code=$CODE" \
-d "redirect_uri=$REDIRECT" -d "client_id=$CLIENT" \
"$AS/oauth2/token")
echo "$NOVERIFIER" | sed -n 's/^<<HTTP \(.*\)>>$/HTTP \1/p'
BODY=${NOVERIFIER%%$'\n'<<HTTP*}
if [ -n "$BODY" ]; then
echo "$BODY" | python3 -m json.tool 2>/dev/null || echo "$BODY"
else
echo "(empty response body)"
fi
echo
case "$NOVERIFIER" in
*access_token*)
echo ">>> A TOKEN WAS ISSUED. The code alone was sufficient. This is what"
echo ">>> requireProofKey(false) on a public client means in practice."
;;
*)
echo ">>> Rejected. invalid_grant is deliberately vague: the server will not tell"
echo ">>> a caller whether the code was wrong, expired, already used, or missing a"
echo ">>> verifier, because each of those is information an attacker can use."
;;
esac
echo "Note: this consumed the code. Authorization codes are single-use, so the"
echo "successful exchange below needs a fresh one."
section "6b. A fresh code, exchanged properly"
LOC=$(curl -s -i -b "$JAR" -c "$JAR" "$AUTHZ" | tr -d '\r' | grep -i '^location:' | head -1 | sed 's/^[Ll]ocation: *//')
case "$LOC" in
*/oauth2/consent*)
curl -s -b "$JAR" -c "$JAR" "$AS${LOC#*9000}" -o /tmp/as-page.html
CSRF=$(form_value /tmp/as-page.html _csrf)
STATE=$(form_value /tmp/as-page.html state)
ARGS=(-d "client_id=$CLIENT" -d "state=$STATE" -d "_csrf=$CSRF")
for s in $(form_values /tmp/as-page.html scope); do
ARGS+=(-d "scope=$s")
done
LOC=$(curl -s -i -b "$JAR" -c "$JAR" "${ARGS[@]}" "$AS/oauth2/authorize" \
| tr -d '\r' | grep -i '^location:' | head -1 | sed 's/^[Ll]ocation: *//')
;;
esac
CODE=$(echo "$LOC" | sed -n 's/.*[?&]code=\([^&]*\).*/\1/p')
echo "fresh code = $CODE"
echo
if [ "$NO_CHALLENGE" = "1" ]; then
echo "\$ curl -d grant_type=authorization_code -d code=... $AS/oauth2/token"
echo " (no code_verifier - there was no challenge to verify against)"
else
echo "\$ curl -d grant_type=authorization_code -d code=... -d code_verifier=... $AS/oauth2/token"
fi
VERIFIER_ARG=(-d "code_verifier=$VERIFIER")
[ "$NO_CHALLENGE" = "1" ] && VERIFIER_ARG=()
HTTPCODE=$(curl -s -o /tmp/as-tok.json -w '%{http_code}' "${AUTH_ARGS[@]}" \
-d grant_type=authorization_code -d "code=$CODE" \
-d "redirect_uri=$REDIRECT" -d "client_id=$CLIENT" \
"${VERIFIER_ARG[@]}" \
"$AS/oauth2/token")
RESP=$(cat /tmp/as-tok.json)
echo "HTTP $HTTPCODE"
if [ -s /tmp/as-tok.json ]; then
python3 -m json.tool < /tmp/as-tok.json 2>/dev/null || cat /tmp/as-tok.json
else
echo "(empty response body)"
fi
if [ "$HTTPCODE" != "200" ] && [ "$NO_CHALLENGE" = "1" ]; then
echo
echo ">>> No token, even though the client is registered with requireProofKey(false)"
echo ">>> and the authorization request carried no challenge. The reason is that a"
echo ">>> public client has no other way to authenticate at the token endpoint:"
echo ">>> PublicClientAuthenticationProvider delegates entirely to"
echo ">>> CodeVerifierAuthenticator, and raises invalid_client when there is nothing"
echo ">>> to verify. requireProofKey(false) relaxes the AUTHORIZATION endpoint only."
fi
read_claim() { python3 -c 'import sys,json
try: print(json.load(sys.stdin).get(sys.argv[1],""))
except Exception: print("")' "$1" < /tmp/as-tok.json; }
AT=$(read_claim access_token)
IDT=$(read_claim id_token)
RT=$(read_claim refresh_token)
if [ -n "$AT" ]; then
section "7. The access token"
jwt_header "$AT"
jwt_payload "$AT"
fi
if [ -n "$IDT" ]; then
section "8. The id_token - a different token, for a different audience"
jwt_payload "$IDT"
echo
echo "aud is the CLIENT here, not the API. Sending this to a resource server is the"
echo "classic mix-up: it verifies (same issuer, same key) and then fails the audience"
echo "check, or worse, passes it if nobody checks audience."
fi
if [ -n "$AT" ]; then
section "9. Calling the resource server"
for path in /api/orders /api/admin; do
CODE_HTTP=$(curl -s -o /tmp/rsbody -w '%{http_code}' -H "Authorization: Bearer $AT" "$RS$path")
echo "GET $path -> $CODE_HTTP"
head -c 500 /tmp/rsbody; echo
done
section "10. Sending the id_token instead"
if [ -n "$IDT" ]; then
curl -s -i -H "Authorization: Bearer $IDT" "$RS/api/orders" \
| sed -n '1p;/^WWW-Authenticate/p'
fi
fi
if [ -n "$RT" ]; then
section "11. Refresh, with rotation"
echo "old refresh token: ${RT:0:24}..."
R2=$(curl -s "${AUTH_ARGS[@]}" -d grant_type=refresh_token -d "refresh_token=$RT" \
-d "client_id=$CLIENT" "$AS/oauth2/token")
NEW=$(echo "$R2" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("refresh_token",""))')
echo "new refresh token: ${NEW:0:24}..."
[ "$RT" = "$NEW" ] && echo "SAME - reuseRefreshTokens(true)" || echo "DIFFERENT - reuseRefreshTokens(false), the old one is now dead"
echo
echo "Replaying the old one:"
curl -s "${AUTH_ARGS[@]}" -d grant_type=refresh_token -d "refresh_token=$RT" \
-d "client_id=$CLIENT" "$AS/oauth2/token"
echo
fi
} > "$OUT" 2>&1
sed -i 's/[[:space:]]*$//' "$OUT"
echo "wrote $OUT"; tail -5 "$OUT"

View File

@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# The simplest grant, and what the token customiser does and does not add to it.
set -u
cd "$(dirname "$0")/.."
. scripts/lib.sh
OUT="../docs/output/${1:-as-client-credentials}.txt"
mkdir -p ../docs/output
{
section "POST /oauth2/token grant_type=client_credentials"
echo "\$ curl -su demo-service:service-secret -d grant_type=client_credentials \\"
echo " -d scope=orders.read $AS/oauth2/token"
RESP=$(curl -s -u demo-service:service-secret \
-d grant_type=client_credentials -d scope=orders.read \
"$AS/oauth2/token")
echo "$RESP" | python3 -m json.tool
TOKEN=$(echo "$RESP" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("access_token",""))')
if [ -z "$TOKEN" ]; then echo "no access token - stopping"; exit 1; fi
case "$TOKEN" in
*.*.*)
section "JOSE header"
jwt_header "$TOKEN"
section "Claims"
jwt_payload "$TOKEN"
;;
*)
section "Not a JWT"
echo "The access token is an opaque reference: $TOKEN"
echo "Length ${#TOKEN}. It carries no claims; the resource server must introspect it."
section "POST /oauth2/introspect"
curl -s -u demo-service:service-secret -d "token=$TOKEN" \
"$AS/oauth2/introspect" | python3 -m json.tool
;;
esac
section "Wrong secret"
echo "\$ curl -si -u demo-service:WRONG -d grant_type=client_credentials $AS/oauth2/token"
curl -s -i -u demo-service:WRONG -d grant_type=client_credentials \
"$AS/oauth2/token" | sed -n '1p;/^WWW-Authenticate/p;/^{/p'
section "A grant the client is not registered for"
echo "\$ curl -si -u demo-service:service-secret -d grant_type=authorization_code -d code=x $AS/oauth2/token"
curl -s -i -u demo-service:service-secret -d grant_type=authorization_code -d code=x \
"$AS/oauth2/token" | sed -n '1p;/^{/p'
section "A scope the client is not registered for"
echo "\$ curl -s -u demo-service:service-secret -d grant_type=client_credentials -d scope=orders.write $AS/oauth2/token"
curl -s -u demo-service:service-secret -d grant_type=client_credentials -d scope=orders.write \
"$AS/oauth2/token"
echo
section "Calling the resource server with the token"
for path in /public /api/orders /api/admin; do
CODE=$(curl -s -o /tmp/rsbody -w '%{http_code}' -H "Authorization: Bearer $TOKEN" "$RS$path")
WWW=$(curl -s -D - -o /dev/null -H "Authorization: Bearer $TOKEN" "$RS$path" | grep -i '^WWW-Authenticate' || true)
echo "GET $path -> $CODE"
[ -n "$WWW" ] && echo " $WWW"
head -c 400 /tmp/rsbody; echo
done
} > "$OUT" 2>&1
sed -i 's/[[:space:]]*$//' "$OUT"
echo "wrote $OUT"

View File

@@ -0,0 +1,110 @@
#!/usr/bin/env bash
# Drives the real Spring OAuth2 client through a real browser flow with curl, so the
# behaviour is the client's own and not this script's.
#
# ./scripts/client-flow.sh <output-name> <label>
set -u
cd "$(dirname "$0")/.."
. scripts/lib.sh
CLIENT=http://127.0.0.1:8080
LAST_AUTHZ=""
NAME="${1:-as-client-flow}"
LABEL="${2:-default}"
OUT="../docs/output/${NAME}.txt"
mkdir -p ../docs/output
JAR=$(mktemp); trap 'rm -f "$JAR" /tmp/cf.html' EXIT
follow() { # url -> prints status + location, follows same-host redirects up to 6 hops
local url="$1" hop=0
while [ $hop -lt 8 ]; do
local hdrs status loc
# A browser sends Accept: text/html. curl's default is */*, and with the entry-point
# matcher configured to ignore */* that difference decides whether /oauth2/authorize
# redirects you to the login page or answers 401.
hdrs=$(curl -s -D - -o /tmp/cf.html -b "$JAR" -c "$JAR" -H 'Accept: text/html' "$url" | tr -d '\r')
status=$(echo "$hdrs" | head -1 | awk '{print $2}')
loc=$(echo "$hdrs" | grep -i '^location:' | head -1 | sed 's/^[Ll]ocation: *//')
echo " $status $url"
case "$url" in *"/oauth2/authorize?"*) LAST_AUTHZ="$url" ;; esac
[ -z "$loc" ] && { LAST_URL="$url"; return 0; }
case "$loc" in http*) url="$loc" ;; *) url="$(echo "$url" | grep -oE '^https?://[^/]+')$loc" ;; esac
hop=$((hop+1))
done
LAST_URL="$url"
}
{
section "The relying party drives the flow [$LABEL]"
echo "GET $CLIENT/orders while unauthenticated. Every hop below is a real redirect."
echo
follow "$CLIENT/orders"
echo
echo "The authorization request the client built:"
echo "$LAST_AUTHZ" | tr '&?' '\n\n' | sed 's/^/ /'
case "$LAST_AUTHZ" in
*code_challenge*) echo " >>> code_challenge IS present" ;;
*) echo " >>> NO code_challenge - a client registered with"
echo " >>> requireProofKey(true) will reject this outright" ;;
esac
case "$LAST_URL" in
"$AS/login"*)
echo
echo "Landed on the authorization server's login page. Submitting credentials:"
CSRF=$(form_value /tmp/cf.html _csrf)
HDRS=$(curl -s -D - -o /dev/null -b "$JAR" -c "$JAR" -H 'Accept: text/html' \
-d username=alice -d password=password -d "_csrf=$CSRF" \
"$AS/login" | tr -d '\r')
echo " $(echo "$HDRS" | head -1 | awk '{print $2}') POST $AS/login"
NEXT=$(echo "$HDRS" | grep -i '^location:' | head -1 | sed 's/^[Ll]ocation: *//')
echo
echo "Resuming the authorization request:"
follow "$NEXT"
;;
esac
# The consent page, if we reached it.
if grep -q 'name="scope"' /tmp/cf.html 2>/dev/null; then
echo
echo "Consent page reached. Approving:"
CSRF=$(form_value /tmp/cf.html _csrf)
STATE=$(form_value /tmp/cf.html state)
CID=$(form_value /tmp/cf.html client_id)
ARGS=(-d "client_id=$CID" -d "state=$STATE" -d "_csrf=$CSRF")
for s in $(form_values /tmp/cf.html scope); do ARGS+=(-d "scope=$s"); done
HDRS=$(curl -s -D - -o /dev/null -b "$JAR" -c "$JAR" -H 'Accept: text/html' "${ARGS[@]}" "$AS/oauth2/authorize" | tr -d '\r')
echo " $(echo "$HDRS" | head -1 | awk '{print $2}') POST $AS/oauth2/authorize"
NEXT=$(echo "$HDRS" | grep -i '^location:' | head -1 | sed 's/^[Ll]ocation: *//')
echo
echo "Back to the client with the code:"
follow "$NEXT"
fi
case "$LAST_URL" in
*error*)
echo
echo "The flow ended at the CLIENT's error page, not the provider's. The provider"
echo "rejected the authorization request and redirected the failure back to the"
echo "registered redirect_uri, so nothing in the client's logs names the provider"
echo "as the cause. The reason is only in the query string above."
;;
esac
section "What the client rendered"
if grep -qi 'error' /tmp/cf.html && ! grep -q 'orders' /tmp/cf.html; then
echo "An error page. The provider rejected the authorization request:"
echo "$LAST_URL" | tr '&?' '\n\n' | sed 's/^/ /'
echo
python3 -c "
import html,re,sys
t = re.sub(r'<[^>]+>', ' ', open('/tmp/cf.html', encoding='utf-8', errors='replace').read())
print(' '.join(html.unescape(t).split())[:600])"
else
python3 -c "
import html,re
t = re.sub(r'<[^>]+>', '\n', open('/tmp/cf.html', encoding='utf-8', errors='replace').read())
print('\n'.join(l.strip() for l in html.unescape(t).splitlines() if l.strip())[:1400])"
fi
} > "$OUT" 2>&1
sed -i 's/[[:space:]]*$//' "$OUT"
echo "wrote $OUT"

View File

@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Compiles the SAS 1.x-style configuration against the 7.1.1 classpath and records the
# compiler's own words. The point is that the error text is what you will actually see,
# not a paraphrase of it.
set -u
cd "$(dirname "$0")/.."
OUT=../docs/output/as-legacy-compile-failure.txt
mkdir -p ../docs/output
mvn -B -q -pl auth-server dependency:build-classpath \
-Dmdep.outputFile=/tmp/as-cp.txt -DincludeScope=compile >/dev/null 2>&1
CP=$(cat /tmp/as-cp.txt)
WORK=$(mktemp -d)
mkdir -p "$WORK/com/ankurm/authserver/legacy" "$WORK/out"
cp src-broken/LegacySasConfig.java.txt "$WORK/com/ankurm/authserver/legacy/LegacySasConfig.java"
javac -nowarn -d "$WORK/out" -cp "$CP" \
"$WORK/com/ankurm/authserver/legacy/LegacySasConfig.java" > "$WORK/err.txt" 2>&1
STATUS=$?
{
echo "# The SAS 1.x configuration, compiled against Spring Boot 4.1.1 / Spring Security 7.1.1."
echo "# Source: src-broken/LegacySasConfig.java.txt"
echo
echo "\$ javac -cp <spring-boot-4.1.1 classpath> LegacySasConfig.java"
echo
sed "s|$WORK|.|g" "$WORK/err.txt"
echo
echo "javac exit status: $STATUS"
} > "$OUT"
rm -rf "$WORK"
cat "$OUT"

View File

@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# What the provider advertises, and the difference between the two metadata documents.
set -u
cd "$(dirname "$0")/.."
. scripts/lib.sh
OUT=../docs/output/as-discovery.txt
mkdir -p ../docs/output
{
section "OpenID Connect discovery: GET /.well-known/openid-configuration"
echo "\$ curl -s $AS/.well-known/openid-configuration"
curl -s "$AS/.well-known/openid-configuration" | python3 -m json.tool
section "OAuth2 metadata: GET /.well-known/oauth-authorization-server"
echo "Present even with .oidc(...) switched off. The OIDC document above is the one"
echo "that additionally advertises userinfo_endpoint and id_token signing algorithms."
echo "\$ curl -s $AS/.well-known/oauth-authorization-server"
curl -s "$AS/.well-known/oauth-authorization-server" | python3 -m json.tool
section "JWK Set: GET /oauth2/jwks"
echo "Public keys only. No 'd' member - if you ever see one here, stop the server."
curl -s "$AS/oauth2/jwks" | python3 -m json.tool
section "Resolved endpoint settings, read back from AuthorizationServerSettings"
curl -s "$AS/diag/settings" | python3 -m json.tool
section "Registered clients, as the server actually holds them"
curl -s "$AS/diag/clients" | python3 -m json.tool
} > "$OUT" 2>&1
sed -i 's/[[:space:]]*$//' "$OUT"
echo "wrote $OUT"; wc -l "$OUT"

View File

@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Why a failed token request sometimes answers 302 -> /login instead of 401.
set -u
cd "$(dirname "$0")/.."
. scripts/lib.sh
OUT=../docs/output/as-entrypoint-accept.txt
LABEL="${1:-}"
mkdir -p ../docs/output
probe() {
local accept="$1" desc="$2"
echo "--- $desc"
echo "\$ curl -H 'Accept: $accept' -d grant_type=authorization_code -d code=bogus \\"
echo " -d client_id=demo-spa $AS/oauth2/token"
curl -s -i -H "Accept: $accept" -d grant_type=authorization_code -d code=bogus \
-d client_id=demo-spa "$AS/oauth2/token" \
| sed -n '1p;/^[Ll]ocation:/p;/^WWW-Authenticate/p'
echo
}
{
section "Public client, failed authentication at the token endpoint [$LABEL]"
echo "A public client authenticates at /oauth2/token by presenting a code_verifier."
echo "With no verifier there is nothing to authenticate with, so the request falls"
echo "through to the AuthenticationEntryPoint - and which entry point runs depends on"
echo "the Accept header."
echo
probe "*/*" "Accept: */* (curl's default, and most HTTP clients')"
probe "application/json" "Accept: application/json"
probe "text/html" "Accept: text/html (a browser)"
section "Confidential client with a wrong secret, for contrast"
echo "This never reaches the entry point: OAuth2ClientAuthenticationFilter writes the"
echo "error itself, so the Accept header makes no difference."
curl -s -i -u demo-web:wrong -d grant_type=client_credentials "$AS/oauth2/token" \
| sed -n '1p;/^[Ll]ocation:/p'
} >> "$OUT" 2>&1
sed -i 's/[[:space:]]*$//' "$OUT"
echo "appended $LABEL to $OUT"

View File

@@ -0,0 +1,91 @@
#!/usr/bin/env bash
# Shared helpers. Sourced by every demo script.
AS=http://localhost:9000
RS=http://localhost:8090
# Kill by main class, never by a pattern that could match this script's own command line.
# `pkill -f spring-boot` matches the shell running it and takes the shell with it.
kill_app() {
local mainclass="$1" port="${2:-}"
for p in $(ps -eo pid,cmd | grep "[${mainclass:0:1}]${mainclass:1}" | awk '{print $1}'); do
kill -9 "$p" 2>/dev/null || true
done
# Then wait for the port to actually close. `ss -lptn` often reports the socket with no
# PID, so a port-based kill can silently do nothing while the old process keeps serving -
# which looks exactly like your config change having had no effect. Waiting for the
# listener to disappear is the only reliable signal that the restart is real.
if [ -n "$port" ]; then
for _ in $(seq 1 30); do
curl -s -o /dev/null --max-time 1 "http://localhost:$port/" || return 0
sleep 1
done
echo "WARNING: something is still listening on :$port after kill_app $mainclass" >&2
return 1
fi
sleep 1
}
wait_for() {
local url="$1" tries="${2:-90}"
for _ in $(seq 1 "$tries"); do
if curl -s -o /dev/null --max-time 2 "$url"; then return 0; fi
sleep 1
done
echo "timed out waiting for $url - check the app log" >&2
return 1
}
hr() { printf '%s\n' "------------------------------------------------------------------"; }
section() { echo; hr; echo "== $*"; hr; }
# Decode a JWS payload without verifying it. Debug only - never do this to decide anything.
jwt_payload() {
python3 - "$1" <<'PY'
import base64, json, sys
part = sys.argv[1].split('.')[1]
part += '=' * (-len(part) % 4)
print(json.dumps(json.loads(base64.urlsafe_b64decode(part)), indent=2, sort_keys=True))
PY
}
jwt_header() {
python3 - "$1" <<'PY'
import base64, json, sys
part = sys.argv[1].split('.')[0]
part += '=' * (-len(part) % 4)
print(json.dumps(json.loads(base64.urlsafe_b64decode(part)), indent=2, sort_keys=True))
PY
}
# Pull a hidden input's value out of a page. Attribute order is not fixed - Spring
# Security's default login page renders name before value, Thymeleaf renders value before
# name - so a naive grep for name="x" value="y" works on one and silently returns empty on
# the other. Empty CSRF token, HTTP 403, and an hour lost.
form_value() {
python3 - "$1" "$2" <<'PY'
import re, sys
html = open(sys.argv[1], encoding='utf-8', errors='replace').read()
want = sys.argv[2]
for tag in re.findall(r'<input\b[^>]*>', html, re.I):
attrs = dict((m.group(1).lower(), m.group(2))
for m in re.finditer(r'([\w:-]+)\s*=\s*"([^"]*)"', tag))
if attrs.get('name') == want:
print(attrs.get('value', ''))
break
PY
}
# Every value of a repeated input (the scope checkboxes on the consent page).
form_values() {
python3 - "$1" "$2" <<'PY'
import re, sys
html = open(sys.argv[1], encoding='utf-8', errors='replace').read()
want = sys.argv[2]
for tag in re.findall(r'<input\b[^>]*>', html, re.I):
attrs = dict((m.group(1).lower(), m.group(2))
for m in re.finditer(r'([\w:-]+)\s*=\s*"([^"]*)"', tag))
if attrs.get('name') == want and 'disabled' not in tag.lower():
print(attrs.get('value', ''))
PY
}

View File

@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Why a confidential Spring OAuth2 client does not send PKCE, straight from the bytecode
# of DefaultOAuth2AuthorizationRequestResolver rather than from documentation.
set -u
cd "$(dirname "$0")/.."
OUT=../docs/output/as-pkce-applier.txt
mkdir -p ../docs/output
mvn -B -q -pl oidc-client dependency:build-classpath \
-Dmdep.outputFile=/tmp/cl-cp.txt -DincludeScope=compile >/dev/null 2>&1
JAR=$(tr ':' '\n' < /tmp/cl-cp.txt | grep 'spring-security-oauth2-client-' | head -1)
WORK=$(mktemp -d); trap 'rm -rf "$WORK"' EXIT
(cd "$WORK" && unzip -o -q "$JAR" 'org/springframework/security/oauth2/client/web/DefaultOAuth2AuthorizationRequestResolver*')
{
echo "# From $(basename "$JAR")"
echo "#"
echo "# The resolver applies its default PKCE customizer only when the registration's"
echo "# client authentication method is NONE - that is, only for public clients."
echo "# A registration that has a client secret gets no code_challenge."
echo
javap -p -c -cp "$WORK" \
org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver \
2>/dev/null \
| grep -E 'ClientAuthenticationMethod.NONE|DEFAULT_PKCE_APPLIER|withPkce' \
| sed 's/^ *//' | head -8
echo
echo "# The fields and the opt-in setter:"
javap -p -cp "$WORK" \
org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver \
2>/dev/null \
| grep -E 'DEFAULT_PKCE_APPLIER|setAuthorizationRequestCustomizer' | sed 's/^ *//'
} > "$OUT" 2>&1
cat "$OUT"

View File

@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# What happens when the resource server starts and the issuer is not reachable.
# Worth capturing because the failure is at STARTUP, not at first request - which means
# a provider outage during a rolling deploy takes your API down with it.
set -u
cd "$(dirname "$0")/.."
. scripts/lib.sh
OUT=../docs/output/as-rs-startup-failure.txt
mkdir -p ../docs/output
kill_app ResourceServerApplication 8090
kill_app AuthServerApplication 9000
sleep 1
mvn -B -o -pl resource-server org.springframework.boot:spring-boot-maven-plugin:run \
> /tmp/rs-fail.log 2>&1 || true
{
echo "# resource-server started with spring.security.oauth2.resourceserver.jwt.issuer-uri"
echo "# pointing at an authorization server that is not running."
echo
grep -E '^Caused by|Unable to resolve the Configuration' /tmp/rs-fail.log \
| sed 's/^Caused by: //' | head -8
} > "$OUT"
cat "$OUT"

View File

@@ -0,0 +1,114 @@
#!/usr/bin/env bash
# Regenerates every file in ../docs/output that belongs to this project.
#
# ./scripts/run-all.sh
#
# Starts and stops the servers itself. Takes a few minutes. The only non-deterministic
# content is timestamps, key ids and token values, which change on every run by design.
set -u
cd "$(dirname "$0")/.."
. scripts/lib.sh
start_auth() {
kill_app AuthServerApplication 9000
local profiles="${1:-}"
local args=(-B -o -pl auth-server org.springframework.boot:spring-boot-maven-plugin:run)
[ -n "$profiles" ] && args+=("-Dspring-boot.run.profiles=$profiles")
setsid nohup mvn "${args[@]}" > /tmp/auth-server.log 2>&1 < /dev/null &
wait_for "$AS/oauth2/jwks" 90
}
start_client() {
kill_app ClientApplication 8080
local profiles="${1:-}"
local args=(-B -o -pl oidc-client org.springframework.boot:spring-boot-maven-plugin:run)
[ -n "$profiles" ] && args+=("-Dspring-boot.run.profiles=$profiles")
setsid nohup mvn "${args[@]}" > /tmp/oidc-client.log 2>&1 < /dev/null &
wait_for "http://127.0.0.1:8080/" 90
}
start_rs() {
kill_app ResourceServerApplication 8090
local profiles="${1:-}"
local args=(-B -o -pl resource-server org.springframework.boot:spring-boot-maven-plugin:run)
[ -n "$profiles" ] && args+=("-Dspring-boot.run.profiles=$profiles")
setsid nohup mvn "${args[@]}" > /tmp/rs.log 2>&1 < /dev/null &
wait_for "$RS/public" 90
}
echo "== ClientSettings / TokenSettings defaults, 1.5.8 vs 7.1.1"
./scripts/settings-defaults.sh > /dev/null
echo "== the SAS 1.x configuration against 7.1.1 (compile only)"
./scripts/compile-legacy.sh > /dev/null
echo "== default profile"
start_auth ""
start_rs ""
./scripts/discovery.sh
./scripts/client-credentials.sh as-client-credentials
./scripts/authcode-pkce.sh as-authcode-pkce demo-spa
./scripts/authcode-pkce.sh as-authcode-web demo-web
echo "== why a confidential Spring client does not send PKCE by default"
./scripts/pkce-applier.sh > /dev/null
echo "== the real Spring OAuth2 client, end to end"
start_client ""
./scripts/client-flow.sh as-client-flow "client sends PKCE"
# Restart the authorization server too, so the consent already granted above does not
# short-circuit the second run.
start_auth ""
start_client "nopkce"
./scripts/client-flow.sh as-client-flow-nopkce "confidential client, no PKCE - the Boot default"
kill_app ClientApplication 8080
echo "== noclaims: the token customiser removed"
start_auth "noclaims"
./scripts/client-credentials.sh as-client-credentials-noclaims
./scripts/authcode-pkce.sh as-authcode-noclaims demo-spa
echo "== nopkce: the public client no longer requires a verifier"
start_auth "nopkce"
# With a challenge present, the server still demands the verifier - requireProofKey only
# controls whether a challenge is MANDATORY, not whether one that was sent is honoured.
./scripts/authcode-pkce.sh as-authcode-nopkce demo-spa
# Without any challenge at all, the code alone is enough. This is the actual exposure.
NO_CHALLENGE=1 ./scripts/authcode-pkce.sh as-authcode-nochallenge demo-spa
echo "== the same request against a client that DOES require PKCE"
start_auth ""
NO_CHALLENGE=1 ./scripts/authcode-pkce.sh as-authcode-pkce-enforced demo-spa
echo "== noconsent: consent turned off"
start_auth "noconsent"
./scripts/authcode-pkce.sh as-authcode-noconsent demo-spa
echo "== entry point and the Accept header"
rm -f ../docs/output/as-entrypoint-accept.txt
start_auth "acceptall"
./scripts/entrypoint-accept.sh "acceptall profile: setIgnoredMediaTypes NOT called"
start_auth ""
./scripts/entrypoint-accept.sh "default profile: setIgnoredMediaTypes(ALL) called"
echo "== opaque: reference tokens for the service client"
start_auth "opaque"
./scripts/client-credentials.sh as-client-credentials-opaque
echo "== audience validation off on the resource server"
start_auth ""
start_rs "noaud"
./scripts/audience.sh
echo "== the contract tests"
mvn -B -o -pl auth-server test 2>&1 | grep -E "Tests run:|^\[INFO\] Running" \
> ../docs/output/as-test-run.txt || true
kill_app AuthServerApplication 9000
kill_app ResourceServerApplication 8090
kill_app ClientApplication 8080
echo "== resource server startup with no provider"
./scripts/rs-startup-failure.sh > /dev/null
echo
echo "docs/output:"
ls -1 ../docs/output/as-*.txt

View File

@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# ./scripts/run.sh <auth|rs|client> [profiles]
#
# ./scripts/run.sh auth the provider on :9000
# ./scripts/run.sh auth nopkce the provider with PKCE not required
# ./scripts/run.sh rs the API on :8090
# ./scripts/run.sh client the relying party on :8080
set -eu
cd "$(dirname "$0")/.."
. scripts/lib.sh
case "${1:-}" in
auth) MODULE=auth-server; MAIN=AuthServerApplication; PORT=9000 ;;
rs) MODULE=resource-server; MAIN=ResourceServerApplication; PORT=8090 ;;
client) MODULE=oidc-client; MAIN=ClientApplication; PORT=8080 ;;
*) echo "usage: $0 <auth|rs|client> [profiles]" >&2; exit 2 ;;
esac
PROFILES="${2:-}"
kill_app "$MAIN" "$PORT"
LOG="/tmp/${MODULE}.log"
ARGS=(-B -pl "$MODULE" org.springframework.boot:spring-boot-maven-plugin:run)
[ -n "$PROFILES" ] && ARGS+=("-Dspring-boot.run.profiles=$PROFILES")
# Detached, so the script returns and the demo scripts can drive it.
setsid nohup mvn "${ARGS[@]}" > "$LOG" 2>&1 < /dev/null &
echo "starting $MODULE${PROFILES:+ [$PROFILES]} -> $LOG"
wait_for "http://localhost:$PORT/" 120 && echo "$MODULE up on :$PORT"

View File

@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# Compiles one tiny program twice - once against Spring Authorization Server 1.5.8, once
# against 7.1.1 - and prints the defaults each version hands you. This is how the
# requireProofKey change was found; no documentation was involved.
set -u
cd "$(dirname "$0")/.."
OUT=../docs/output/as-settings-defaults.txt
mkdir -p ../docs/output
WORK=$(mktemp -d); trap 'rm -rf "$WORK"' EXIT
mkdir -p "$WORK/old"
fetch() { # group artifact version
curl -sL -o "$WORK/old/$2-$3.jar" \
"https://repo1.maven.org/maven2/$1/$2/$3/$2-$3.jar"
}
for a in spring-security-oauth2-authorization-server:1.5.8 spring-security-oauth2-core:6.5.1 \
spring-security-oauth2-jose:6.5.1 spring-security-core:6.5.1 \
spring-security-oauth2-client:6.5.1 spring-security-web:6.5.1; do
fetch org/springframework/security "${a%%:*}" "${a##*:}"
done
for a in spring-core:6.2.7 spring-jcl:6.2.7; do
fetch org/springframework "${a%%:*}" "${a##*:}"
done
mvn -B -q -pl auth-server dependency:build-classpath \
-Dmdep.outputFile=/tmp/as-cp.txt -DincludeScope=compile >/dev/null 2>&1
CP_NEW=$(cat /tmp/as-cp.txt)
CP_OLD=$(ls "$WORK"/old/*.jar | tr '\n' ':')
{
echo "# Defaults of ClientSettings.builder().build() and TokenSettings.builder().build(),"
echo "# read out of the jars themselves rather than from documentation."
echo "# Source: tools/SettingsDefaults.java"
echo
echo "=== Spring Authorization Server 1.5.8 (last release of the standalone project) ==="
javac -nowarn -cp "$CP_OLD" -d "$WORK/out-old" tools/SettingsDefaults.java \
&& java -cp "$CP_OLD:$WORK/out-old" SettingsDefaults
echo
echo "=== Spring Authorization Server 7.1.1 (inside Spring Security, Boot 4.1.1 BOM) ==="
javac -nowarn -cp "$CP_NEW" -d "$WORK/out-new" tools/SettingsDefaults.java \
&& java -cp "$CP_NEW:$WORK/out-new" SettingsDefaults
echo
echo "# The same question on the CLIENT side. Source: tools/ClientPkceDefault.java"
mvn -B -q -pl oidc-client dependency:build-classpath \
-Dmdep.outputFile=/tmp/cl-cp.txt -DincludeScope=compile >/dev/null 2>&1
CP_CLIENT=$(cat /tmp/cl-cp.txt)
echo
echo "=== spring-security-oauth2-client 6.5.1 ==="
javac -nowarn -cp "$CP_OLD" -d "$WORK/co" tools/ClientPkceDefault.java \
&& java -cp "$CP_OLD:$WORK/co" ClientPkceDefault
echo
echo "=== spring-security-oauth2-client 7.1.1 (Boot 4.1.1 BOM) ==="
javac -nowarn -cp "$CP_CLIENT" -d "$WORK/cn" tools/ClientPkceDefault.java \
&& java -cp "$CP_CLIENT:$WORK/cn" ClientPkceDefault
echo
echo "# Both sides flipped in the 7.x line. Spring-to-Spring therefore still works;"
echo "# a 7.1 authorization server in front of a 6.x or hand-rolled client does not."
} > "$OUT" 2>&1
cat "$OUT"

View File

@@ -0,0 +1,40 @@
// The configuration every Spring Authorization Server tutorial written before
// September 2025 tells you to write. It compiled against 1.5.8 and does not compile
// against 7.1.1. Kept out of the build on purpose - scripts/compile-legacy.sh runs
// javac against it and captures the real compiler output into
// docs/output/as-legacy-compile-failure.txt.
//
// Three separate breakages in nine lines:
// 1. the *Configuration class moved out of the SAS jar into spring-security-config
// 2. the *Configurer class moved AND changed package shape
// 3. applyDefaultSecurity(HttpSecurity) was deleted outright
package com.ankurm.authserver.legacy;
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class LegacySasConfig {
@Bean
@Order(Ordered.HIGHEST_PRECEDENCE)
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http)
throws Exception {
// The 1.x one-liner.
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
.oidc(Customizer.withDefaults());
return http.build();
}
}

View File

@@ -0,0 +1,15 @@
import org.springframework.security.oauth2.client.registration.ClientRegistration;
/**
* Prints whether a Spring OAuth2 <i>client</i> registration requires PKCE out of the box.
* Run against spring-security-oauth2-client 6.5.1 and 7.1.1, it shows the default flipping
* in the same release that flipped it on the authorization server.
*/
public class ClientPkceDefault {
public static void main(String[] args) {
ClientRegistration.ClientSettings cs =
ClientRegistration.ClientSettings.builder().build();
System.out.println("ClientRegistration.ClientSettings.requireProofKey = "
+ cs.isRequireProofKey());
}
}

View File

@@ -0,0 +1,20 @@
import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
import org.springframework.security.oauth2.server.authorization.settings.TokenSettings;
/**
* Prints the out-of-the-box values of ClientSettings and TokenSettings. Run against two
* different Spring Authorization Server jars, it is the shortest proof that a default
* changed. scripts/settings-defaults.sh does exactly that for 1.5.8 and 7.1.1.
*/
public class SettingsDefaults {
public static void main(String[] a) {
ClientSettings cs = ClientSettings.builder().build();
TokenSettings ts = TokenSettings.builder().build();
System.out.println("requireProofKey = " + cs.isRequireProofKey());
System.out.println("requireAuthorizationConsent= " + cs.isRequireAuthorizationConsent());
System.out.println("accessTokenTimeToLive = " + ts.getAccessTokenTimeToLive());
System.out.println("accessTokenFormat = " + ts.getAccessTokenFormat().getValue());
System.out.println("refreshTokenTimeToLive = " + ts.getRefreshTokenTimeToLive());
System.out.println("reuseRefreshTokens = " + ts.isReuseRefreshTokens());
System.out.println("authorizationCodeTTL = " + ts.getAuthorizationCodeTimeToLive());
}
}