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:
65
authorization-server/auth-server/pom.xml
Normal file
65
authorization-server/auth-server/pom.xml
Normal 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>
|
||||
@@ -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} — consent turned off for the confidential client</li>
|
||||
* <li>{@code nopkce} — the public client no longer requires PKCE</li>
|
||||
* <li>{@code noclaims} — the token customiser is not registered</li>
|
||||
* <li>{@code opaque} — 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);
|
||||
}
|
||||
}
|
||||
@@ -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 — 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)} — the
|
||||
* static method essentially every blog post calls — 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 — 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 — 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 — 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 — the type used
|
||||
* for *opaque* tokens — 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());
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 — 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 — 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";
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
logging:
|
||||
level:
|
||||
org.springframework.security: TRACE
|
||||
org.springframework.security.oauth2.server.authorization: TRACE
|
||||
org.springframework.security.web.FilterChainProxy: DEBUG
|
||||
@@ -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
|
||||
@@ -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 — 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>
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user