Closed xenoterracide closed 4 months ago
I don't quite follow your complaint. The first step is the client initiating the flow by contacting the authorization endpoint. The server then proceeds to step 2, authenticating the resource owner. This is done by redirecting the client to the login page. Once the resource owner logs in the server resumes the authorization which may include the consent page before going to step 3 and providing the authorization code.
That the first request to the authorization endpoint fails due to being unauthorized is, as far as I can tell, correct.
If you want your test to follow the spec you should handle this redirection accordingly. Or, as you have seen, simply flip the login and authorization to skip the redirection.
Hope this helps.
this test should pass then. It does not. It does pass if I put login first. The redirect is not the only thing needed, a code
must be returned to the client. note: updating the original ticket with an updated server implementation.
AuthorizationServerTest > authn() FAILED
java.lang.AssertionError: [code]
Expecting actual:
{}
to contain key:
"code"
at com.xenoterracide.test.authorization.server.AuthorizationServerTest.authn(AuthorizationServerTest.java:107)
// © Copyright 2024 Caleb Cushing
// SPDX-License-Identifier: AGPL-3.0-or-later
package com.xenoterracide.test.authorization.server;
import static org.assertj.core.api.Assertions.assertThat;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.function.Consumer;
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.ObjectFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Lazy;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse;
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
import org.springframework.security.oauth2.core.endpoint.PkceParameterNames;
import org.springframework.security.oauth2.core.http.converter.OAuth2AccessTokenResponseHttpMessageConverter;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.web.client.RestClient;
import org.springframework.web.util.UriComponentsBuilder;
@ActiveProfiles({ "test", "test-http" })
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class AuthorizationServerTest {
@SuppressWarnings("NullAway")
@Value("${spring.security.user.name}")
String user;
@SuppressWarnings("NullAway")
@Value("${spring.security.user.password}")
String pass;
@SuppressWarnings("NullAway")
@Value("${spring.security.oauth2.authorizationserver.endpoint.authorization-uri}")
String authorizationUriPath;
@SuppressWarnings("NullAway")
@Value("${spring.security.oauth2.authorizationserver.endpoint.token-uri}")
String tokenUriPath;
@Autowired
ObjectFactory<RestClient> oauthTestClient;
SecureRandom random = new SecureRandom();
Base64.Encoder encoder = Base64.getUrlEncoder().withoutPadding();
static byte[] bytesFrom(int size, Consumer<byte[]> setter) {
var bytes = new byte[size];
setter.accept(bytes);
return bytes;
}
private static LinkedMultiValueMap<String, String> getAuthParams(String challenge) {
var authParams = new LinkedMultiValueMap<String, String>();
authParams.add(PkceParameterNames.CODE_CHALLENGE, challenge);
authParams.add(PkceParameterNames.CODE_CHALLENGE_METHOD, "S256");
authParams.add(OAuth2ParameterNames.CLIENT_ID, AuthorizationServer.CLIENT_ID);
authParams.add(OAuth2ParameterNames.REDIRECT_URI, AuthorizationServer.REDIRECT_URI);
authParams.add(OAuth2ParameterNames.RESPONSE_TYPE, "code");
authParams.add(OAuth2ParameterNames.SCOPE, "openid+profile+email");
authParams.add(OAuth2ParameterNames.STATE, "sUmww5GH");
authParams.add("nonce", "FVO5cA3");
authParams.add("audience", "http://localhost");
authParams.add("response_mode", "query");
authParams.add("auth0Client", "eyJuY");
return authParams;
}
@Test
void authn() throws Exception {
var rc = this.oauthTestClient.getObject();
var code = bytesFrom(32, random::nextBytes);
var verifier = encoder.encodeToString(code);
var challenge = encoder.encodeToString(
MessageDigest.getInstance("SHA-256").digest(verifier.getBytes(StandardCharsets.US_ASCII))
);
var authParams = getAuthParams(challenge);
var authorize = rc
.get()
.uri(uriBuilder -> uriBuilder.path(this.authorizationUriPath).queryParams(authParams).build())
.retrieve()
.toEntity(String.class);
assertThat(authorize.getStatusCode()).describedAs("authorize").isEqualTo(HttpStatus.FOUND);
var qp = UriComponentsBuilder.fromUri(authorize.getHeaders().getLocation()).build().getQueryParams();
assertThat(qp).describedAs("code").containsKey("code");
var credentials = new LinkedMultiValueMap<String, String>();
credentials.add("username", this.user);
credentials.add("password", this.pass);
var login = rc
.post()
.uri("/login")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body(credentials)
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError, (req, res) -> {})
.toEntity(String.class);
assertThat(login).describedAs("login").extracting(res -> res.getStatusCode()).isEqualTo(HttpStatus.FOUND);
var params = new LinkedMultiValueMap<String, String>();
params.add(OAuth2ParameterNames.CLIENT_ID, AuthorizationServer.CLIENT_ID);
params.add(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.AUTHORIZATION_CODE.getValue());
params.add(OAuth2ParameterNames.CODE, qp.getFirst(OAuth2ParameterNames.CODE));
params.add(OAuth2ParameterNames.REDIRECT_URI, AuthorizationServer.REDIRECT_URI);
params.add(PkceParameterNames.CODE_VERIFIER, verifier);
var tokenResponse = rc
.post()
.uri(this.tokenUriPath)
.body(params)
.retrieve()
.toEntity(OAuth2AccessTokenResponse.class);
assertThat(tokenResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(tokenResponse.getBody().getAccessToken()).isNotNull();
}
@TestConfiguration
static class TestConfig {
@Bean
@Lazy
RestClient oauthTestClient(@LocalServerPort int port) {
return RestClient.builder()
.requestFactory(
new HttpComponentsClientHttpRequestFactory(HttpClients.custom().disableRedirectHandling().build())
)
.baseUrl("http://localhost:" + port)
.messageConverters(converters -> {
converters.addFirst(new OAuth2AccessTokenResponseHttpMessageConverter());
})
.build();
}
}
}
The first response from /authorize (the redirect to login) does not contain a code. Only once the user is logged can a code be returned. Checking it that early in the test will thus fail. Here where you assert the code the URL will just be a plain URL to /login
I understand now. The spec is confusing, they aren't usually meant to be used by first time consumers.
This is why I want improved documentation that shows a basic expected request/response for this (all) flow. There's only 4 calls here, they are't super complicated but trying to parse a books worth of spec to figure it out is not a great situation.
and generating the code,verifier,challenge isn't super straight forward.
@TimothyEarley Thank you for your responses!
@xenoterracide The authorization_code
grant flow requires the Resource Owner to be authenticated in order to proceed and complete the flow. The current implementation is implemented to spec.
Just as a reminder, questions should be asked on Stack Overflow. We prefer to use GitHub issues only for bugs and enhancements. If you feel there is a bug, please provide a minimal sample that reproduces the issue.
I asked this question on stack overflow and I didn't get a response. Perhaps more time needs to be spent on stack overflow....
I have suggested that spring should consider enabling the discussion segments of GitHub. Stack overflow hasn't been a good place for questions in a long time.
I have also been putting stuff here because I'm trying to convince you to expand the documentation.
From what I've read and understood, the very first step in doing the SPA PKCE worflow is for the client to make a request using their client id to the authorization server
/authorize
endpoint. No authentication should be done yet, and additional credentials would not make sense. https://www.ietf.org/archive/id/draft-ietf-oauth-v2-1-07.html#name-authorization-code-grantHowever, whenever I've tried this it fails with me not being authenticated. According to the code this will always fail. https://github.com/spring-projects/spring-authorization-server/blob/a080cda45ec97bdc0a30a4475f4c84f0476e2a61/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeRequestAuthenticationProvider.java#L170-L172
I believe that this test should pass. If I reverse the calls to be login then authorize I get the consent page which is more or less what I'd expect. Except those calls should be done in the opposite order.