spring-projects / spring-authorization-server

Spring Authorization Server
https://spring.io/projects/spring-authorization-server
Apache License 2.0
4.84k stars 1.27k forks source link

SPA requires authentication before authorization, this does not match the spec #1617

Closed xenoterracide closed 4 months ago

xenoterracide commented 4 months ago

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-grant

(1) The client initiates the flow by directing the resource owner's user agent to the authorization endpoint. The client includes its client identifier, code challenge (derived from a generated code verifier), optional requested scope, optional local state, and a redirect URI to which the authorization server will send the user agent back once access is granted (or denied).

However, 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

 +----------+
 | Resource |
 |   Owner  |
 +----------+
       ^
       |
       |
 +-----|----+          Client Identifier      +---------------+
 | .---+---------(1)-- & Redirection URI ---->|               |
 | |   |    |                                 |               |
 | |   '---------(2)-- User authenticates --->|               |
 | | User-  |                                 | Authorization |
 | | Agent  |                                 |     Server    |
 | |        |                                 |               |
 | |    .--------(3)-- Authorization Code ---<|               |
 +-|----|---+                                 +---------------+
   |    |                                         ^      v
   |    |                                         |      |
   ^    v                                         |      |
 +---------+                                      |      |
 |         |>---(4)-- Authorization Code ---------'      |
 |  Client |          & Redirection URI                  |
 |         |                                             |
 |         |<---(5)----- Access Token -------------------'
 +---------+       (w/ Optional Refresh Token)

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.

// © 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();
    }
  }
}
// © Copyright 2024 Caleb Cushing
// SPDX-License-Identifier: AGPL-3.0-or-later

package com.xenoterracide.test.authorization.server;

import com.xenoterracide.tools.java.annotation.ExcludeFromGeneratedCoverageReport;
import java.util.UUID;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
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.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.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer;
import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
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;

/**
 * Test Authorization Server to mimick Auth0.
 */
@SpringBootApplication(proxyBeanMethods = false)
public class AuthorizationServer {

  /**
   * Client ID for the client.
   */
  public static final String CLIENT_ID = "client";
  /**
   * Redirect URI for the client.
   */
  public static final String REDIRECT_URI = "http://localhost:3000";
  private static final String ALL = "*";

  AuthorizationServer() {}

  /**
   * Main.
   *
   * @param args arguments to the program
   */
  @ExcludeFromGeneratedCoverageReport
  public static void main(String[] args) {
    SpringApplication.run(AuthorizationServer.class, args);
  }

  @Bean
  @Order(1)
  SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
    OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
    http.getConfigurer(OAuth2AuthorizationServerConfigurer.class).oidc(Customizer.withDefaults());
    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()));

    return http.cors(Customizer.withDefaults()).build();
  }

  @Bean
  @Order(2)
  SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
    http
      .authorizeHttpRequests(authorize -> authorize.requestMatchers("/oauth/authorize").permitAll())
      .authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated())
      // Form login handles the redirect to the login page from the
      // authorization server filter chain
      .formLogin(Customizer.withDefaults());

    return http.cors(Customizer.withDefaults()).csrf(csrf -> csrf.disable()).build();
  }

  @Bean
  CorsConfigurationSource corsConfigurationSource() {
    var config = new CorsConfiguration();
    config.addAllowedHeader(ALL);
    config.addAllowedMethod(ALL);
    config.addAllowedOrigin(REDIRECT_URI);
    config.setAllowCredentials(true);

    var source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/**", config);
    return source;
  }

  @Bean
  RegisteredClientRepository registeredClientRepository() {
    var publicClient = RegisteredClient.withId(UUID.randomUUID().toString())
      .clientId(CLIENT_ID)
      .clientAuthenticationMethod(ClientAuthenticationMethod.NONE)
      .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
      .redirectUri(REDIRECT_URI)
      .scope(OidcScopes.OPENID)
      .scope(OidcScopes.PROFILE)
      .scope(OidcScopes.EMAIL)
      .clientSettings(ClientSettings.builder().requireAuthorizationConsent(false).requireProofKey(true).build())
      .build();

    return new InMemoryRegisteredClientRepository(publicClient);
  }
}
server.port = 9000
logging.level.root = info
logging.level.com.xenoterracide = debug
logging.level.org.springframework.security = trace
spring.application.name = auth-server
spring.main.banner-mode = off
spring.security.user.name = user
spring.security.user.password = pass
# match auth0
spring.security.oauth2.authorizationserver.endpoint.authorization-uri = /oauth/authorize
spring.security.oauth2.authorizationserver.endpoint.token-uri = /oauth/token
TimothyEarley commented 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.

xenoterracide commented 4 months ago

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();
    }
  }
}
TimothyEarley commented 4 months ago

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

xenoterracide commented 4 months ago

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.

jgrandja commented 4 months ago

@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.

xenoterracide commented 4 months ago

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.