🍃

Spring Authorization Server を使って簡単な IdP を作ってみる

2023/01/06に公開

概要

Spring Authorization Server というフレームワークが GA になりました。
このフレームワークは

Spring Authorization Server は、OAuth 2.1 [IETF] (英語) および OpenID Connect 1.0 (英語) 仕様とその他の関連仕様の実装を提供するフレームワークです

となっており、これを使うことで OAuth2.1 や OIDC の認可サーバを楽に実装できるはずです。

ゴール

今回はこちらをつかった簡単な IdP と同じく Spring Framework の Spring Security OAuth2 Client を使った RP でログインできるところまでを検証したいと思います。

前提条件

Java 17
Gradle 7.7
Spring Boot 3.0.1
※ Spring Authorization Server は Spring 3系のみ対応

Authorization Server 側の port :8080
RP 側の port :8090

実装

Spring Authorization Server

build.gradle
/* 省略 */

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-web'
	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation 'org.springframework.security:spring-security-oauth2-authorization-server:1.0.0'
	developmentOnly 'org.springframework.boot:spring-boot-devtools'
}

SecurityConfig.java
@EnableWebSecurity
@Configuration(proxyBeanMethods = false)
public class securityConfig {

    @Bean
    @Order(1)
    public SecurityFilterChain authorizationServerFilterChain(HttpSecurity http) throws Exception {
        OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
        http.getConfigurer(OAuth2AuthorizationServerConfigurer.class).oidc(Customizer.withDefaults());
        http.exceptionHandling(exceptions -> {
            exceptions.authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login"));
        }).oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt);
        return http.build();
    }

    @Bean
    @Order(2)
    public SecurityFilterChain defaultFilterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(authorize -> {
            authorize.anyRequest().authenticated();
        }).formLogin();
        return http.build();
    }

    @Bean
    public RegisteredClientRepository registeredClientRepository() {
        RegisteredClient sampleClient = RegisteredClient.withId(UUID.randomUUID().toString())
                .clientId("sample-client")
                .clientSecret("{noop}sample-secret")
                .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
                .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
                .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
                .redirectUri("http://127.0.0.1:8080/login/oauth2/code/sample-authorization-server")
                .scope(OidcScopes.OPENID)
                .scope(OidcScopes.PROFILE)
                .scope(OidcScopes.EMAIL).build();

        return new InMemoryRegisteredClientRepository(sampleClient);
    }

    @Bean
    UserDetailsService users() {
        UserDetails user = User.builder()
                .username("user")
                .password("{noop}password")
                .roles("USER")
                .build();
        return new InMemoryUserDetailsManager(user);
    }

    @Bean
    public JWKSource<SecurityContext> jwkSource() throws NoSuchAlgorithmException {
        KeyPair keyPair;
        KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
        keyPairGenerator.initialize(2048);
        keyPair = keyPairGenerator.generateKeyPair();
        RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
        RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
        RSAKey rsaKey = new RSAKey.Builder(publicKey).privateKey(privateKey).keyID(UUID.randomUUID().toString()).build();
        JWKSet jwkSet = new JWKSet(rsaKey);
        return ((jwkSelector, context) -> jwkSelector.select(jwkSet));
    }

    @Bean
    public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
        return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
    }

    @Bean
    public AuthorizationServerSettings authorizationServerSettings() {
        return AuthorizationServerSettings.builder().build();
    }
}

今回はサンプルのため、メモリ上にユーザと RP の情報も保持します。
詳細を見ていきます。

authorizationServerFilterChain

    @Bean
    @Order(1)
    public SecurityFilterChain authorizationServerFilterChain(HttpSecurity http) throws Exception {
        OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
        http.getConfigurer(OAuth2AuthorizationServerConfigurer.class).oidc(Customizer.withDefaults());
        http.exceptionHandling(exceptions -> {
            exceptions.authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login"));
        }).oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt);
        return http.build();
    }

メインの IdP に関する設定はこの部分になります。
今回はデフォルトの OAuth2AuthorizationServerConfigurer をそのまま使っています。
また、 .oidc(Customizer.withDefaults()); で OpenID Connect を有効化しています。

defaultFilterChain

    @Bean
    @Order(2)
    public SecurityFilterChain defaultFilterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(authorize -> {
            authorize.anyRequest().authenticated();
        }).formLogin();
        return http.build();
    }

ここは一般的な Spring Security の設定部分になります。
ここで注目していただきたいのが、全てのパスに対して認証を要求しているところです。
このままだと OAuth2.0 や OpenID Connect のエンドポイントにもアクセスができないように見えますが、 authorizationServerFilterChain既に口が開けられており、それらのエンドポイントに関しては意識しなくても大丈夫なのが利点ですね。

registeredClientRepository

    @Bean
    public RegisteredClientRepository registeredClientRepository() {
        RegisteredClient sampleClient = RegisteredClient.withId(UUID.randomUUID().toString())
                .clientId("sample-client")
                .clientSecret("{noop}sample-secret")
                .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
                .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
		.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
                .redirectUri("http://127.0.0.1:8080/login/oauth2/code/sample-authorization-server")
                .scope(OidcScopes.OPENID)
		.clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build())
		.build();

        return new InMemoryRegisteredClientRepository(sampleClient);
    }

