SpringSecurityによるLINEログインをローカル環境だけで動作させる

以前、SpringBoot及びSpringSecurityを使ってLINEログインを実現方法についての記事を書きました。

SpringSecurityでLINEログインを実現する | Techno-core Blog

SpringBootで構築したWebアプリに対して、SpringSecurityによるLINEログインを実装した際の学び、及び備忘録です。 ※SpringBootに関しては簡単に解説している部分もありますが、最低限の触れ

https://blog.techno-core.jp/posts/line-login-by-spring-security

SSOでログインを実装するとユーザー観点では余計なアカウントの管理がなくなって便利ですが、開発者としてはローカル環境だけで開発を完結させたり、ログイン周りのテストを自動化することが難しくなります。実際のサービスには接続しない状態で、ローカル環境だけでSSOが実現できるようになると便利です。

この記事では、SpringSecurityを使って実装したLINEログインをローカル環境だけで動作させるようにするための構築方法を紹介します。

LINEログインだけに限らず、OIDCを使ったSSOをローカル環境だけで構築してテストを自動化したりCIに組み込む際にも応用可能ですので、参考になれば幸いです。

環境

使用するSpringBootのプロジェクトは前回の記事で作成したものをそのまま使用します。
手を動かしながら試したい場合は、前回の記事を参照してSpringBootのプロジェクトを用意してLINEログインを実装しておいてください。

今回は、SpringBootのWebアプリに加えて以下のツールを使用します。

  • Wiremock
  • Docker

Wiremockについての詳細な説明は割愛しますが、ざっくり説明するとAPIのモックを作成することができるツールで、外部サービスを使用するWebアプリのテストする際に、外部サービスに見立てたモックが簡単に構築できるJavaベースのフレームワークです。

WireMock - flexible, open source API mocking

WireMock is a tool for building mock APIs. API mocking enables you build stable, predictable development environments when the APIs you depend on are unreliable or don’t exist.

https://wiremock.org/

WiremockはJVM上で動作しますが、今回はDockerイメージを使用するので、別途インストールする必要はありません。
Dockerをインストールしてdockerコマンドが使える状態にしておいてください。

構築の流れ

ローカルでLINEログインを実現できるようになるまでの流れは以下になります。

  • Wiremockのレスポンスを拡張するjarファイルの作成
  • Dockerイメージの作成
  • mappingsファイルの作成
  • コンテナの起動

作業に入る前に、Wiremockのスタブについて簡単に整理しておきます。

Wiremockによるスタブの作成

Wiremockを使うと、特定のHTTPリクエストが送られてきた際に、あらかじめ決められたレスポンスを返すようにスタブを作成することができます。以下のようなjsonファイルをmappingsディレクトリ配下においておくことで、サーバー起動時に自動的にスタブとして設定してくれます。

{
  "request": {
    "urlPathTemplate": "/contacts/{contactId}/addresses/{addressId}"
    "method" : "GET"
  },
  "response" : {
    "status" : 200
  }
}
Request Matching

WireMock supports matching of requests to stubs and verification queries using the following attributes.

https://wiremock.org/docs/request-matching/

この例ではステータスコード200を返しているだけですが、レスポンスヘッダやレスポンスボディも設定することができます。

しかし、このようなスタブでは静的な値を返すことしかできません。ですが、実際のWebアプリケーションを想定した場合、リクエストの内容に応じて動的な値をレスポンスしたい場合もあるでしょう。

そのような場合、WiremockではTransforming Responsesという仕組みが用意されており、ResponseDefinitionTransformerV2インターフェースを実装したクラスを定義し、transformメソッドをオーバーライドすることで、レスポンスをカスタマイズすることができます。

public static class ExampleTransformer implements ResponseDefinitionTransformerV2 {

        @Override
        public ResponseDefinition transform(ServeEvent serveEvent) {
            return new ResponseDefinitionBuilder()
                    .withHeader("MyHeader", "Transformed")
                    .withStatus(200)
                    .withBody("Transformed body")
                    .build();
        }

