OAuth2 Autoconfig（Spring Security OAuth2）をプロキシ環境で使うのが意外と面倒だった件

前回、[Spring Boot 2とOAuth2 Autoconfigで超シンプルにOAuth2クライアントを実現する方法](https://qiita.com/yoshikawaa/items/5bc6cabbc2e5815f011c)を解説しましたが、作成したクライアントアプリをプロキシ環境で利用する場合には`OAuth2RestTemplate`がプロキシを経由するようセットアップする必要があります。

しかし、プロキシを適用する方法が意外と面倒で、まとまっているサイトもなかったので、参考程度にまとめておきます。

## 今回使用するライブラリ

* Spring Boot 2 (Spring & Spring Security 5)
  - spring-boot-starter-web 2.0.1.RELEASE
  - spring-boot-starter-security 2.0.1.RELEASE
* OAuth2 Autoconfig (Spring Security OAuth2)
  - spring-security-oauth2-autoconfigure 2.0.0.RELEASE

※前回記事と同じです。

## `OAuth2RestTemplate`にプロキシを適用する

`OAuth2RestTemplate`は`RestTemplate`を拡張しており、プロキシを適用する方法も同様です。

* 認証なしプロキシを利用する場合

```java
    @Bean
    public ClientHttpRequestFactory requestFactory(
            @Value("${proxy.host}") String host,
            @Value("${proxy.port}") int port) {
        
        SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
        factory.setProxy(new Proxy(Type.HTTP, new InetSocketAddress(host, port)));
        return factory;
    }

    @Bean
    public OAuth2RestTemplate oauth2RestTemplate(OAuth2ClientContext oauth2ClientContext,
            OAuth2ProtectedResourceDetails details, ClientHttpRequestFactory requestFactory) {

        OAuth2RestTemplate restTemplate = new OAuth2RestTemplate(details, oauth2ClientContext);
        restTemplate.setRequestFactory(requestFactory);
        return restTemplate;
    }
```

* 認証プロキシを利用する場合

```java
    @Bean
    public ClientHttpRequestFactory requestFactory(
            @Value("${proxy.host}") String host,
            @Value("${proxy.port}") int port,
            @Value("${proxy.user}") String user,
            @Value("${proxy.password}") String password) {

        HttpClientBuilder builder = HttpClientBuilder.create();
        builder.setProxy(new HttpHost(host, port));

        if (StringUtils.hasText(user) && StringUtils.hasText(password)) {
            BasicCredentialsProvider provider = new BasicCredentialsProvider();
            provider.setCredentials(new AuthScope(host, port), new UsernamePasswordCredentials(user, password));
            builder.setDefaultCredentialsProvider(provider);
        }

        return new HttpComponentsClientHttpRequestFactory(builder.build());
    }

    @Bean
    public OAuth2RestTemplate oauth2RestTemplate(OAuth2ClientContext oauth2ClientContext,
            OAuth2ProtectedResourceDetails details, ClientHttpRequestFactory requestFactory) {

        OAuth2RestTemplate restTemplate = new OAuth2RestTemplate(details, oauth2ClientContext);
        restTemplate.setRequestFactory(requestFactory);
        return restTemplate;
    }
```

今回は、認証プロキシを利用するパターンで検証してみました。

## プロキシが適切に適用されない

作成したクライアントアプリに上記の方法で認証プロキシを適用し、SSOログインしてみると、、、

* コンテキストルート（`/`）にアクセス
* ログインURL（`/login`）にリダイレクト
* Githubの認証URLにリダイレクト
* 認可コードを付与してログインURL（`/login`）にリダイレクト
* Githubの認証URLにリダイレクト
* 認可コードを付与してログインURL（`/login`）にリダイレクト
* ...

と、延々認証をループしていまいました。
Spring Security OAuth2のロガーをINFOレベルにしてログを確認すると、**407 Proxy Authentication Required**と出力されており、認証プロキシが適切に適用されていないことが分かります。

これは、`@EnableOAuth2Sso`でセットアップされる`OAuth2ClientAuthenticationProcessingFilter`では、Bean定義した`OAuth2RestTemplate`とは別の`OAuth2RestTemplate`が使用されるためです。

## `@EnableOAuth2Sso`でセットアップされる`OAuth2RestTemplate`にプロキシを適用する（解析編）

ここで、`@EnableOAuth2Sso`によりセットアップされるコンポーネントを図示します。

![alt](https://raw.githubusercontent.com/yoshikawaa/images/master/spring-boot2%2Boauth2-autoconfig/01_oauth2-autoconfig-architecture.jpg)

これにより、`@EnableOAuth2Sso`により複数の`OAuth2RestTemplate`が生成されますが、そのすべてが`UserInfoRestTemplateFactory`によって生成（または生成された`OAuth2RestTemplate`を通じて生成）されていることが分かります。

次に、`UserInfoRestTemplateFactory`インターフェイスのデフォルト実装クラス（`DefaultUserInfoRestTemplateFactory`）を確認します。

```java
public class DefaultUserInfoRestTemplateFactory implements UserInfoRestTemplateFactory {

    // omitted.

    public DefaultUserInfoRestTemplateFactory(
            ObjectProvider<List<UserInfoRestTemplateCustomizer>> customizers,
            ObjectProvider<OAuth2ProtectedResourceDetails> details,
            ObjectProvider<OAuth2ClientContext> oauth2ClientContext) {
        this.customizers = customizers.getIfAvailable();
        this.details = details.getIfAvailable();
        this.oauth2ClientContext = oauth2ClientContext.getIfAvailable();
    }

    // omitted.

    @Override
    public OAuth2RestTemplate getUserInfoRestTemplate() {
        if (this.oauth2RestTemplate == null) {
            this.oauth2RestTemplate = createOAuth2RestTemplate(
                    this.details == null ? DEFAULT_RESOURCE_DETAILS : this.details);
            this.oauth2RestTemplate.getInterceptors()
                    .add(new AcceptJsonRequestInterceptor());
            AuthorizationCodeAccessTokenProvider accessTokenProvider = new AuthorizationCodeAccessTokenProvider();
            accessTokenProvider.setTokenRequestEnhancer(new AcceptJsonRequestEnhancer());
            this.oauth2RestTemplate.setAccessTokenProvider(accessTokenProvider);
            if (!CollectionUtils.isEmpty(this.customizers)) {
                AnnotationAwareOrderComparator.sort(this.customizers);
                for (UserInfoRestTemplateCustomizer customizer : this.customizers) {
                    customizer.customize(this.oauth2RestTemplate); // ★★★
                }
            }
        }
        return this.oauth2RestTemplate;
    }

    // omitted.
}
```

注目すべきは、`UserInfoRestTemplateCustomizer#customize`メソッドにより、`OAuth2RestTemplate`に任意の設定を追加できる点です。

次に、独自に`OAuth2RestTemplate`を生成している`AccessTokenProvider`の抽象クラス（`OAuth2AccessTokenSupport`）を確認します。

```java
public abstract class OAuth2AccessTokenSupport {

    // omitted.

    protected RestOperations getRestTemplate() {
        if (restTemplate == null) {
            synchronized (this) {
                if (restTemplate == null) {
                    RestTemplate restTemplate = new RestTemplate();
                    restTemplate.setErrorHandler(getResponseErrorHandler());
                    restTemplate.setRequestFactory(requestFactory); // ★★★
                    restTemplate.setInterceptors(interceptors);
                    this.restTemplate = restTemplate;
                }
            }
        }
        if (messageConverters == null) {
            setMessageConverters(new RestTemplate().getMessageConverters());
        }
        return restTemplate;
    }

    // omitted.

    public void setRequestFactory(ClientHttpRequestFactory requestFactory) {
        Assert.notNull(requestFactory, "'requestFactory' must not be null");
        this.requestFactory = requestFactory;
    }

    // omitted.
}
```

注目すべきは、冒頭で説明した`ClientHttpRequestFactory`により、内部的に生成する`OAuth2RestTemplate`にプロキシを適用できる点です。

> ちなみに、`OAuth2AccessTokenSupport`ではデフォルトで適用する`ClientHttpRequestFactory`の`prepareConnection`メソッドをオーバーライドしており、同様にオーバーライドしたほうが良いかもしれません。（細かく検証していないので、問題になるパターンには遭遇していませんが。）

## `@EnableOAuth2Sso`でセットアップされる`OAuth2RestTemplate`にプロキシを適用する（実装編）

解析結果から、`@EnableOAuth2Sso`によりセットアップされるすべての`OAuth2RestTemplate`に認証プロキシを適用します。

```java
    // (1) リクエストにプロキシ認証情報を組み込むClientHttpRequestFactoryを生成する
    @Bean
    @ConditionalOnProperty("proxy.host")
    public ClientHttpRequestFactory requestFactory(
            @Value("${proxy.host}") String host,
            @Value("${proxy.port}") int port,
            @Value("${proxy.user}") String user,
            @Value("${proxy.password}") String password) {

        HttpClientBuilder builder = HttpClientBuilder.create();
        builder.setProxy(new HttpHost(host, port));

        if (StringUtils.hasText(user) && StringUtils.hasText(password)) {
            BasicCredentialsProvider provider = new BasicCredentialsProvider();
            provider.setCredentials(new AuthScope(host, port), new UsernamePasswordCredentials(user, password));
            builder.setDefaultCredentialsProvider(provider);
        }

        return new HttpComponentsClientHttpRequestFactory(builder.build());
    }

    // (2) 認証プロキシ用ClientHttpRequestFactoryをセットしたAccessTokenProviderを生成する
    @Bean
    @ConditionalOnBean(ClientHttpRequestFactory.class)
    public AccessTokenProvider accessTokenProvider(ClientHttpRequestFactory requestFactory) {

        AuthorizationCodeAccessTokenProvider authorizationCodeAccessTokenProvider = new AuthorizationCodeAccessTokenProvider();
        authorizationCodeAccessTokenProvider.setRequestFactory(requestFactory);
        ImplicitAccessTokenProvider implicitAccessTokenProvider = new ImplicitAccessTokenProvider();
        implicitAccessTokenProvider.setRequestFactory(requestFactory);
        ResourceOwnerPasswordAccessTokenProvider resourceOwnerPasswordAccessTokenProvider = new ResourceOwnerPasswordAccessTokenProvider();
        resourceOwnerPasswordAccessTokenProvider.setRequestFactory(requestFactory);
        ClientCredentialsAccessTokenProvider clientCredentialsAccessTokenProvider = new ClientCredentialsAccessTokenProvider();
        clientCredentialsAccessTokenProvider.setRequestFactory(requestFactory);
        return new AccessTokenProviderChain(
                Arrays.asList(authorizationCodeAccessTokenProvider, implicitAccessTokenProvider,
                        resourceOwnerPasswordAccessTokenProvider, clientCredentialsAccessTokenProvider));
    }

    // (3) UserInfoRestTemplateCustomizerを利用して、OAuth2RestTemplateに認証プロキシ用ClientHttpRequestFactoryとAccessTokenProviderをセットする
    @Bean
    @ConditionalOnBean({ AccessTokenProvider.class, ClientHttpRequestFactory.class })
    public UserInfoRestTemplateCustomizer userInfoRestTemplateCustomizer(AccessTokenProvider accessTokenProvider,
            ClientHttpRequestFactory requestFactory) {

        return new UserInfoRestTemplateCustomizer() {
            @Override
            public void customize(OAuth2RestTemplate restTemplate) {
                restTemplate.setAccessTokenProvider(accessTokenProvider);
                restTemplate.setRequestFactory(requestFactory);
            }
        };
    }

    // (4) Controller等から直接利用するOAuth2RestTemplateにも、認証プロキシ用ClientHttpRequestFactoryとAccessTokenProviderをセットする
    @Bean
    public OAuth2RestTemplate oauth2RestTemplate(OAuth2ClientContext oauth2ClientContext,
            OAuth2ProtectedResourceDetails details, ObjectProvider<AccessTokenProvider> accessTokenProvider,
            ObjectProvider<ClientHttpRequestFactory> requestFactory) {

        OAuth2RestTemplate restTemplate = new OAuth2RestTemplate(details, oauth2ClientContext);
        accessTokenProvider.ifAvailable(restTemplate::setAccessTokenProvider);
        requestFactory.ifAvailable(restTemplate::setRequestFactory);
        return restTemplate;
    }
```

> 今回の実装では、プロキシ環境とパブリック環境の両方で利用できるよう、`@ConditionalOnProperty`と`@ConditionalOnBean`により生成するBeanを切り替えられるようにしています。
> また、`ObjectProvider`は引数で受け取るBeanが存在しない場合や複数ある場合に対応するためのラッパーで、Java 8の`Optional`のような役割を果たします。

修正したクライアントアプリで改めてSSOログインしてみると、、、

* コンテキストルート（`/`）にアクセス
* ログインURL（`/login`）にリダイレクト
* Githubの認証URLにリダイレクト
* 認可コードを付与してログインURL（`/login`）にリダイレクト
* コンテキストルートが表示される

と、正常にログインできました。

## まとめ

Autoconfigがデフォルトでプロキシを考慮していないことはしょうがないのですが、意外なほど`OAuth2RestTemplate`を使い回しておらず、設定したはずなのに何故動かないんだろう？と躓いてしまいました。。。
公式リファレンスで「OAuth2クライアントでは`OAuth2RestTemplate`をBean定義して使え」と記載しているのに、Bean定義したものが利用されない場合があるのは直感的ではなく、ちょっと不親切ですね。今後改善されるといいなーと思います。

## 参考

* [StackOverFlow - Proxy configuration in OAuth2RestTemplate](https://stackoverflow.com/questions/38884073/proxy-configuration-in-oauth2resttemplate?utm_medium=organic&utm_source=google_rich_qa&utm_campaign=google_rich_qa) - 一部参考にしました。