Spring Authorization Server を使って簡単な IdP を作ってみる
概要
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
/* 省略 */
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'
}
@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
という値を保持していることを意味します。
また、 .redirectUri
を localhost
ではなく、 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
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
@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