これはインメモリで設定していますが実際にはこのような設定方法はしないと思います。

.clientSecret で記載している {noop} は クライアントシークレットのハッシュ化アルゴリズムのことであり、ここでは平文で sample-secret という値を保持していることを意味します。

また、 .redirectUrilocalhost ではなく、 127.0.0.1 としていますが、これは Spring Authorization Server では localhost の指定が禁止されているためこのような形になっています。

users

    @Bean
    UserDetailsService users() {
        UserDetails user = User.builder()
                .username("user")
                .password("{noop}password")
                .roles("USER")
                .build();
        return new InMemoryUserDetailsManager(user);
    }

username: user
password: password
のユーザを作成しています。(それだけ)

jwkSource

    @Bean
    public JWKSource<SecurityContext> jwkSource() throws NoSuchAlgorithmException {
        KeyPair keyPair;
        KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
        keyPairGenerator.initialize(2048);
        keyPair = keyPairGenerator.generateKeyPair();
        RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
        RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
        RSAKey rsaKey = new RSAKey.Builder(publicKey).privateKey(privateKey).keyID(UUID.randomUUID().toString()).build();
        JWKSet jwkSet = new JWKSet(rsaKey);
        return ((jwkSelector, context) -> jwkSelector.select(jwkSet));
    }

ここでは IDトークンやアクセストークンの JWS に使用する 鍵を設定しています。今回は1つだけです。

ここまで設定ができれば完成です。

IdP を起動し、 http://localhost:8090/.well-known/openid-configuration にアクセスすると下記のような json が取得できます。

{
"issuer": "http://localhost:8090",
"authorization_endpoint": "http://localhost:8090/oauth2/authorize",
"token_endpoint": "http://localhost:8090/oauth2/token",
"token_endpoint_auth_methods_supported": [
"client_secret_basic",
"client_secret_post",
"client_secret_jwt",
"private_key_jwt"
],
"jwks_uri": "http://localhost:8090/oauth2/jwks",
"userinfo_endpoint": "http://localhost:8090/userinfo",
"response_types_supported": [
"code"
],
"grant_types_supported": [
"authorization_code",
"client_credentials",
"refresh_token"
],
"revocation_endpoint": "http://localhost:8090/oauth2/revoke",
"revocation_endpoint_auth_methods_supported": [
"client_secret_basic",
"client_secret_post",
"client_secret_jwt",
"private_key_jwt"
],
"introspection_endpoint": "http://localhost:8090/oauth2/introspect",
"introspection_endpoint_auth_methods_supported": [
"client_secret_basic",
"client_secret_post",
"client_secret_jwt",
"private_key_jwt"
],
"subject_types_supported": [
"public"
],
"id_token_signing_alg_values_supported": [
"RS256"
],
"scopes_supported": [
"openid"
]
}

これは OpenID Connect Discovery 1.0 という仕様であり、 Spring Security OAuth2 Client ではここで記されている issuer を設定さえすればあとはよしなに定義してくれます。

Spring Security OAuth2 Client

application.properties
spring.security.oauth2.client.registration.sample-auth-server.provider=sample-auth-server
spring.security.oauth2.client.registration.sample-auth-server.client-id=sample-client
spring.security.oauth2.client.registration.sample-auth-server.client-secret=sample-secret
spring.security.oauth2.client.registration.sample-auth-server.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.sample-auth-server.redirect-uri=http://127.0.0.1:8080/login/oauth2/code/sample-authorization-server
spring.security.oauth2.client.registration.sample-auth-server.scope[0]=openid
spring.security.oauth2.client.provider.sample-auth-server.issuer-uri=http://localhost:8090
SecurityConfig.java
@EnableWebSecurity
public class SecurityConfig {
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests()
                .anyRequest().authenticated()
                .and()
                .oauth2Login();
        return http.build();
    }
}

RP の設定はこれで終わりです。簡単ですね。

動作確認

所感

面倒なフルスクラッチをしなくていいという部分は大きいですが、まだバージョン 1.0.0 なため今後に期待という印象です。
 
OAuth2.1 および OpenID Connect をサポートしているとのことですが、当然のことながら全てを網羅的にサポートしているわけではないです。

例えば、 OpenID Connect に関しては offline_scope のサポートはされていないようで、仮に設定したとしても OpenID Connect Core に記載されているようにリフレッシュトークンを発行したり、同意を求める画面が表示されるわけではありません。
現状では、 RP の登録時に .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) を設定することで発行がされるようです。

また、車輪の再開発をしないためだとは思いますが、 Spring Security OAuth2 Resource Server が依存関係に入っており、 IdP が Resource Server に依存しているという歪な関係になっています

OAuth2.1 に関してはそもそもがまだ draft の仕様なので今後も追従が必要になるでしょう。

そして広い範囲をサポートしている分、開発者の学習コストも高い印象を受けました。
複数の RFC をサポートしているため、認識していないエンドポイントが解放されてしまっているということもありうるため OAuth2.0 や OpenID Connect とそれに関連する仕様についての知識はやはり必要だと思います。

Discussion