KotlinとSpring Security 6.xを使って、Introspection Endpointでトークンを検証するOAuth2のリソースサーバ−を実装する

2023.03.01

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

AWS事業本部サービス部の佐藤です。

この前までTypeScriptばっかり触っていたのに、気づけばJavaとSpring Bootばかり触っていますね…

KotlinとSpring Security 6.xを使ってOAuth2のリソースサーバーを試す機会がありましたので、手順を残そうと思います。2023年3月時点ではSpring Boot 3.xを使っているためSpring Security 6.xを使うことになるのですが、あまりネット上に情報が少なかったのとハマった部分も多かったので記事にしました。現在のSpring Security書き方をするように意識し、非推奨の書き方はしないようにしています。

新しいSpring Securityの設定の書き方については以下のQiitaの記事がとても参考になります。

Spring Security 5.4〜6.0でセキュリティ設定の書き方が大幅に変わる件

この記事では、Spring Security 6.xを使ってOAuth2 Resource Serverを構成し、Access TokenをIntrospection Endpoint経由で検証、メソッドセキュリティ経由で権限チェックするところまでを実装したいと思います。

なお、認可サーバーはすでにある前提で進めます。Access TokenはJWTではなくOpaque Tokenを使用する前提です。

環境

  • Kotlin: 1.7.22
  • JDK: openjdk 17.0.6 2023-01-17 LTS
  • Spring Boot: 3.0.3
  • Spring Security: 6.0.1
    • spring-security-oauth2-resource-server

Opaque Tokenとは

Access TokenとしてJWT(JSON Web Token)を使用することが多いと思いますが、これはトークン自体に情報を持っていてデコードすることで認可情報を取得できます。対してOpaque Tokenというトークンも存在します。これは、トークン自体には情報を持たず、必ず認可サーバーにトークンをキーとして問い合わせて情報を取得する必要がある形式です。その問い合わせするためのAPIがIntrospection Endpointです。Opeque Tokenの形式の場合は必ず認可サーバーにこのトークンがactiveかどうかを確認するステップが存在します。

この記事では、JWTではなくこの方式でトークンを検証する方法を取ります。

Introspection Endpointの仕様

Introspectionの仕様については RFC7662 に書かれています。Spring Security OAuth2 Resource Serverはデフォルト設定ではこの仕様に則ったレスポンスを期待して内部的に処理をします。認可サーバーの実装によっては独自仕様のレスポンスが含まれていることもあり、それに対応しようとすると、Spring Securityのデフォルト実装をオーバーライドする必要があります。

参考に、デフォルトではRFCに定義されている以下のようなレスポンスを期待します。

{
  "active": true,
  "iss": "https://example.com",
  "scope": "read write",
  "exp": 1676599750,
  "sub": "hoge",
  "clientId": "client-id"
}

実装してみる

Spring Bootを使ってWeb APIを作成し、各エンドポイントを保護します。認可サーバーで用意されているToken Introspection Endpointを使ってAccess Tokenを検証し、正しいAccess Tokenであればエンドポイントの実行を許可するような構成です。

spring initializerでアプリケーションの雛形を作成する

まずは、以下の設定で spring initializer を使ってアプリケーションの雛形を作ります。

  • Gradle - Kotlin
  • Kotlin
  • Spring Boot 3.0.3
  • Jar
  • Java 17

build.gradle.kts

作成されたgradleの設定ファイルのdependenciesに以下を追加します。ポイントはSpring Securityだけではなく、 spring-boot-starter-oauth2-resource-serveroauth2-oidc-sdk を依存に含めることです。

implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server")
runtimeOnly("com.nimbusds:oauth2-oidc-sdk:10.7")

gradleの全体像はこんな感じです。

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
    id("org.springframework.boot") version "3.0.2"
    id("io.spring.dependency-management") version "1.1.0"
    id("org.graalvm.buildtools.native") version "0.9.18"
    kotlin("jvm") version "1.7.22"
    kotlin("plugin.spring") version "1.7.22"
    kotlin("plugin.jpa") version "1.7.22"
    kotlin("plugin.serialization") version "1.7.22"
}

group = "com.briete"
version = "0.0.1-SNAPSHOT"
java.sourceCompatibility = JavaVersion.VERSION_17

