Browse Source

Reactive Resource Server insufficient_scope

This introduces an implementation of ServerAccessDeniedHandler that is
compliant with the OAuth 2.0 spec for insufficent_scope errors.

Fixes: gh-5705
Josh Cummings 7 years ago
parent
commit
8510e9a285

+ 50 - 6
config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java

@@ -65,6 +65,7 @@ import org.springframework.security.oauth2.client.web.server.authentication.OAut
 import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder;
 import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder;
 import org.springframework.security.oauth2.server.resource.authentication.JwtReactiveAuthenticationManager;
+import org.springframework.security.oauth2.server.resource.web.access.server.BearerTokenServerAccessDeniedHandler;
 import org.springframework.security.oauth2.server.resource.web.server.BearerTokenServerAuthenticationEntryPoint;
 import org.springframework.security.oauth2.server.resource.web.server.ServerBearerTokenAuthenticationConverter;
 import org.springframework.security.web.server.DelegatingServerAuthenticationEntryPoint;
@@ -90,6 +91,7 @@ import org.springframework.security.web.server.authorization.AuthorizationWebFil
 import org.springframework.security.web.server.authorization.DelegatingReactiveAuthorizationManager;
 import org.springframework.security.web.server.authorization.ExceptionTranslationWebFilter;
 import org.springframework.security.web.server.authorization.ServerAccessDeniedHandler;
+import org.springframework.security.web.server.authorization.ServerWebExchangeDelegatingServerAccessDeniedHandler;
 import org.springframework.security.web.server.context.NoOpServerSecurityContextRepository;
 import org.springframework.security.web.server.context.ReactorContextWebFilter;
 import org.springframework.security.web.server.context.SecurityContextServerWebExchangeWebFilter;
