Skip to content

Commit 321080f

Browse files
author
Steve Riesenberg
committed
Add How-to: Authenticate using a Single Page Application with PKCE
Closes gh-539
1 parent 048896e commit 321080f

File tree

8 files changed

+402
-10
lines changed

8 files changed

+402
-10
lines changed
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/*
2+
* Copyright 2020-2023 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package sample.pkce;
17+
18+
import java.util.UUID;
19+
20+
import org.springframework.context.annotation.Bean;
21+
import org.springframework.context.annotation.Configuration;
22+
import org.springframework.security.oauth2.core.AuthorizationGrantType;
23+
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
24+
import org.springframework.security.oauth2.core.oidc.OidcScopes;
25+
import org.springframework.security.oauth2.server.authorization.client.InMemoryRegisteredClientRepository;
26+
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
27+
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
28+
import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
29+
30+
@Configuration
31+
public class ClientConfig {
32+
33+
// tag::client[]
34+
@Bean
35+
public RegisteredClientRepository registeredClientRepository() {
36+
// @formatter:off
37+
RegisteredClient publicClient = RegisteredClient.withId(UUID.randomUUID().toString())
38+
.clientId("public-client")
39+
.clientAuthenticationMethod(ClientAuthenticationMethod.NONE)
40+
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
41+
.redirectUri("http://127.0.0.1:4200")
42+
.scope(OidcScopes.OPENID)
43+
.scope(OidcScopes.PROFILE)
44+
.clientSettings(ClientSettings.builder()
45+
.requireAuthorizationConsent(true)
46+
.requireProofKey(true)
47+
.build()
48+
)
49+
.build();
50+
// @formatter:on
51+
52+
return new InMemoryRegisteredClientRepository(publicClient);
53+
}
54+
// end::client[]
55+
56+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/*
2+
* Copyright 2020-2023 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package sample.pkce;
17+
18+
import org.springframework.context.annotation.Bean;
19+
import org.springframework.context.annotation.Configuration;
20+
import org.springframework.core.annotation.Order;
21+
import org.springframework.http.MediaType;
22+
import org.springframework.security.config.Customizer;
23+
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
24+
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
25+
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
26+
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer;
27+
import org.springframework.security.web.SecurityFilterChain;
28+
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
29+
import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher;
30+
import org.springframework.web.cors.CorsConfiguration;
31+
import org.springframework.web.cors.CorsConfigurationSource;
32+
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
33+
34+
@Configuration
35+
@EnableWebSecurity
36+
public class SecurityConfig {
37+
38+
@Bean
39+
@Order(1)
40+
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http)
41+
throws Exception {
42+
// @fold:on
43+
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
44+
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
45+
.oidc(Customizer.withDefaults()); // Enable OpenID Connect 1.0
46+
// @formatter:off
47+
http
48+
// Redirect to the login page when not authenticated from the
49+
// authorization endpoint
50+
.exceptionHandling((exceptions) -> exceptions
51+
.defaultAuthenticationEntryPointFor(
52+
new LoginUrlAuthenticationEntryPoint("/login"),
53+
new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
54+
)
55+
)
56+
// Accept access tokens for User Info and/or Client Registration
57+
.oauth2ResourceServer((oauth2) -> oauth2.jwt(Customizer.withDefaults()));
58+
// @formatter:on
59+
60+
// @fold:off
61+
return http.cors(Customizer.withDefaults()).build();
62+
}
63+
64+
@Bean
65+
@Order(2)
66+
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http)
67+
throws Exception {
68+
// @fold:on
69+
// @formatter:off
70+
http
71+
.authorizeHttpRequests((authorize) -> authorize
72+
.anyRequest().authenticated()
73+
)
74+
// Form login handles the redirect to the login page from the
75+
// authorization server filter chain
76+
.formLogin(Customizer.withDefaults());
77+
// @formatter:on
78+
79+
// @fold:off
80+
return http.cors(Customizer.withDefaults()).build();
81+
}
82+
83+
@Bean
84+
public CorsConfigurationSource corsConfigurationSource() {
85+
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
86+
CorsConfiguration config = new CorsConfiguration();
87+
config.addAllowedHeader("*");
88+
config.addAllowedMethod("*");
89+
config.addAllowedOrigin("http://127.0.0.1:4200");
90+
config.setAllowCredentials(true);
91+
source.registerCorsConfiguration("/**", config);
92+
return source;
93+
}
94+
95+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
spring:
2+
security:
3+
oauth2:
4+
authorizationserver:
5+
client:
6+
public-client:
7+
registration:
8+
client-id: "public-client"
9+
client-authentication-methods:
10+
- "none"
11+
authorization-grant-types:
12+
- "authorization_code"
13+
redirect-uris:
14+
- "http://127.0.0.1:4200"
15+
scopes:
16+
- "openid"
17+
- "profile"
18+
require-authorization-consent: true
19+
require-proof-key: true

docs/src/docs/asciidoc/examples/src/test/java/sample/AuthorizationCodeGrantFlow.java

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
import org.springframework.security.oauth2.core.AuthorizationGrantType;
3232
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponseType;
3333
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
34+
import org.springframework.security.oauth2.core.endpoint.PkceParameterNames;
3435
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
3536
import org.springframework.test.web.servlet.MockMvc;
3637
import org.springframework.test.web.servlet.MvcResult;
@@ -85,20 +86,36 @@ public void addScope(String scope) {
8586
* @return The state parameter for submitting consent for authorization
8687
*/
8788
public String authorize(RegisteredClient registeredClient) throws Exception {
89+
return authorize(registeredClient, null);
90+
}
91+
92+
/**
93+
* Perform the authorization request and obtain a state parameter.
94+
*
95+
* @param registeredClient The registered client
96+
* @param additionalParameters Additional parameters for the request
97+
* @return The state parameter for submitting consent for authorization
98+
*/
99+
public String authorize(RegisteredClient registeredClient, MultiValueMap<String, String> additionalParameters) throws Exception {
88100
MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
89101
parameters.set(OAuth2ParameterNames.RESPONSE_TYPE, OAuth2AuthorizationResponseType.CODE.getValue());
90102
parameters.set(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId());
91103
parameters.set(OAuth2ParameterNames.REDIRECT_URI, registeredClient.getRedirectUris().iterator().next());
92104
parameters.set(OAuth2ParameterNames.SCOPE,
93105
StringUtils.collectionToDelimitedString(registeredClient.getScopes(), " "));
94106
parameters.set(OAuth2ParameterNames.STATE, "state");
107+
if (additionalParameters != null) {
108+
parameters.addAll(additionalParameters);
109+
}
95110

111+
// @formatter:off
96112
MvcResult mvcResult = this.mockMvc.perform(get("/oauth2/authorize")
97113
.params(parameters)
98114
.with(user(this.username).roles("USER")))
99115
.andExpect(status().isOk())
100116
.andExpect(header().string("content-type", containsString(MediaType.TEXT_HTML_VALUE)))
101117
.andReturn();
118+
// @formatter:on
102119
String responseHtml = mvcResult.getResponse().getContentAsString();
103120
Matcher matcher = HIDDEN_STATE_INPUT_PATTERN.matcher(responseHtml);
104121

@@ -120,14 +137,16 @@ public String submitConsent(RegisteredClient registeredClient, String state) thr
120137
parameters.add(OAuth2ParameterNames.SCOPE, scope);
121138
}
122139