configurations {
    compileOnly {
        extendsFrom(configurations.annotationProcessor.get())
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-actuator")
    implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server")
    implementation("org.springframework.boot:spring-boot-starter-security")
    implementation("org.springframework.boot:spring-boot-starter-web")
    runtimeOnly("com.nimbusds:oauth2-oidc-sdk:10.7")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.0")
    implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.4.0")
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")

    testImplementation("org.springframework.boot:spring-boot-starter-test")
    testImplementation("org.springframework.security:spring-security-test")
}

tasks.withType<KotlinCompile> {
    kotlinOptions {
        freeCompilerArgs = listOf("-Xjsr305=strict")
        jvmTarget = "17"
    }
}

tasks.withType<Test> {
    useJUnitPlatform()
}

Spring Securiy OAuth2 Resource Serverの基本設定

まずは、application.ymlに設定を行います。Introspection EndpointのURLと認可サーバーから払い出されている client-idclient-secret を設定します。

spring:
  security:
    oauth2:
      resourceserver:
        opaque-token:
          introspection-uri: "https://<url>/oauth/introspect"
          client-id: "<client-id>"
          client-secret: "<client-secret>"

次にConfigurationでSpring Securityの設定を行います。Kotlinで書いていますが、Javaでもほとんど同様です。 SecurityFilterChain を使って各APIごとの認可の方法を決定します。 authorizeHttpRequestsauthorize を使って、まずパスごとの認可の方法を決定します。今回 spring-boot-starter-actuator を使っているため、自動的にヘルスチェックエンドポイントができています。 /actuator/health については認可の対象外にしたいため、 permitAll でpublicなAPIとしています。また、リソースサーバーのみの機能とするのでHTTPのFormLoginやLogout機能についてはdisableにします。

リソースサーバーの設定については、 oauth2ResourceServer { opaqueToken {} } の指定だけです。これで内部的にIntrospection Endpointに対してリクエストし、レスポンスをSpring Securityのインターフェイスに沿って返してくれます。

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
class SecurityConfig(val appConfig: AppConfig) {
    @Bean
    fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
        http {
            authorizeHttpRequests {
                authorize("/actuator/health", permitAll)
                authorize(anyRequest, authenticated)
            }
            csrf { disable() }
            httpBasic { disable() }
            logout { disable() }
            oauth2ResourceServer { opaqueToken { } }
        }

        http.formLogin().disable()
        return http.build()
    }
}

Controller

適当な名前のControllerを作成して、Spring Securityで保護されたAPIを作成します。メソッドセキュリティのPreAuthorize を使ってメソッドに対して保護をしています。 これで /private/hoge APIは scopeに read を持ったトークンでのみアクセスできることになります。principalにはIntrospection Endpointのレスポンスの結果などの情報が入っています。

@RestController
@RequestMapping("/private")
class HelloWorldController() {

    @GetMapping(value = ["/hoge"])
    @PreAuthorize("hasAuthority('SCOPE_read')")
    fun getOrganization(
        @AuthenticationPrincipal principal: OAuth2AuthenticatedPrincipal
    ) {
        println(principal)
        return ResponseEntity.ok("hoge")
    }
}

実行して試してみる

./gradlew bootRun を実行してローカルで起動してみます。まずは、Access Tokenなしでリクエストしてみます。