        @Override
        public String getName() {
            return "example";
        }
    }
}

作成したTransformerを使用してレスポンスを返す場合は、スタブで使用したいTransformerを指定します。

{
    "request": {
        "method": "GET",
        "url": "/local-transform"
    },
    "response": {
        "status": 200,
        "body": "Original body",
        "transformers": ["my-transformer", "other-transformer"]
    }
}
Transforming Responses

Transforming response definitions and responses via extensions

https://wiremock.org/docs/extensibility/transforming-responses/

LINEログインなどのOIDCでは、セキュリティ対策としてリクエストにstatenonceといったランダム文字列を生成し、リクエストに含めます(参考)。そして、プロバイダー(LINE)側は、nonceの値を使って署名したIDトークンをレスポンスとして返します。

SpringSecurityを使ってOIDCを実装する場合、statenonceの値は毎回フレームワークが自動でランダムに生成します。そのため、Wiremockを用いてランダムに生成された値を元にしたIDトークンを動的にレスポンスするには、Transformerを使う必要があります。

Wiremockのサーバーを起動させる方法は、大きく2つあります。
1つは、Wiremockを組み込みで使用する場合(MavenやGradleに依存関係を追加して、アプリからMockサーバーを起動する方式)で、もう一つはDockerイメージから起動する方法です。
Wiremockを組み込みで起動する方式であれば、単にTransformerのクラスを作成すればよいですが、WiremockをDockerコンテナ上で動作させる場合、拡張したTransformerを使うにはビルドしたjarファイルをコンテナに含めなければなりません。そのため、Dockerを使用する場合の方が手間がかかるのですが、CIへの組み込みや諸々の使い勝手の良さを考えると、Dockerを使ってスタンドアロンでWiremockを起動させる方が汎用性が高いので、今回はDockerに対応した方法を取ります。

Wiremockのレスポンスを拡張するjarファイルの作成

Transfomer用のクラスのjarファイルを作成するために、Webアプリとは別で独立したJava、またはKotlinのモジュールを作成します。ここではKotlinでMavenプロジェクトを作成することにします。プロジェクト名は任意ですが、ここではwiremock-extensionとします。

