Spring WebFlux × Spring Securityで動的にOAuth 2.0クライアント情報を取得する

こちらの記事は、アソビュー! Advent Calendar 2023の20日目(B面)です。
こんにちは! アソビューでバックエンドエンジニアをしている島田です。

そろそろクリスマスが近づいていますが、4歳の息子に去年は3歳で3つのプレゼントがあったから今年は4つだよね?と末恐ろしいことを言われて恐々としています... (お菓子セットで逃げようかと思います)

さて本題ですが、SaaS向けシステムの新規開発を行い、その中でSpring Cloud GatewayにSpring SecurityのOAuth 2.0クライアントを実装しました。

アソビューのバックエンドシステムは、さまざまなアプリケーションで構成されており、それぞれがマイクロサービスとして運用されています。
これにより、バックエンドは分散した形態をとっていますが、外部に公開するAPIに関しては、ユーザーに対して統一感のあるインターフェイスを提供することが重要です。この一貫性を確保するために、アソビューではSpring Cloud Gatewayを活用したAPI Gatewayを導入しています。

今回、Spring Cloud GatewayにSpring Securityで動的にOAuth 2.0クライアント情報を取得した内容を共有したいと思います。

なぜ動的にクライアント情報を取得する必要があったのか

動的なサブドメインで認証する必要があった

ユーザー向けに公開されるURLドメインが特定の基準に基づいて動的に変化するサブドメインを含んでいました。例えば、顧客ごとにカスタマイズされた {subdomain}.hoge.app のようなサブドメインがそれに該当します。このため、各サブドメインに対応したOAuthのリダイレクトURIを動的に生成する必要がありました。
リダイレクトURIは認証サーバーに事前に登録する必要があり、ユーザーが認証サーバー(例えば、Google、Facebookなど)で認証を完了した後、リダイレクトURIはユーザーをクライアントアプリケーションに戻すために使用されます。
リダイレクトURIはOAuth2.0の規格RFC6749にて、絶対URIでなければならないと定められているため、相対URIとして一つ登録しておくということができません。

datatracker.ietf.org

そのため、クライアント情報を(クライアントIDとクライアントシークレット、ClientRegistrationを一意にするレジストレーションID)をDBに保存するようにしました。

具体的な実装

環境

  • Java 11
  • Spring Boot 2系

ClientRegistrationの登録

ClientRegistrationとは

ClientRegistrationは、OAuth 2.0 または OpenID Connect 1.0 プロバイダーに登録されたクライアント情報を管理するクラスです。

github.com

こちらのクラスに設定できるフィールドは以下となります。

フィールド名 説明
registrationId ClientRegistration を一意に識別するID
clientId クライアント識別子
clientSecret クライアントのシークレット
clientAuthenticationMethod プロバイダーでクライアントを認証するために使用される方法。サポートされている値は、client_secret_basic、client_secret_post、private_key_jwt、client_secret_jwt、および none(パブリッククライアント)です
authorizationGrantType OAuth 2.0 認証フレームワークは、4 つの認可付与型を定義します。サポートされている値は、authorization_code、client_credentials、password、拡張認可型 urn:ietf:params:oauth:grant-type:jwt-bearer です
redirectUri エンドユーザーがクライアントへのアクセスを認証および認可した後に、認可サーバーがエンドユーザーのユーザーエージェントをリダイレクトするクライアントの登録済みリダイレクトURI
scopes 認可リクエストフロー中にクライアントがリクエストしたスコープ(openid、メール、プロファイルなど)
clientName クライアントに使用される説明的な名前。この名前は、自動生成されたログインページにクライアントの名前を表示するときなど、特定のシナリオで使用される場合があります
authorizationUri 認可サーバーの認可エンドポイントURI
tokenUri 認可サーバーのトークンエンドポイントURI
jwkSetUri 認可サーバーから JSON Web キー (JWK) セットを取得するために使用されるURI。IDトークンの JSON Web 署名 (JWS) およびオプションで UserInfo レスポンスを検証するために使用される暗号化キーが含まれます
issuerUri OpenID Connect 1.0 プロバイダーまたは OAuth 2.0 Authorization Server の発行者識別子URI
configurationMetadata OpenID プロバイダーの構成情報。この情報は、Spring Boot 2.x プロパティ spring.security.oauth2.client.provider.[providerId].issuerUri が構成されている場合にのみ利用できます
(userInfoEndpoint)uri 認証されたエンドユーザーのクレーム / 属性にアクセスするために使用される UserInfo エンドポイントURI
(userInfoEndpoint)authenticationMethod アクセストークンを UserInfo エンドポイントに送信するときに使用される認証方法。サポートされる値は、header、form、および query です
userNameAttributeName エンドユーザーの名前または識別子を参照する UserInfo レスポンスで返される属性の名前

spring.pleiades.io

また、Spring SecurityではClientRegistrationに設定できるプロパティが用意されています。 application.yml に以下のような形で設定すると、Spring Boot 2.x 自動構成は、spring.security.oauth2.client.registration.[registrationId] の各プロパティを ClientRegistration のインスタンスにバインドし、ReactiveClientRegistrationRepository 内の各 ClientRegistrationインスタンスを生成します。
今回、動的になる部分は registrationIdclientIdclientSecretになるのでそれ以外で必要なプロパティを設定しておきます。

spring:
  security:
    oauth2:
      client:
        registration:
          providerName:  // google, okta等のプロバイダー名
            client-id: 123456789
            client-secret: secret
            redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
            ...