curl --verbose http://localhost:8080/private/hoge
*   Trying 127.0.0.1:8080...
* Connected to localhost (127.0.0.1) port 8080 (#0)
> GET /private/hoge HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.85.0
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 401
< Set-Cookie: JSESSIONID=EC5DBAF42E6C0409E9A85D685F5ABE6A; Path=/; HttpOnly
< WWW-Authenticate: Bearer
< X-Content-Type-Options: nosniff
< X-XSS-Protection: 0
< Cache-Control: no-cache, no-store, max-age=0, must-revalidate
< Pragma: no-cache
< Expires: 0
< X-Frame-Options: DENY
< Content-Length: 0
< Date: Wed, 01 Mar 2023 08:46:33 GMT
<
* Connection #0 to host localhost left intact

保護が効いていて401が返ってきています。また、 WWW-Authenticate: Bearer が返ってきているので、Barrerトークンでリクエストすれば良いことがわかります。

次に、Headerに認可サーバーから取得したアクセストークンをBearer として指定してリクエストしてみます。

curl 'http://localhost:8080/private/hoge' \
      --header 'Authorization: Bearer xxx' \
      --header 'Content-Type: application/json'
hoge

レスポンスが返却されました。内部的にはトークンをイントロスペクションAPIに問い合わせて、 active:true が返却されたらリクエストができるという方式です。また、 scope: read のスコープが含まれている必要があります。

次に、permitAllに指定した actuator/health については、アクセストークンなしでリクエストできることが確認できます。

curl --verbose http://localhost:8080/actuator/health
*   Trying 127.0.0.1:8080...
* Connected to localhost (127.0.0.1) port 8080 (#0)
> GET /actuator/health HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.85.0
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200
< X-Content-Type-Options: nosniff
< X-XSS-Protection: 0
< Cache-Control: no-cache, no-store, max-age=0, must-revalidate
< Pragma: no-cache
< Expires: 0
< X-Frame-Options: DENY
< Content-Type: application/vnd.spring-boot.actuator.v3+json
< Transfer-Encoding: chunked
< Date: Wed, 01 Mar 2023 09:14:48 GMT
<
* Connection #0 to host localhost left intact
{"status":"UP"}⏎

Introspection Endpointのリクエストレスポンスをカスタマイズする

RFCの仕様に沿ったIntrospection Endpointなら良いですが、自前実装の認可サーバーを使うときは独自仕様のレスポンスやリクエストの方式が違う場合もあります。例えば、RFCだとリクエストは application/json ではなく application/x-www-form-urlencoded を使うことになっていますが、認可サーバーが application/json を期待した場合は Spring Security OAuth2 Resource Server のデフォルトをそのまま使うとうまくいかなくなります。イントロスペクションリクエストのデフォルトは NimbusOpaqueTokenIntrospector という実装を使っています。このクラスはリクエストの方法を外部から指定できるメソッドをがあるのでここでカスタマイズします。また、レスポンスのカスタマイズもできます。

例えば、リクエストを application/json にし、 独自仕様の role というレスポンスがあることを考えると、以下のようなクラスを作成することで対処できます。

独自レスポンスの情報を PreAuthorize の 認可に使用するためには、 SimpleGrantedAuthoririty として返すことで可能になります。

class CustomOpaqueTokenIntrospector(
    private val introspectUri: String,
    clientId: String,
    clientSecret: String
) : OpaqueTokenIntrospector {
    private val delegate: NimbusOpaqueTokenIntrospector = NimbusOpaqueTokenIntrospector(introspectUri, clientId, clientSecret)

    override fun introspect(token: String): OAuth2AuthenticatedPrincipal {
        delegate.setRequestEntityConverter(requestEntityConverter(URI(introspectUri)))

        val principal: OAuth2AuthenticatedPrincipal = delegate.introspect(token)
        val roles = principal.attributes["role"] as Collection<*>

        return DefaultOAuth2AuthenticatedPrincipal(
            principal.attributes,
            roles.map { SimpleGrantedAuthority(it as String) }
        )
    }

    private fun requestEntityConverter(introspectionUri: URI): Converter<String?, RequestEntity<*>> {
        return Converter { token: String? ->
            val body = CustonItrospectRequest(token)
            RequestEntity.post(introspectionUri).contentType(MediaType.APPLICATION_JSON).body(body)
        }
    }
}

data class CustonItrospectRequest(
    val token: String?
)

あとは、このクラスを ConfigurationのクラスでBeanとして指定するだけです。

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
class SecurityConfig(val appConfig: AppConfig) {

        @Bean
    fun opaqueTokenIntrospector(): OpaqueTokenIntrospector = CustomAuthoritiesOpaqueTokenIntrospector(appConfig.introspectionUri, appConfig.clientId, appConfig.clientSecret)

    @Bean
    fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
        http {
            authorizeHttpRequests {
                authorize("/actuator/health", permitAll)
                authorize(anyRequest, authenticated)
            }
            csrf { disable() }
            httpBasic { disable() }
            logout { disable() }
            oauth2ResourceServer { opaqueToken { } }
        }

        http.formLogin().disable()
        return http.build()
    }
}

まとめ

Spring Security 6.x 現在の書き方で OAuth2 Resouce Serverを実装してみました。Spring Bootも3.xになったばかりでSpring Security 6.x の情報もまだネット上に少ないので、特にカスタマイズ方法についてハマりました。公式のドキュメントもまだ整備されているとは言えないので、今のところはフレームワークの実装を追うしかないですね。誰かの参考になれば幸いです。

参考