@@ -230,6 +232,9 @@ public class ServerHttpSecurity {
 
 	private ServerAccessDeniedHandler accessDeniedHandler;
 
+	private List<ServerWebExchangeDelegatingServerAccessDeniedHandler.DelegateEntry>
+			defaultAccessDeniedHandlers = new ArrayList<>();
+
 	private List<WebFilter> webFilters = new ArrayList<>();
 
 	private ApplicationContext context;
@@ -687,6 +692,9 @@ public class ServerHttpSecurity {
 	 * Configures OAuth2 Resource Server Support
 	 */
 	public class OAuth2ResourceServerSpec {
+		private BearerTokenServerAuthenticationEntryPoint entryPoint = new BearerTokenServerAuthenticationEntryPoint();
+		private BearerTokenServerAccessDeniedHandler accessDeniedHandler = new BearerTokenServerAccessDeniedHandler();
+
 		private JwtSpec jwt;
 
 		public JwtSpec jwt() {
@@ -752,9 +760,10 @@ public class ServerHttpSecurity {
 						new ServerBearerTokenAuthenticationConverter();
 				this.bearerTokenServerWebExchangeMatcher.setBearerTokenConverter(bearerTokenConverter);
 
+				registerDefaultAccessDeniedHandler(http);
+				registerDefaultAuthenticationEntryPoint(http);
 				registerDefaultCsrfOverride(http);
 
-				BearerTokenServerAuthenticationEntryPoint entryPoint = new BearerTokenServerAuthenticationEntryPoint();
 				ReactiveJwtDecoder jwtDecoder = getJwtDecoder();
 				JwtReactiveAuthenticationManager authenticationManager = new JwtReactiveAuthenticationManager(
 						jwtDecoder);
@@ -763,9 +772,6 @@ public class ServerHttpSecurity {
 				oauth2.setAuthenticationFailureHandler(new ServerAuthenticationEntryPointFailureHandler(entryPoint));
 
 				http
-					.exceptionHandling()
-						.authenticationEntryPoint(entryPoint)
-						.and()
 					.addFilterAt(oauth2, SecurityWebFiltersOrder.AUTHENTICATION);
 			}
 
@@ -776,6 +782,28 @@ public class ServerHttpSecurity {
 				return this.jwtDecoder;
 			}
 
+			private void registerDefaultAccessDeniedHandler(ServerHttpSecurity http) {
+				if ( http.exceptionHandling != null ) {
+					http.defaultAccessDeniedHandlers.add(
+							new ServerWebExchangeDelegatingServerAccessDeniedHandler.DelegateEntry(
+									this.bearerTokenServerWebExchangeMatcher,
+									new BearerTokenServerAccessDeniedHandler()
+							)
+					);
+				}
+			}
+
+			private void registerDefaultAuthenticationEntryPoint(ServerHttpSecurity http) {
+				if ( http.exceptionHandling != null ) {
+					http.defaultEntryPoints.add(
+							new DelegateEntry(
+									this.bearerTokenServerWebExchangeMatcher,
+									new BearerTokenServerAuthenticationEntryPoint()
+							)
+					);
+				}
+			}
+
 			private void registerDefaultCsrfOverride(ServerHttpSecurity http) {
 				if ( http.csrf != null && !http.csrf.specifiedRequireCsrfProtectionMatcher ) {
 					http
@@ -1033,8 +1061,10 @@ public class ServerHttpSecurity {
 				exceptionTranslationWebFilter.setAuthenticationEntryPoint(
 					authenticationEntryPoint);
 			}
-			if (this.accessDeniedHandler != null) {
-				exceptionTranslationWebFilter.setAccessDeniedHandler(this.accessDeniedHandler);
+			ServerAccessDeniedHandler accessDeniedHandler = getAccessDeniedHandler();
+			if (accessDeniedHandler != null) {
+				exceptionTranslationWebFilter.setAccessDeniedHandler(
+						accessDeniedHandler);
 			}
 			this.addFilterAt(exceptionTranslationWebFilter, SecurityWebFiltersOrder.EXCEPTION_TRANSLATION);
 			this.authorizeExchange.configure(this);
@@ -1077,6 +1107,20 @@ public class ServerHttpSecurity {
 		return result;
 	}
 
+	private ServerAccessDeniedHandler getAccessDeniedHandler() {
+		if (this.accessDeniedHandler != null || this.defaultAccessDeniedHandlers.isEmpty()) {
+			return this.accessDeniedHandler;
+		}
+		if (this.defaultAccessDeniedHandlers.size() == 1) {
+			return this.defaultAccessDeniedHandlers.get(0).getAccessDeniedHandler();
+		}
+		ServerWebExchangeDelegatingServerAccessDeniedHandler result =
+				new ServerWebExchangeDelegatingServerAccessDeniedHandler(this.defaultAccessDeniedHandlers);
+		result.setDefaultAccessDeniedHandler(this.defaultAccessDeniedHandlers
+				.get(this.defaultAccessDeniedHandlers.size() - 1).getAccessDeniedHandler());
+		return result;
+	}
+
 	/**
 	 * Creates a new instance.
 	 * @return the new {@link ServerHttpSecurity} instance

+ 45 - 10
config/src/test/java/org/springframework/security/config/web/server/OAuth2ResourceServerSpecTests.java

@@ -171,6 +171,17 @@ public class OAuth2ResourceServerSpecTests {
 				.expectStatus().isOk();
 	}
 
+	@Test
+	public void getWhenTokenHasInsufficientScopeThenReturnsInsufficientScope() {
+		this.spring.register(DenyAllConfig.class, RootController.class).autowire();
+
+		this.client.get()
+				.headers(headers -> headers.setBearerAuth(this.messageReadToken))
+				.exchange()
+				.expectStatus().isForbidden()
+				.expectHeader().value(HttpHeaders.WWW_AUTHENTICATE, startsWith("Bearer error=\"insufficient_scope\""));
+	}
+
 	@Test
 	public void postWhenMissingTokenThenReturnsForbidden() {
 		this.spring.register(PublicKeyConfig.class, RootController.class).autowire();
@@ -248,21 +259,16 @@ public class OAuth2ResourceServerSpecTests {
 		SecurityWebFilterChain springSecurity(ServerHttpSecurity http) throws Exception {
 			// @formatter:off
 			http
+				.authorizeExchange()
+					.anyExchange().hasAuthority("SCOPE_message:read")
+					.and()
 				.oauth2ResourceServer()
 					.jwt()
-						.publicKey(this.publicKey());
+						.publicKey(publicKey());
 			// @formatter:on
 
-			return http.build();
-		}
 
-		RSAPublicKey publicKey() throws NoSuchAlgorithmException, InvalidKeySpecException {
-			String modulus = "26323220897278656456354815752829448539647589990395639665273015355787577386000316054335559633864476469390247312823732994485311378484154955583861993455004584140858982659817218753831620205191028763754231454775026027780771426040997832758235764611119743390612035457533732596799927628476322029280486807310749948064176545712270582940917249337311592011920620009965129181413510845780806191965771671528886508636605814099711121026468495328702234901200169245493126030184941412539949521815665744267183140084667383643755535107759061065656273783542590997725982989978433493861515415520051342321336460543070448417126615154138673620797";
-			String exponent = "65537";
-
-			RSAPublicKeySpec spec = new RSAPublicKeySpec(new BigInteger(modulus), new BigInteger(exponent));
-			KeyFactory factory = KeyFactory.getInstance("RSA");
-			return (RSAPublicKey) factory.generatePublic(spec);
+			return http.build();
 		}
 	}
 
@@ -318,6 +324,25 @@ public class OAuth2ResourceServerSpecTests {
 		}
 	}
 
+	@EnableWebFlux
+	@EnableWebFluxSecurity
+	static class DenyAllConfig {
+		@Bean
+		SecurityWebFilterChain authorization(ServerHttpSecurity http) throws Exception {
+			// @formatter:off
+			http
+				.authorizeExchange()
+					.anyExchange().denyAll()
+					.and()
+				.oauth2ResourceServer()
+					.jwt()
+						.publicKey(publicKey());
+			// @formatter:on
+
+			return http.build();
+		}
+	}
+
 	@RestController
 	static class RootController {
 		@GetMapping
@@ -331,6 +356,16 @@ public class OAuth2ResourceServerSpecTests {
 		}
 	}
 
+
+	private static RSAPublicKey publicKey() throws NoSuchAlgorithmException, InvalidKeySpecException {
+		String modulus = "26323220897278656456354815752829448539647589990395639665273015355787577386000316054335559633864476469390247312823732994485311378484154955583861993455004584140858982659817218753831620205191028763754231454775026027780771426040997832758235764611119743390612035457533732596799927628476322029280486807310749948064176545712270582940917249337311592011920620009965129181413510845780806191965771671528886508636605814099711121026468495328702234901200169245493126030184941412539949521815665744267183140084667383643755535107759061065656273783542590997725982989978433493861515415520051342321336460543070448417126615154138673620797";
+		String exponent = "65537";
+
+		RSAPublicKeySpec spec = new RSAPublicKeySpec(new BigInteger(modulus), new BigInteger(exponent));
+		KeyFactory factory = KeyFactory.getInstance("RSA");
+		return (RSAPublicKey) factory.generatePublic(spec);
+	}
+
 	private GenericWebApplicationContext autowireWebServerGenericWebApplicationContext() {
 		GenericWebApplicationContext context = new GenericWebApplicationContext();
 		context.registerBean("webHandler", DispatcherHandler.class);

+ 135 - 0
oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/web/access/server/BearerTokenServerAccessDeniedHandler.java

@@ -0,0 +1,135 @@
+/*
+ * Copyright 2002-2018 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.oauth2.server.resource.web.access.server;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import reactor.core.publisher.Mono;
+
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.security.access.AccessDeniedException;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.oauth2.server.resource.BearerTokenErrorCodes;
+import org.springframework.security.oauth2.server.resource.authentication.AbstractOAuth2TokenAuthenticationToken;
+import org.springframework.security.web.server.authorization.ServerAccessDeniedHandler;
+import org.springframework.util.StringUtils;
+import org.springframework.web.server.ServerWebExchange;
+
+/**
+ * Translates any {@link AccessDeniedException} into an HTTP response in accordance with
+ * <a href="https://tools.ietf.org/html/rfc6750#section-3" target="_blank">RFC 6750 Section 3: The WWW-Authenticate</a>.
+ *
+ * So long as the class can prove that the request has a valid OAuth 2.0 {@link Authentication}, then will return an
+ * <a href="https://tools.ietf.org/html/rfc6750#section-3.1" target="_blank">insufficient scope error</a>; otherwise,
+ * it will simply indicate the scheme (Bearer) and any configured realm.
+ *
+ * @author Josh Cummings
+ * @since 5.1
+ *
+ */
+public class BearerTokenServerAccessDeniedHandler implements ServerAccessDeniedHandler {
+	private static final Collection<String> WELL_KNOWN_SCOPE_ATTRIBUTE_NAMES =
+			Arrays.asList("scope", "scp");
+
+	private String realmName;
+
+	@Override
+	public Mono<Void> handle(ServerWebExchange exchange, AccessDeniedException denied) {
+
+		Map<String, String> parameters = new LinkedHashMap<>();
+
+		if (this.realmName != null) {
+			parameters.put("realm", this.realmName);
+		}
+
+		return exchange.getPrincipal()
+				.filter(AbstractOAuth2TokenAuthenticationToken.class::isInstance)
+				.cast(AbstractOAuth2TokenAuthenticationToken.class)
+				.map(token -> errorMessageParameters(token, parameters))
+				.switchIfEmpty(Mono.just(parameters))
+				.flatMap(params -> respond(exchange, params));
+	}
+
+	/**
+	 * Set the default realm name to use in the bearer token error response
+	 *
+	 * @param realmName
+	 */
+	public final void setRealmName(String realmName) {
+		this.realmName = realmName;
+	}
+
+	private static Map<String, String> errorMessageParameters(
+			AbstractOAuth2TokenAuthenticationToken token,
+			Map<String, String> parameters) {
+
+		String scope = getScope(token);
+
+		parameters.put("error", BearerTokenErrorCodes.INSUFFICIENT_SCOPE);
+		parameters.put("error_description",
+				String.format("The token provided has insufficient scope [%s] for this request", scope));
+		parameters.put("error_uri", "https://tools.ietf.org/html/rfc6750#section-3.1");
+
+		if (StringUtils.hasText(scope)) {
+			parameters.put("scope", scope);
+		}
+
+		return parameters;
+	}
+
+	private static Mono<Void> respond(ServerWebExchange exchange, Map<String, String> parameters) {
+		String wwwAuthenticate = computeWWWAuthenticateHeaderValue(parameters);
+		exchange.getResponse().setStatusCode(HttpStatus.FORBIDDEN);
+		exchange.getResponse().getHeaders().set(HttpHeaders.WWW_AUTHENTICATE, wwwAuthenticate);
+		return exchange.getResponse().setComplete();
+	}
+
+	private static String getScope(AbstractOAuth2TokenAuthenticationToken token) {
+
+		Map<String, Object> attributes = token.getTokenAttributes();
+
+		for (String attributeName : WELL_KNOWN_SCOPE_ATTRIBUTE_NAMES) {
+			Object scopes = attributes.get(attributeName);
+			if (scopes instanceof String) {
+				return (String) scopes;
+			} else if (scopes instanceof Collection) {
+				Collection coll = (Collection) scopes;
+				return (String) coll.stream()
+						.map(String::valueOf)
+						.collect(Collectors.joining(" "));
+			}
+		}
+
+		return "";
+	}
+
+	private static String computeWWWAuthenticateHeaderValue(Map<String, String> parameters) {
+		String wwwAuthenticate = "Bearer";
+		if (!parameters.isEmpty()) {
+			wwwAuthenticate += parameters.entrySet().stream()
+					.map(attribute -> attribute.getKey() + "=\"" + attribute.getValue() + "\"")
+					.collect(Collectors.joining(", ", " ", ""));
+		}
+
+		return wwwAuthenticate;
+	}
+}

+ 235 - 0
oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/web/access/server/BearerTokenServerAccessDeniedHandlerTests.java

@@ -0,0 +1,235 @@
+/*
+ * Copyright 2002-2018 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.oauth2.server.resource.web.access.server;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Map;
+
+import org.assertj.core.util.Maps;
+import org.junit.Before;
+import org.junit.Test;
+import reactor.core.publisher.Mono;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.mock.http.server.reactive.MockServerHttpResponse;
+import org.springframework.security.authentication.TestingAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.oauth2.core.AbstractOAuth2Token;
+import org.springframework.security.oauth2.server.resource.authentication.AbstractOAuth2TokenAuthenticationToken;
+import org.springframework.web.server.ServerWebExchange;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class BearerTokenServerAccessDeniedHandlerTests {
+	private BearerTokenServerAccessDeniedHandler accessDeniedHandler;
+
+	@Before
+	public void setUp() {
+		this.accessDeniedHandler = new BearerTokenServerAccessDeniedHandler();
+	}
+
+	@Test
+	public void handleWhenNotOAuth2AuthenticatedThenStatus403() {
+
+		Authentication token = new TestingAuthenticationToken("user", "pass");
+		ServerWebExchange exchange = mock(ServerWebExchange.class);
+		when(exchange.getPrincipal()).thenReturn(Mono.just(token));
+		when(exchange.getResponse()).thenReturn(new MockServerHttpResponse());
+
+		this.accessDeniedHandler.handle(exchange, null).block();
+
+		assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
+		assertThat(exchange.getResponse().getHeaders().get("WWW-Authenticate")).isEqualTo(
+				Arrays.asList("Bearer"));
+	}
+
+	@Test
+	public void handleWhenNotOAuth2AuthenticatedAndRealmSetThenStatus403AndAuthHeaderWithRealm() {
+
+		Authentication token = new TestingAuthenticationToken("user", "pass");
+		ServerWebExchange exchange = mock(ServerWebExchange.class);
+		when(exchange.getPrincipal()).thenReturn(Mono.just(token));
+		when(exchange.getResponse()).thenReturn(new MockServerHttpResponse());
+
+		this.accessDeniedHandler.setRealmName("test");
+		this.accessDeniedHandler.handle(exchange, null).block();
+
+		assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
+		assertThat(exchange.getResponse().getHeaders().get("WWW-Authenticate")).isEqualTo(
+				Arrays.asList("Bearer realm=\"test\""));
+	}
+
+	@Test
+	public void handleWhenTokenHasNoScopesThenInsufficientScopeError() {
+
+		Authentication token = new TestingOAuth2TokenAuthenticationToken(Collections.emptyMap());
+		ServerWebExchange exchange = mock(ServerWebExchange.class);
+		when(exchange.getPrincipal()).thenReturn(Mono.just(token));
+		when(exchange.getResponse()).thenReturn(new MockServerHttpResponse());
+
+		this.accessDeniedHandler.handle(exchange, null).block();
+
+		assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
+		assertThat(exchange.getResponse().getHeaders().get("WWW-Authenticate")).isEqualTo(
+				Arrays.asList("Bearer error=\"insufficient_scope\", " +
+				"error_description=\"The token provided has insufficient scope [] for this request\", " +
+				"error_uri=\"https://tools.ietf.org/html/rfc6750#section-3.1\""));
+	}
+
+
+	@Test
+	public void handleWhenTokenHasScopeAttributeThenInsufficientScopeErrorWithScopes() {
+		Map<String, Object> attributes = Maps.newHashMap("scope", "message:read message:write");
+		Authentication token = new TestingOAuth2TokenAuthenticationToken(attributes);
+		ServerWebExchange exchange = mock(ServerWebExchange.class);
+		when(exchange.getPrincipal()).thenReturn(Mono.just(token));
+		when(exchange.getResponse()).thenReturn(new MockServerHttpResponse());
+
+		this.accessDeniedHandler.handle(exchange, null).block();
+
+		assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
+		assertThat(exchange.getResponse().getHeaders().get("WWW-Authenticate")).isEqualTo(
+				Arrays.asList("Bearer error=\"insufficient_scope\", " +
+				"error_description=\"The token provided has insufficient scope [message:read message:write] for this request\", " +
+				"error_uri=\"https://tools.ietf.org/html/rfc6750#section-3.1\", " +
+				"scope=\"message:read message:write\""));
+	}
+
+	@Test
+	public void handleWhenTokenHasEmptyScopeAttributeThenInsufficientScopeError() {
+		Map<String, Object> attributes = Maps.newHashMap("scope", "");
+		Authentication token = new TestingOAuth2TokenAuthenticationToken(attributes);
+		ServerWebExchange exchange = mock(ServerWebExchange.class);
+		when(exchange.getPrincipal()).thenReturn(Mono.just(token));
+		when(exchange.getResponse()).thenReturn(new MockServerHttpResponse());
+
+		this.accessDeniedHandler.handle(exchange, null).block();
+
+		assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
+		assertThat(exchange.getResponse().getHeaders().get("WWW-Authenticate")).isEqualTo(
+				Arrays.asList("Bearer error=\"insufficient_scope\", " +
+				"error_description=\"The token provided has insufficient scope [] for this request\", " +
+				"error_uri=\"https://tools.ietf.org/html/rfc6750#section-3.1\""));
+	}
+
+	@Test
+	public void handleWhenTokenHasScpAttributeThenInsufficientScopeErrorWithScopes() {
+		Map<String, Object> attributes = Maps.newHashMap("scp", Arrays.asList("message:read", "message:write"));
+		Authentication token = new TestingOAuth2TokenAuthenticationToken(attributes);
+		ServerWebExchange exchange = mock(ServerWebExchange.class);
+		when(exchange.getPrincipal()).thenReturn(Mono.just(token));
+		when(exchange.getResponse()).thenReturn(new MockServerHttpResponse());
+
+		this.accessDeniedHandler.handle(exchange, null).block();
+
+		assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
+		assertThat(exchange.getResponse().getHeaders().get("WWW-Authenticate")).isEqualTo(
+				Arrays.asList("Bearer error=\"insufficient_scope\", " +
+				"error_description=\"The token provided has insufficient scope [message:read message:write] for this request\", " +
+				"error_uri=\"https://tools.ietf.org/html/rfc6750#section-3.1\", " +
+				"scope=\"message:read message:write\""));
+	}
+
+	@Test
+	public void handleWhenTokenHasEmptyScpAttributeThenInsufficientScopeError() {
+
+		Map<String, Object> attributes = Maps.newHashMap("scp", Collections.emptyList());
+		Authentication token = new TestingOAuth2TokenAuthenticationToken(attributes);
+		ServerWebExchange exchange = mock(ServerWebExchange.class);
+		when(exchange.getPrincipal()).thenReturn(Mono.just(token));
+		when(exchange.getResponse()).thenReturn(new MockServerHttpResponse());
+
+		this.accessDeniedHandler.handle(exchange, null).block();
+
+		assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
+		assertThat(exchange.getResponse().getHeaders().get("WWW-Authenticate")).isEqualTo(
+				Arrays.asList("Bearer error=\"insufficient_scope\", " +
+				"error_description=\"The token provided has insufficient scope [] for this request\", " +
+				"error_uri=\"https://tools.ietf.org/html/rfc6750#section-3.1\""));
+	}
+
+	@Test
+	public void handleWhenTokenHasBothScopeAndScpAttributesTheInsufficientErrorBasedOnScopeAttribute() {
+		Map<String, Object> attributes = Maps.newHashMap("scp", Arrays.asList("message:read", "message:write"));
+		attributes.put("scope", "missive:read missive:write");
+		Authentication token = new TestingOAuth2TokenAuthenticationToken(attributes);
+		ServerWebExchange exchange = mock(ServerWebExchange.class);
+		when(exchange.getPrincipal()).thenReturn(Mono.just(token));
+		when(exchange.getResponse()).thenReturn(new MockServerHttpResponse());
+
+		this.accessDeniedHandler.handle(exchange, null).block();
+
+		assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
+		assertThat(exchange.getResponse().getHeaders().get("WWW-Authenticate")).isEqualTo(
+				Arrays.asList("Bearer error=\"insufficient_scope\", " +
+				"error_description=\"The token provided has insufficient scope [missive:read missive:write] for this request\", " +
+				"error_uri=\"https://tools.ietf.org/html/rfc6750#section-3.1\", " +
+				"scope=\"missive:read missive:write\""));
+	}
+
+	@Test
+	public void handleWhenTokenHasScopeAttributeAndRealmIsSetThenInsufficientScopeErrorWithScopesAndRealm() {
+		Map<String, Object> attributes = Maps.newHashMap("scope", "message:read message:write");
+		Authentication token = new TestingOAuth2TokenAuthenticationToken(attributes);
+		ServerWebExchange exchange = mock(ServerWebExchange.class);
+		when(exchange.getPrincipal()).thenReturn(Mono.just(token));
+		when(exchange.getResponse()).thenReturn(new MockServerHttpResponse());
+
+		this.accessDeniedHandler.setRealmName("test");
+		this.accessDeniedHandler.handle(exchange, null).block();
+
+		assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
+		assertThat(exchange.getResponse().getHeaders().get("WWW-Authenticate"))
+				.isEqualTo(Arrays.asList("Bearer realm=\"test\", " +
+				"error=\"insufficient_scope\", " +
+				"error_description=\"The token provided has insufficient scope [message:read message:write] for this request\", " +
+				"error_uri=\"https://tools.ietf.org/html/rfc6750#section-3.1\", " +
+				"scope=\"message:read message:write\""));
+	}
+
+	@Test
+	public void setRealmNameWhenNullRealmNameThenNoExceptionThrown() {
+		assertThatCode(() -> this.accessDeniedHandler.setRealmName(null))
+				.doesNotThrowAnyException();
+	}
+
+	static class TestingOAuth2TokenAuthenticationToken
+			extends AbstractOAuth2TokenAuthenticationToken<TestingOAuth2TokenAuthenticationToken.TestingOAuth2Token> {
+
+		private Map<String, Object> attributes;
+
+		protected TestingOAuth2TokenAuthenticationToken(Map<String, Object> attributes) {
+			super(new TestingOAuth2TokenAuthenticationToken.TestingOAuth2Token("token"));
+			this.attributes = attributes;
+		}
+
+		@Override
+		public Map<String, Object> getTokenAttributes() {
+			return this.attributes;
+		}
+
+		static class TestingOAuth2Token extends AbstractOAuth2Token {
+			public TestingOAuth2Token(String tokenValue) {
+				super(tokenValue);
+			}
+		}
+	}
+}