8 changed files with 402 additions and 10 deletions
@ -0,0 +1,56 @@
@@ -0,0 +1,56 @@
|
||||
/* |
||||
* Copyright 2020-2023 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 |
||||
* |
||||
* https://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 sample.pkce; |
||||
|
||||
import java.util.UUID; |
||||
|
||||
import org.springframework.context.annotation.Bean; |
||||
import org.springframework.context.annotation.Configuration; |
||||
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; |
||||
|
||||
@Configuration |
||||
public class ClientConfig { |
||||
|
||||
// tag::client[]
|
||||
@Bean |
||||
public RegisteredClientRepository registeredClientRepository() { |
||||
// @formatter:off
|
||||
RegisteredClient publicClient = RegisteredClient.withId(UUID.randomUUID().toString()) |
||||
.clientId("public-client") |
||||
.clientAuthenticationMethod(ClientAuthenticationMethod.NONE) |
||||
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) |
||||
.redirectUri("http://127.0.0.1:4200") |
||||
.scope(OidcScopes.OPENID) |
||||
.scope(OidcScopes.PROFILE) |
||||
.clientSettings(ClientSettings.builder() |
||||
.requireAuthorizationConsent(true) |
||||
.requireProofKey(true) |
||||
.build() |
||||
) |
||||
.build(); |
||||
// @formatter:on
|
||||
|
||||
return new InMemoryRegisteredClientRepository(publicClient); |
||||
} |
||||
// end::client[]
|
||||
|
||||
} |
||||
@ -0,0 +1,95 @@
@@ -0,0 +1,95 @@
|
||||
/* |
||||
* Copyright 2020-2023 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 |
||||
* |
||||
* https://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 sample.pkce; |
||||
|
||||
import org.springframework.context.annotation.Bean; |
||||
import org.springframework.context.annotation.Configuration; |
||||
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.EnableWebSecurity; |
||||
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration; |
||||
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer; |
||||
import org.springframework.security.web.SecurityFilterChain; |
||||
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; |
||||
import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher; |
||||
import org.springframework.web.cors.CorsConfiguration; |
||||
import org.springframework.web.cors.CorsConfigurationSource; |
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource; |
||||
|
||||
@Configuration |
||||
@EnableWebSecurity |
||||
public class SecurityConfig { |
||||
|
||||
@Bean |
||||
@Order(1) |
||||
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) |
||||
throws Exception { |
||||
// @fold:on
|
||||
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http); |
||||
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class) |
||||
.oidc(Customizer.withDefaults()); // Enable OpenID Connect 1.0
|
||||
// @formatter:off
|
||||
http |
||||
// Redirect to the login page when not authenticated from the
|
||||
// authorization endpoint
|
||||
.exceptionHandling((exceptions) -> exceptions |
||||
.defaultAuthenticationEntryPointFor( |
||||
new LoginUrlAuthenticationEntryPoint("/login"), |
||||
new MediaTypeRequestMatcher(MediaType.TEXT_HTML) |
||||
) |
||||
) |
||||
// Accept access tokens for User Info and/or Client Registration
|
||||
.oauth2ResourceServer((oauth2) -> oauth2.jwt(Customizer.withDefaults())); |
||||
// @formatter:on
|
||||
|
||||
// @fold:off
|
||||
return http.cors(Customizer.withDefaults()).build(); |
||||
} |
||||
|
||||
@Bean |
||||
@Order(2) |
||||
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) |
||||
throws Exception { |
||||
// @fold:on
|
||||
// @formatter:off
|
||||
http |
||||
.authorizeHttpRequests((authorize) -> authorize |
||||
.anyRequest().authenticated() |
||||
) |
||||
// Form login handles the redirect to the login page from the
|
||||
// authorization server filter chain
|
||||
.formLogin(Customizer.withDefaults()); |
||||
// @formatter:on
|
||||
|
||||
// @fold:off
|
||||
return http.cors(Customizer.withDefaults()).build(); |
||||
} |
||||
|
||||
@Bean |
||||
public CorsConfigurationSource corsConfigurationSource() { |
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); |
||||
CorsConfiguration config = new CorsConfiguration(); |
||||
config.addAllowedHeader("*"); |
||||
config.addAllowedMethod("*"); |
||||
config.addAllowedOrigin("http://127.0.0.1:4200"); |
||||
config.setAllowCredentials(true); |
||||
source.registerCorsConfiguration("/**", config); |
||||
return source; |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
|
||||
spring: |
||||
security: |
||||
oauth2: |
||||
authorizationserver: |
||||
client: |
||||
public-client: |
||||
registration: |
||||
client-id: "public-client" |
||||
client-authentication-methods: |
||||
- "none" |
||||
authorization-grant-types: |
||||
- "authorization_code" |
||||
redirect-uris: |
||||
- "http://127.0.0.1:4200" |
||||
scopes: |
||||
- "openid" |
||||
- "profile" |
||||
require-authorization-consent: true |
||||
require-proof-key: true |
||||
@ -0,0 +1,87 @@
@@ -0,0 +1,87 @@
|
||||
/* |
||||
* Copyright 2020-2023 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 |
||||
* |
||||
* https://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 sample.pkce; |
||||
|
||||
import java.util.Map; |
||||
|
||||
import org.junit.jupiter.api.Test; |
||||
import org.junit.jupiter.api.extension.ExtendWith; |
||||
import sample.AuthorizationCodeGrantFlow; |
||||
import sample.test.SpringTestContext; |
||||
import sample.test.SpringTestContextExtension; |
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired; |
||||
import org.springframework.boot.autoconfigure.EnableAutoConfiguration; |
||||
import org.springframework.context.annotation.ComponentScan; |
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; |
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; |
||||
import org.springframework.security.oauth2.core.oidc.OidcScopes; |
||||
import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames; |
||||
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 sample.AuthorizationCodeGrantFlow.withCodeChallenge; |
||||
import static sample.AuthorizationCodeGrantFlow.withCodeVerifier; |
||||
|
||||
/** |
||||
* @author Steve Riesenberg |
||||
*/ |
||||
@ExtendWith(SpringTestContextExtension.class) |
||||
public class PublicClientTests { |
||||
public final SpringTestContext spring = new SpringTestContext(this); |
||||
|
||||
@Autowired |
||||
private MockMvc mockMvc; |
||||
|
||||
@Autowired |
||||
private RegisteredClientRepository registeredClientRepository; |
||||
|
||||
@Test |
||||
public void oidcLoginWhenPublicClientThenSuccess() throws Exception { |
||||
this.spring.register(AuthorizationServerConfig.class).autowire(); |
||||
|
||||
RegisteredClient registeredClient = this.registeredClientRepository.findByClientId("public-client"); |
||||
assertThat(registeredClient).isNotNull(); |
||||
|
||||
AuthorizationCodeGrantFlow authorizationCodeGrantFlow = new AuthorizationCodeGrantFlow(this.mockMvc); |
||||
authorizationCodeGrantFlow.setUsername("user"); |
||||
authorizationCodeGrantFlow.addScope(OidcScopes.OPENID); |
||||
authorizationCodeGrantFlow.addScope(OidcScopes.PROFILE); |
||||
|
||||
String state = authorizationCodeGrantFlow.authorize(registeredClient, withCodeChallenge()); |
||||
assertThat(state).isNotNull(); |
||||
|
||||
String authorizationCode = authorizationCodeGrantFlow.submitConsent(registeredClient, state); |
||||
assertThat(authorizationCode).isNotNull(); |
||||
|
||||
Map<String, Object> tokenResponse = authorizationCodeGrantFlow.getTokenResponse(registeredClient, |
||||
authorizationCode, withCodeVerifier()); |
||||
assertThat(tokenResponse.get(OAuth2ParameterNames.ACCESS_TOKEN)).isNotNull(); |
||||
// Note: Refresh tokens are not issued to public clients
|
||||
assertThat(tokenResponse.get(OAuth2ParameterNames.REFRESH_TOKEN)).isNull(); |
||||
assertThat(tokenResponse.get(OidcParameterNames.ID_TOKEN)).isNotNull(); |
||||
} |
||||
|
||||
@EnableWebSecurity |
||||
@EnableAutoConfiguration |
||||
@ComponentScan |
||||
static class AuthorizationServerConfig { |
||||
|
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,77 @@
@@ -0,0 +1,77 @@
|
||||
[[how-to-pkce]] |
||||
= How-to: Authenticate using a Single Page Application with PKCE |
||||
:index-link: ../how-to.html |
||||
:docs-dir: .. |
||||
:examples-dir: {docs-dir}/examples |
||||
|
||||
This guide shows how to configure xref:{docs-dir}/index.adoc#top[Spring Authorization Server] to support a Single Page Application (SPA) with Proof Key for Code Exchange (PKCE). |
||||
The purpose of this guide is to demonstrate how to support a public client and require PKCE for client authentication. |
||||
|
||||
NOTE: Spring Authorization Server will not issue refresh tokens for a public client. We recommend the backend for frontend (BFF) pattern as an alternative to exposing a public client. See https://github.com/spring-projects/spring-authorization-server/issues/297#issue-896744390[gh-297] for more information. |
||||
|
||||
* <<enable-cors>> |
||||
* <<configure-public-client>> |
||||
* <<authenticate-with-client>> |
||||
|
||||
[[enable-cors]] |
||||
== Enable CORS |
||||
|
||||
A SPA consists of static resources that can be deployed in a variety of ways. |
||||
It can be deployed separately from the backend such as with a CDN or separate web server, or it can be deployed along side the backend using Spring Boot. |
||||
|
||||
When a SPA is hosted under a different domain, Cross Origin Resource Sharing (CORS) can be used to allow the application to communicate with the backend. |
||||
|
||||
For example, if you have an Angular dev server running locally on port `4200`, you can define a `CorsConfigurationSource` `@Bean` and configure Spring Security to allow pre-flight requests using the `cors()` DSL as in the following example: |
||||
|
||||
[[enable-cors-configuration]] |
||||
.Enable CORS |
||||
[source,java] |
||||
---- |
||||
include::{examples-dir}/src/main/java/sample/pkce/SecurityConfig.java[] |
||||
---- |
||||
|
||||
TIP: Click on the "Expand folded text" icon in the code sample above to display the full example. |
||||
|
||||
[[configure-public-client]] |
||||
== Configure a Public Client |
||||
|
||||
A SPA cannot securely store credentials and therefore must be treated as a https://datatracker.ietf.org/doc/html/rfc6749#section-2.1[public client^]. |
||||
Public clients should be required to use https://datatracker.ietf.org/doc/html/rfc7636#section-4[Proof Key for Code Exchange] (PKCE). |
||||
|
||||
Continuing the <<enable-cors-configuration,earlier>> example, you can configure Spring Authorization Server to support a public client using the Client Authentication Method `none` and require PKCE as in the following example: |
||||
|
||||
[[configure-public-client-example]] |
||||
.Yaml |
||||
[source,yaml,role="primary"] |
||||
---- |
||||
include::{examples-dir}/src/main/java/sample/pkce/application.yml[] |
||||
---- |
||||
|
||||
.Java |
||||
[source,java,role="secondary"] |
||||
---- |
||||
include::{examples-dir}/src/main/java/sample/pkce/ClientConfig.java[tag=client,indent=0] |
||||
---- |
||||
|
||||
NOTE: The `requireProofKey` setting is helpful in situations where you forget to include the `code_challenge` and `code_challenge_method` query parameters because you will receive an error indicating PKCE is required during the xref:{docs-dir}/protocol-endpoints.adoc#oauth2-authorization-endpoint[Authorization Request] instead of a general client authentication error during the xref:{docs-dir}/protocol-endpoints.adoc#oauth2-token-endpoint[Token Request]. |
||||
|
||||
[[authenticate-with-client]] |
||||
== Authenticate with the Client |
||||
|
||||
Once the server is configured to support a public client, a common question is: _How do I authenticate the client and get an access token?_ |
||||
The short answer is: The same way you would with any other client. |
||||
|
||||
NOTE: A SPA is a browser-based application and therefore uses the same redirection-based flow as any other client. This question is usually related to an expectation that authentication can be performed via a REST API, which is not the case with OAuth2. |
||||
|
||||
A more detailed answer requires an understanding of the flow(s) involved in OAuth2 and OpenID Connect, in this case the Authorization Code flow. |
||||
The steps of the Authorization Code flow are as follows: |
||||
|
||||
1. The client initiates an OAuth2 request via a redirect to the xref:{docs-dir}/protocol-endpoints.adoc#oauth2-authorization-endpoint[Authorization Endpoint]. For a public client, this step includes generating the `code_verifier` and calculating the `code_challenge`, which is then sent as a query parameter. |
||||
2. If the user is not authenticated, the authorization server will redirect to the login page. After authentication, the user is redirected back to the Authorization Endpoint again. |
||||
3. If the user has not consented to the requested scope(s) and consent is required, the consent page is displayed. |
||||
4. Once the user has consented, the authorization server generates an `authorization_code` and redirects back to the client via the `redirect_uri`. |
||||
5. The client obtains the `authorization_code` via a query parameter and performs a request to the xref:{docs-dir}/protocol-endpoints.adoc#oauth2-token-endpoint[Token Endpoint]. For a public client, this step includes sending the `code_verifier` parameter instead of credentials for authentication. |
||||
|
||||
As you can see, the flow is fairly involved and this overview only scratches the surface. |
||||
|
||||
TIP: It is recommended that you use a robust client-side library supported by your single-page app framework to handle the Authorization Code flow. |
||||
Loading…
Reference in new issue