spring.pleiades.io

そして、ClientRegistrationを永続化するリポジトリとしてReactiveClientRegistrationRepositoryがあります。

今回、動的にClientRegistrationを永続化します。 先にコードを示します。

@Configuration
@AllArgsConstructor
@EnableWebFluxSecurity
public class GatewayConfig {

    GatewayProperties properties;

    @Bean
    ReactiveClientRegistrationRepository reactiveClientRegistrationRepository(ReactorHogeServiceGrpc.ReactorHogeServiceStub reactorHogeServiceStub) { // ★
        var registrations = new ConcurrentHashMap<String, ClientRegistration>();
        reactorHogeServiceStub.listClientRegistrations(ListClientRegistrationsRequest.newBuilder().build())
                .subscribe(res -> res.getClientRegistrationsList().forEach(registration -> registrations.put(registration.getName(), convertClientRegistration(registration))),
                        t -> log.error("初期化に失敗しました", t.getCause()));
        return new GatewayClientRegistrationRepository(registrations, reactorHogeServiceStub);
    }

    ClientRegistration convertClientRegistration(com.hoge.resource.v1.ClientRegistration clientRegistration) {
        return ClientRegistration.withRegistrationId(clientRegistration.getName())
                .clientId(clientRegistration.getClientId())
                .clientName(properties.getOauth2Client().getHoge().getClientName())
                .clientSecret(clientRegistration.getClientSecret())
                .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
                .redirectUriTemplate(properties.getOauth2Client().getHoge().getRedirectUriTemplate())
                .scope(OidcScopes.OPENID)
                .authorizationUri(properties.getHoge().getTarget() + properties.getOauth2Client().getHoge().getAuthorizationUri())
                .jwkSetUri(properties.getHoge().getTarget() + properties.getOauth2Client().getHoge().getJwkSetUri())
                .tokenUri(properties.getHoge().getTarget() + properties.getOauth2Client().getHoge().getTokenUri())
                .userNameAttributeName(IdTokenClaimNames.SUB)
                .build();
    }

    @AllArgsConstructor
    public class GatewayClientRegistrationRepository implements ReactiveClientRegistrationRepository { // ★
        ConcurrentHashMap<String, ClientRegistration> registrations;
        ReactorHogeServiceGrpc.ReactorHogeServiceStub reactorHogeServiceStub;

        @Override
        public Mono<ClientRegistration> findByRegistrationId(String registrationId) {
            return Mono.justOrEmpty(registrations.get(registrationId))
                    .switchIfEmpty(reactorHogeServiceStub.getClientRegistration(GetClientRegistrationRequest.newBuilder().setName(registrationId).build())
                            .map(res -> {
                                var clientRegistration = convertClientRegistration(res);
                                registrations.put(res.getName(), clientRegistration);
                                return clientRegistration;
                            })
                            .onErrorResume(StatusRuntimeException.class, ex -> Mono.error(new org.springframework.web.server.ResponseStatusException(HttpStatus.BAD_REQUEST, "registrationIdが不正です。"))));
        }
    }
}

上記のコードで以下のことを行なっています。

ReactiveClientRegistrationRepository のカスタマイズ

ReactiveClientRegistrationRepositoryをBeanに登録します。 アプリケーション起動時に1回実行され、保存されているClientRegistrationの情報(registrationIdclientIdclientSecret)をAPIにて一覧情報を取得し、registrationIdをキーとしてClientRegistrationを値に設定するMapを作成します。

動的なクライアント登録(ReactiveClientRegistrationRepositoryの実装)

ReactiveClientRegistrationRepositoryの実装クラスを用意し、findByRegistrationIdメソッドを実装しています。
このメソッドは認証フロー開始時に、リクエストで送られてきた registrationId からClientRegistrationを解決するメソッドです。 Mapに保持されていない場合、APIで取得した結果をMapに保持しています。

なお、OAuth 2.0の認証コード付与フローは、ユーザーがアプリケーションでログインを試みるときに始まります。これを実現するために、Spring SecurityのWebFlux環境では特定のクラスとプロセスが重要な役割を果たします。

OAuth2AuthorizationRequestRedirectWebFilter

このクラスは、ユーザーが認証を開始する際の最初のステップを管理します。 その主な機能は、ServerOAuth2AuthorizationRequestResolverを使って、いわゆるOAuth2AuthorizationRequest(OAuth2認可リクエスト)を生成することです。 このリクエストが生成されたら、ユーザーのブラウザ(ユーザーエージェント)はOAuth2認証サーバーのログインページにリダイレクトされます。

ServerOAuth2AuthorizationRequestResolverの役割

このインターフェースの目的は、WebリクエストからOAuth2AuthorizationRequestを作成することです。 デフォルトの実装であるDefaultServerOAuth2AuthorizationRequestResolverは、特定のURLパターン(/oauth2/authorization/{registrationId})を使って動作します。 このURLからregistrationIdを抽出し、それを基に関連するClientRegistration(クライアント登録情報)を使ってOAuth2認可リクエストを構築します。

最後に

アソビューでは、プロダクトに関わるエンジニアメンバーを募集しております! 自社プロダクトのため顧客に向き合い課題解決でき、やりがいを感じる場面が多くあります。
興味がある方はぜひカジュアル面談でお話できればと思いますので、お気軽にご応募ください!

www.asoview.com