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,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());
}
}