140+
// @formatter:off
123141
MvcResult mvcResult = this.mockMvc.perform(post("/oauth2/authorize")
124142
.params(parameters)
125143
.with(user(this.username).roles("USER")))
126144
.andExpect(status().is3xxRedirection())
127145
.andReturn();
146+
// @formatter:on
128147
String redirectedUrl = mvcResult.getResponse().getRedirectedUrl();
129148
assertThat(redirectedUrl).isNotNull();
130-
assertThat(redirectedUrl).matches("http://127.0.0.1:8080/\\S+\\?code=.{15,}&state=state");
149+
assertThat(redirectedUrl).matches("\\S+\\?code=.{15,}&state=state");
131150

132151
String locationHeader = URLDecoder.decode(redirectedUrl, StandardCharsets.UTF_8.name());
133152
UriComponents uriComponents = UriComponentsBuilder.fromUriString(locationHeader).build();
@@ -143,29 +162,67 @@ public String submitConsent(RegisteredClient registeredClient, String state) thr
143162
* @return The token response
144163
*/
145164
public Map<String, Object> getTokenResponse(RegisteredClient registeredClient, String authorizationCode) throws Exception {
165+
return getTokenResponse(registeredClient, authorizationCode, null);
166+
}
167+
168+
/**
169+
* Exchange an authorization code for an access token.
170+
*
171+
* @param registeredClient The registered client
172+
* @param authorizationCode The authorization code obtained from the authorization request
173+
* @param additionalParameters Additional parameters for the request
174+
* @return The token response
175+
*/
176+
public Map<String, Object> getTokenResponse(RegisteredClient registeredClient, String authorizationCode, MultiValueMap<String, String> additionalParameters) throws Exception {
146177
MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
178+
parameters.set(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId());
147179
parameters.set(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.AUTHORIZATION_CODE.getValue());
148180
parameters.set(OAuth2ParameterNames.CODE, authorizationCode);
149181
parameters.set(OAuth2ParameterNames.REDIRECT_URI, registeredClient.getRedirectUris().iterator().next());
182+
if (additionalParameters != null) {
183+
parameters.addAll(additionalParameters);
184+
}
150185

151-
HttpHeaders basicAuth = new HttpHeaders();
152-
basicAuth.setBasicAuth(registeredClient.getClientId(), "secret");
186+
boolean publicClient = (registeredClient.getClientSecret() == null);
187+
HttpHeaders headers = new HttpHeaders();
188+
if (!publicClient) {
189+
headers.setBasicAuth(registeredClient.getClientId(),
190+
registeredClient.getClientSecret().replace("{noop}", ""));
191+
}
153192

193+
// @formatter:off
154194
MvcResult mvcResult = this.mockMvc.perform(post("/oauth2/token")
155195
.params(parameters)
156-
.headers(basicAuth))
196+
.headers(headers))
157197
.andExpect(status().isOk())
158198
.andExpect(header().string(HttpHeaders.CONTENT_TYPE, containsString(MediaType.APPLICATION_JSON_VALUE)))
159199
.andExpect(jsonPath("$.access_token").isNotEmpty())
160200
.andExpect(jsonPath("$.token_type").isNotEmpty())
161201
.andExpect(jsonPath("$.expires_in").isNotEmpty())
162-
.andExpect(jsonPath("$.refresh_token").isNotEmpty())
202+
.andExpect(publicClient
203+
? jsonPath("$.refresh_token").doesNotExist()
204+
: jsonPath("$.refresh_token").isNotEmpty()
205+
)
163206
.andExpect(jsonPath("$.scope").isNotEmpty())
164207
.andExpect(jsonPath("$.id_token").isNotEmpty())
165208
.andReturn();
209+
// @formatter:on
166210