まずはpom.xmlを作成します。
ポイントは、Wiremockの拡張jarはJavaのバージョン11でコンパイルする必要があったので、pluginでバージョンを指定。
また、kotlinでコードを書く場合は、kotlinの標準ライブラリもWiremock側でjarとして読み込む必要があるため、そのための設定をmaven-shade-pluginとして追加しています。

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>org.example</groupId>
    <artifactId>wiremock-extension</artifactId>
    <version>1.0-SNAPSHOT</version>
    <packaging>jar</packaging>
    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <kotlin.code.style>official</kotlin.code.style>
        <kotlin.compiler.jvmTarget>1.8</kotlin.compiler.jvmTarget>
    </properties>

    <repositories>
        <repository>
            <id>mavenCentral</id>
            <url>https://repo1.maven.org/maven2/</url>
        </repository>
    </repositories>

    <build>
        <sourceDirectory>src/main/kotlin</sourceDirectory>
        <plugins>
            <plugin>
                <groupId>org.jetbrains.kotlin</groupId>
                <artifactId>kotlin-maven-plugin</artifactId>
                <version>2.1.20</version>
                <executions>
                    <execution>
                        <id>compile</id>
                        <phase>compile</phase>
                        <goals>
                            <goal>compile</goal>
                        </goals>
                    </execution>
                    <execution>
                        <id>test-compile</id>
                        <phase>test-compile</phase>
                        <goals>
                            <goal>test-compile</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
            <plugin>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>2.22.2</version>
            </plugin>
            <plugin>
                <artifactId>maven-failsafe-plugin</artifactId>
                <version>2.22.2</version>
            </plugin>
            <plugin>
                <groupId>org.codehaus.mojo</groupId>
                <artifactId>exec-maven-plugin</artifactId>
                <version>1.6.0</version>
                <configuration>
                    <mainClass>MainKt</mainClass>
                </configuration>
            </plugin>
            <plugin>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.11.0</version>
                <configuration>
                    <release>11</release>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-shade-plugin</artifactId>
                <version>3.5.0</version>
                <executions>
                    <execution>
                        <phase>package</phase>
                        <goals><goal>shade</goal></goals>
                        <configuration>
                              <createDependencyReducedPom>true</createDependencyReducedPom>
                            <shadedArtifactAttached>true</shadedArtifactAttached>
                            <shadedClassifierName>all</shadedClassifierName>
                            <filters>
                                <filter>
                                    <artifact>*:*</artifact>
                                    <!-- 署名ファイルは除外 -->
                                    <excludes>
                                        <exclude>META-INF/*.SF</exclude>
                                        <exclude>META-INF/*.DSA</exclude>
                                        <exclude>META-INF/*.RSA</exclude>
                                    </excludes>
                                </filter>
                            </filters>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
  
    <dependencies>
        <dependency>
            <groupId>org.jetbrains.kotlin</groupId>
            <artifactId>kotlin-test-junit5</artifactId>
            <version>2.1.20</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <version>5.10.0</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.jetbrains.kotlin</groupId>
            <artifactId>kotlin-stdlib</artifactId>
            <version>2.1.20</version>
        </dependency>
        <dependency>
            <groupId>org.wiremock</groupId>
            <artifactId>wiremock</artifactId>
            <version>3.10.0</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>com.nimbusds</groupId>
            <artifactId>nimbus-jose-jwt</artifactId>
            <version>10.5</version>
        </dependency>
        <dependency>
            <groupId>org.bouncycastle</groupId>
            <artifactId>bcpkix-jdk15on</artifactId>
            <version>1.70</version>
        </dependency>
        <dependency>
            <groupId>org.json</groupId>
            <artifactId>json</artifactId>
            <version>20241224</version>
        </dependency>
    </dependencies>

</project>

次に、Transformerのクラスを作成します。

Transformer.kt

package org.example  
  
import com.github.tomakehurst.wiremock.client.ResponseDefinitionBuilder  
import com.github.tomakehurst.wiremock.extension.ResponseDefinitionTransformerV2  
import com.github.tomakehurst.wiremock.http.ResponseDefinition  
import com.github.tomakehurst.wiremock.stubbing.ServeEvent  
import com.nimbusds.jose.JOSEObjectType  
import com.nimbusds.jose.JWSAlgorithm  
import com.nimbusds.jose.JWSHeader  
import com.nimbusds.jose.crypto.MACSigner  
import com.nimbusds.jwt.JWTClaimsSet  
import com.nimbusds.jwt.SignedJWT  
import java.util.Date  
  
class IdTokenTransformer(): ResponseDefinitionTransformerV2 {  
  
    override fun getName(): String = "id-token-transformer"  
  
    override fun transform(serveEvent: ServeEvent): ResponseDefinition? {  
        try {  
            val form = serveEvent.request.bodyAsString // "grant_type=authorization_code&code=....&..."  
            val code = form.split("&").firstOrNull { it.startsWith("code=") }?.substringAfter("=") ?: ""  
  
            val issuer = env("OIDC_ISSUER") ?: "http://127.0.0.1:8081"  
            val clientId = env("OIDC_CLIENT_ID") ?: "1234567890"  
            val secret = (env("OIDC_CLIENT_SECRET") ?: "12345678901234567890123456789012").toByteArray()  
            val kid = env("OIDC_JWK_KID") ?: "test-kid-1"  
  
            // --- JWT Claims ---  
            val now = Date()  
            val exp = Date(now.time + 3600_000) // 1h  
            val claims = JWTClaimsSet.Builder()  
                .issuer(issuer)  
                .audience(clientId)  
                .subject("sub-12345")  
                .issueTime(now)  
                .expirationTime(exp)  
                .claim("nonce", code)  // nonce  
                .claim("email", "test@example.com")  
                .claim("name", "Test User")  
                .build()  
  
            // --- Header: HS256  ---  
            val headerB = JWSHeader.Builder(JWSAlgorithm.HS256).type(JOSEObjectType.JWT)  
            if (kid.isNotBlank()) headerB.keyID(kid)  
            val header = headerB.build()  
  
            // --- Sign with HMAC (HS256) ---  
            val signer = MACSigner(secret)  
            val jwt = SignedJWT(header, claims).apply { sign(signer) }.serialize()  
  
            // OAuth2 Token Response  
            val json =  
                """{"access_token":"at-123","token_type":"Bearer","expires_in":3600,"id_token":"$jwt"}"""  
  
            return ResponseDefinitionBuilder()  
                .withStatus(200)  
                .withHeader("Content-Type", "application/json")  
                .withBody(json)  
                .build()  
        } catch (e: Exception) {  
            return ResponseDefinitionBuilder()  
                .withStatus(500)  
                .withHeader("Content-Type", "application/json")  
                .withBody("""{"error":"hs256-transformer-failed","detail":"${e.message}"}""")  
                .build()  
        }  
    }  
  
    override fun applyGlobally(): Boolean = false  
  
    private fun env(name: String): String? =  
        System.getenv(name)?.takeIf { it.isNotBlank() }  
  
}

作成するクラスはこれだけです。
ポイントはcodeの変数の値です。リクエストが送られてきた際にSpringSecurity側で生成されたランダムな値がcodeパラメータとして送られてくるので、その値を取得しています。

署名されたITトークンを作成するにあたり、cliendIdやclientSecretなどが必要になりますが、これらの値は環境変数から取得できるようにしておきます。

WebアプリによるLINEログインでは、署名のアルゴリズムとしてHS256が使用されているので(前回の記事を参照)、HS256のアルゴリズムを使用して、ITトークンを生成してレスポンスします。

クラスの定義が終わったら、mvnコマンドを使ってビルドします。

mvn package -DskipTests

targetディレクトリが作成され、wiremock-extension-1.0-SNAPSHOT.jarwiremock-extension-1.0-SNAPSHOT-all.jarの2つのjarファイルが作成されていればOKです。

Dockerイメージの作成

今回作成したjarファイルを含んだWiremockのDockerイメージを作成するため、プロジェクト内にDockerfileを作成します。
内容は以下になります。コピー元のパスはDockerfileを作成した場所に応じて適宜変更してください。

FROM wiremock/wiremock:latest  
COPY target/wiremock-extension-1.0-SNAPSHOT.jar /var/wiremock/extensions/id-token-transformer.jar  
COPY target/wiremock-extension-1.0-SNAPSHOT-all.jar /var/wiremock/extensions/wiremock-extension-1.0.0-all.jar

Dcokerfileのあるディレクトリで、以下のコマンドでDockerイメージを作成します。

docker build -t wiremock-extension-oidc .

最終的には以下のディレクトリ構成になっている想定です。

wiremock-extension: プロジェクトルート
|-src
   |-main
      |-kotlin
           |- org
               |-example
                    |- IdTokenTransformer.kt
|-target
    |- wiremock-extension-1.0-SNAPSHOT.jar
    |- wiremock-extension-1.0-SNAPSHOT-all.jar
|-pom.xml
|-Dockerfile

Dockerイメージまで作成できれば、このMavenプロジェクトの役割はここで終わりです。

mappingsの作成

続いては、Wiremockのスタブを作成します。
ここではLINEログインを実装しているアプリ側のプロジェクトに作成することにします。プロジェクトのディレクトリの配下に、wiremock/mappingsディレクトリを作成します。
その中に、必要なjsonファイルを作成します。
ここでは、4つのjsonファイルを作成します。

line-login-sample
|- src
|- wiremock
      |- mappings
            |- authorize.json
            |- discovery.json
            |- token.json
            |- userinfo.json

まず1つ目は、discovery.json。
これは、issuerのURLを元に、エンドポイントの情報などのレスポンスを返すスタブです。issur_url/.well-known/openid-configurationにリクエストが送られてきた場合のスタブになります。

discovery.json

{  
  "request": { "method": "GET", "url": "/.well-known/openid-configuration" },  
  "response": {  
    "status": 200,  
    "headers": { "Content-Type": "application/json" },  
    "jsonBody": {  
      "issuer": "http://127.0.0.1:8081",  
      "authorization_endpoint": "http://127.0.0.1:8081/authorize",  
      "token_endpoint": "http://127.0.0.1:8081/token",  
      "revocation_endpoint": "http://127.0.0.1:8081/revoke",  
      "userinfo_endpoint": "http://127.0.0.1:8081/userinfo",  
      "scopes_supported": ["openid", "profile", "email"],  
      "jwks_uri": "http://127.0.0.1:8081/jwks",  
      "response_types_supported": ["code"],  
      "subject_types_supported": ["pairwise"],  
      "id_token_signing_alg_values_supported": ["ES256"],  
      "code_challenge_methods_supported": ["S256"]  
    }  
  }  
}

2つ目はauthorize.json。
これは、/authorizeのリクエストに対するスタブで、LINEログインボタンを押したときに呼ばれます。ここで、noncestateはリクエストとして送られてきた値をそのまま使って、リダイレクトURLを作ってリダイレクトさせます。

authorize.json

{  
  "request": {  
    "method": "GET",  
    "urlPath": "/authorize",  
    "queryParameters": {  
      "response_type": {  
        "equalTo": "code"  
      },  
      "client_id": {  
        "matches": ".*"  
      },  
      "redirect_uri": {  
        "matches": ".*"  
      },  
      "scope": {  
        "contains": "openid"  
      },  
      "state": {  
        "matches": ".+"  
      }  
    }  
  },  
  "response": {  
    "status": 302,  
    "headers": {  
      "Location": "http://127.0.0.1:8080/login/oauth2/code/line?code={{request.query.nonce}}&state={{request.query.state}}"  
    },  
    "transformers": ["response-template"]  
  }  
}

3つ目は、/tokenのリクエストが呼ばれたときのスタブ。ここで、先に作っておいたTransformerの名前を指定する。これにより、IDトークンが返されるようになります。

token.json

{  
  "request": { "method": "POST", "url": "/token" },  
  "response": {  
    "transformers": ["id-token-transformer"]  
  }  
}

4つ目は、認証が完了した後に、ユーザー情報を取得する際のスタブ。ここは必要に応じて情報を追加したり編集したりしてください。

userinfo.json

{  
  "request": { "method": "GET", "url": "/userinfo" },  
  "response": {  
    "status": 200,  
    "headers": { "Content-Type": "application/json" },  
    "jsonBody": { 
	    "sub":"sub-12345", 
	    "name":"Test User", 
	    "email":"test@example.com" 
	    }  
  }  
}

コンテナの起動

最後に、docker-compose.yamlを作成してコンテナを起動させます。先に作成しておいたwiremock-extension-oidcのDockerイメージを指定します。また、commandで、--extensionsを指定し、Transformerとして使用するクラスを指定します。

環境変数の値のCLIENT_IDやCLIENT_SECRETは、アプリ側の設定値と合わせるようにしてください。

docker-compose.yaml

version: '3.8'  
  
services:  
  wiremock:  
    image: wiremock-extension-oidc  
    container_name: line-login-wiremock  
    ports:  
      - "8081:8080"  
    command:  
      - "--global-response-templating"  
      - "--extensions"  
      - "org.example.IdTokenTransformer"  
      - "--verbose"  
    volumes:  
      - ./wiremock:/home/wiremock  
    environment:  
      OIDC_CLIENT_ID: "1234567890"  
      OIDC_CLIENT_SECRET: "xxxxxxxxxxyyyyyyyyyyzzzzzzzzzz12"  
      OIDC_ISSUER: "http://127.0.0.1:8081"  
      OIDC_JWK_KID: "test-kid-1"

アプリケーション側の設定ファイルは以下のようなイメージです。

application-local.yaml

spring:  
  application:  
    name: lineLoginSample  
  security:  
    oauth2:  
      client:  
        provider:  
          line:  
            issuer-uri: http://127.0.0.1:8081  
        registration:  
          line:  
            client-id: 1234567890  
            client-secret: xxxxxxxxxxyyyyyyyyyyzzzzzzzzzz12

最終的にディレクトリ構成は以下になっている想定です。

line-login-sample
|- src:アプリのコード格納先
|- wiremock
      |- mappings
            |- authorize.json
            |- discovery.json
            |- token.json
            |- userinfo.json
|- docker-compose.yaml

あとは、コンテナを起動します。

docker compose up -d

SpringBootのアプリも起動し、LINEログインボタンから、ログイン成功の画面に遷移すれば成功です。

OIDCの流れの整理

ざっくりとOIDCの流れとしては以下のようになっています。

プロバイダ(LINE、今回はWiremock)                                  SpringBoot
| <---- request  : issuer_url/.well-known/openid-configuration |:アプリ起動時
|       response : discovery.json  ----------------------->    |:endpointを保持
| <---- request  : /authorize?...                              |:LINEログイン押下時
|       response : authorize.json  ----------------------->    |:stateの検証
| <---- request  : /token?...                                  |
|       response : token.json      ----------------------->    |:トークンの検証
| <---- request  :  /useringo                                   |
|       response : useringo.json    ----------------------->    |

SpringSecurityを使って実装しておけば、Webアプリを起動した際に設定ファイルのissuer_urlから、各種エンドポイントを取得します。LINEログインボタン押下時には/authorizeのエンドポイントにリクエストをなげ、その際にstatenonce(code)は自動でランダムな値を生成してくれます。その後の検証周りも、フレームワークが自動で実現してくれます。

LINEの場合、署名のアルゴリズムにHS256方式(共通鍵方式)を使うため、クライアントシークレットの値を使ってトークンの検証ができますが、これがRS256のような、公開鍵を使う方式の場合は、/tokenのリクエストの後に/jwksのリクエストを投げて、公開鍵を取得するフローが追加されます。

感想

今回、ローカルでのLINEログインを実装をするにあたって、実現するまでにAIを使いながらかなり試行錯誤しました。最終的には思ったよりもシンプルに実現することができたつもりですが、この段階に持ってくるまでにかなり苦戦しました。

試行錯誤している途中の段階では、実現したいことと、かかった労力が見合わないなと思う部分もありました。
しかし、実現できるようになって、試行錯誤の過程を振り返る中で様々な知識不足を認識することできて、新しい知識を増やすことができました。具体的には以下のような知識。

  • OIDCによる認証の流れ
    • IDトークンの署名の仕方
    • 署名時の暗号鍵方式の違いによる認証の流れの違い
  • 署名のアルゴリズムの違い
  • Mavenでのサブモジュールの作り方
  • Mavenでのビルドに関する知識
    • Javaコンパイラのバージョンの指定や、システムのライブラリも同時にjarにする方法など
  • Wiremockによるカスタムレスポンスの作り方や設定方法

結果として得られた知識は多く、特にWiremockのカスタムレスポンスに関しては、APIのテストを自動化する際に役立ちそうな知識だなと思います。

また、OIDCの流れを知ったことで、フレームワークの便利さをより実感することが出来ました。