167211
ObjectMapper objectMapper = new ObjectMapper();
168212
String responseJson = mvcResult.getResponse().getContentAsString();
169213
return objectMapper.readValue(responseJson, TOKEN_RESPONSE_TYPE_REFERENCE);
170214
}
215+
216+
public static MultiValueMap<String, String> withCodeChallenge() {
217+
MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
218+
parameters.set(PkceParameterNames.CODE_CHALLENGE, "BqZZ8pTVLsiA3t3tDOys2flJTSH7LoL3Pp5ZqM_YOnE");
219+
parameters.set(PkceParameterNames.CODE_CHALLENGE_METHOD, "S256");
220+
return parameters;
221+
}
222+
223+
public static MultiValueMap<String, String> withCodeVerifier() {
224+
MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
225+
parameters.set(PkceParameterNames.CODE_VERIFIER, "yZ6eB-lEB4BBhIzqoDPqXTTATC0Vkgov7qDF8ar2qT4");
226+
return parameters;
227+
}
171228
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/*
2+
* Copyright 2020-2023 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package sample.pkce;
17+
18+
import java.util.Map;
19+
20+
import org.junit.jupiter.api.Test;
21+
import org.junit.jupiter.api.extension.ExtendWith;
22+
import sample.AuthorizationCodeGrantFlow;
23+
import sample.test.SpringTestContext;
24+
import sample.test.SpringTestContextExtension;
25+
26+
import org.springframework.beans.factory.annotation.Autowired;
27+
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
28+
import org.springframework.context.annotation.ComponentScan;
29+
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
30+
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
31+
import org.springframework.security.oauth2.core.oidc.OidcScopes;
32+
import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames;
33+
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
34+
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
35+
import org.springframework.test.web.servlet.MockMvc;
36+
37+
import static org.assertj.core.api.Assertions.assertThat;
38+
import static sample.AuthorizationCodeGrantFlow.withCodeChallenge;
39+
import static sample.AuthorizationCodeGrantFlow.withCodeVerifier;
40+
41+
/**
42+
* @author Steve Riesenberg
43+
*/
44+
@ExtendWith(SpringTestContextExtension.class)
45+
public class PublicClientTests {
46+
public final SpringTestContext spring = new SpringTestContext(this);
47+
48+
@Autowired
49+
private MockMvc mockMvc;
50+
51+
@Autowired
52+
private RegisteredClientRepository registeredClientRepository;
53+
54+
@Test
55+
public void oidcLoginWhenPublicClientThenSuccess() throws Exception {
56+
this.spring.register(AuthorizationServerConfig.class).autowire();
57+
58+
RegisteredClient registeredClient = this.registeredClientRepository.findByClientId("public-client");
59+
assertThat(registeredClient).isNotNull();
60+
61+
AuthorizationCodeGrantFlow authorizationCodeGrantFlow = new AuthorizationCodeGrantFlow(this.mockMvc);
62+
authorizationCodeGrantFlow.setUsername("user");
63+
authorizationCodeGrantFlow.addScope(OidcScopes.OPENID);
64+
authorizationCodeGrantFlow.addScope(OidcScopes.PROFILE);
65+
66+
String state = authorizationCodeGrantFlow.authorize(registeredClient, withCodeChallenge());
67+
assertThat(state).isNotNull();
68+
69+
String authorizationCode = authorizationCodeGrantFlow.submitConsent(registeredClient, state);
70+
assertThat(authorizationCode).isNotNull();
71+
72+
Map<String, Object> tokenResponse = authorizationCodeGrantFlow.getTokenResponse(registeredClient,
73+
authorizationCode, withCodeVerifier());
74+
assertThat(tokenResponse.get(OAuth2ParameterNames.ACCESS_TOKEN)).isNotNull();
75+
// Note: Refresh tokens are not issued to public clients
76+
assertThat(tokenResponse.get(OAuth2ParameterNames.REFRESH_TOKEN)).isNull();
77+
assertThat(tokenResponse.get(OidcParameterNames.ID_TOKEN)).isNotNull();
78+
}
79+
80+
@EnableWebSecurity
81+
@EnableAutoConfiguration
82+
@ComponentScan
83+
static class AuthorizationServerConfig {
84+
85+
}
86+
87+
}

0 commit comments

Comments
 (0)