Spring Boot アプリにサインインを追加する

このクイックスタートでは、Spring Boot 4.1 アプリに、Spring エコシステムの標準的な OpenID Connect クライアントである Spring Security の OAuth 2.0 クライアントを使って KeenAuth のサインインを追加します。KeenAuth にはまだ Java SDK がなく、必要もありません。KeenAuth は標準的な OpenID Connect プロバイダです。アプリが OAuth クライアントとなり、すべてのトークンをサーバー側のセッションに保持します。ブラウザが受け取るのはセッション ID の Cookie だけです。サインインの設定、ページの保護、ユーザーのアクセストークンを使った保護された API の呼び出し、サインアウト、そしてオフラインでのアプリのテストを行います。

アプリには次のルートがあります。

ルート 役割
GET / 公開のホームページ。サインインのリンク、またはユーザー名とサインアウトボタンを表示します
GET /dashboard セッションが必要です。ユーザーと、API が返すメッセージを表示します
GET /oauth2/authorization/keenauth Spring Security が提供します。サインインを開始します
GET /login/oauth2/code/keenauth Spring Security が提供します。コールバックです
POST /logout Spring Security が提供します。ここと KeenAuth の両方でセッションを終了します。CSRF トークンが必要です

始める前に

1. 依存関係を追加する

Spring Boot の親 POM(spring-boot-starter-parent 4.1.2)がすべてのバージョンを固定します。Web、OAuth 2.0 クライアント、Thymeleaf のスターターを追加します。

    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-webmvc</artifactId>
      <exclusions>
        <!-- Logback is EPL-1.0 OR LGPL-2.1, outside the licence allow list: Log4j 2 (Apache-2.0) logs instead. -->
        <exclusion>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-logging</artifactId>
        </exclusion>
        <!-- EPL-2.0 OR GPL-2.0 with Classpath Exception, outside the allow list; this app uses none of its annotations. -->
        <exclusion>
          <groupId>jakarta.annotation</groupId>
          <artifactId>jakarta.annotation-api</artifactId>
        </exclusion>
      </exclusions>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-log4j2</artifactId>
    </dependency>
    <!-- Spring Security's OAuth 2.0 client: OpenID Connect sign-in with discovery, PKCE and ID token checks. -->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-oauth2-client</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-thymeleaf</artifactId>
    </dependency>

このサンプルでは、ライセンスがサンプルのライセンス許可リストの対象外である 2 つの依存関係を置き換えています。Logback(EPL-1.0 または LGPL-2.1)は Log4j 2 に、jakarta.annotation-api(EPL-2.0 または Classpath Exception 付き GPL-2.0)は、このアプリが使用していないため除外しています。同じ理由で、テストでは JUnit(EPL-2.0)ではなく TestNG を使用しています。ご自身のアプリでこれを行う必要はありません。

2. 設定する

アプリは設定をすべて環境変数から読み取ります。その値をコミットしないでください。

変数 必須 意味
KEENAUTH_ISSUER はい 環境の発行者(issuer)。https
KEENAUTH_CLIENT_ID はい Web アプリケーションの client_id
KEENAUTH_CLIENT_SECRET はい そのクライアントシークレット
APP_BASE_URL はい アプリの公開 URL。リダイレクト URI はこれから組み立てられます。http は localhost、127.0.0.1、[::1] の場合のみ使用できます
API_URL はい アプリが呼び出す API。例:http://localhost:3001
KEENAUTH_API_AUDIENCE はい KeenAuth における、その API の識別子
SESSION_SECURE_COOKIE いいえ 単純な http://localhost の場合のみ false

KeenAuthSettings.from は起動時にこれらの値を読み取って検証します。値が欠けている、または使用できない場合はアプリが停止し、どのエラーメッセージにも値は含まれません。

	public static KeenAuthSettings from(Function<String, String> env) {
		return new KeenAuthSettings(url(need(env, "KEENAUTH_ISSUER"), "KEENAUTH_ISSUER", true, false),
				need(env, "KEENAUTH_CLIENT_ID"), need(env, "KEENAUTH_CLIENT_SECRET"),
				url(need(env, "APP_BASE_URL"), "APP_BASE_URL", false, false),
				url(need(env, "API_URL"), "API_URL", false, false),
				// RFC 8707: the access token is requested for your API, which then accepts it as its audience.
				url(need(env, "KEENAUTH_API_AUDIENCE"), "KEENAUTH_API_AUDIENCE", false, true));
	}

3. クライアントを登録する

クライアントの登録情報は、Spring Security が起動時に取得するディスカバリ(<issuer>/.well-known/openid-configuration)から得られます。このサンプルでは、クライアントの資格情報、リダイレクト URI、スコープを設定し、この機密クライアントに対して PKCE を有効にしています。

	/** The endpoints and the signing keys come from discovery: <issuer>/.well-known/openid-configuration. */
	@Bean
	ClientRegistrationRepository clientRegistrationRepository(KeenAuthSettings settings) {
		return new InMemoryClientRegistrationRepository(
				registration(ClientRegistrations.fromIssuerLocation(settings.issuer()), settings));
	}

	static ClientRegistration registration(ClientRegistration.Builder discovered, KeenAuthSettings settings) {
		return discovered.registrationId("keenauth")
			.clientId(settings.clientId())
			.clientSecret(settings.clientSecret())
			.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
			.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
			.redirectUri(settings.redirectUri())
			// messages:read is a scope of your API; the example API requires it on /api/messages.
			.scope("openid", "profile", "email", "messages:read")
			// PKCE (RFC 7636) with S256 for this confidential client too; state and nonce are always on.
			.clientSettings(ClientRegistration.ClientSettings.builder().requireProofKey(true).build())
			.build();
	}

4. サインイン、API のオーディエンス、サインアウトを設定する

認可リクエストのリゾルバは、API の識別子を resource(RFC 8707)として追加します。これがないと、アクセストークンはご自身の API 向けにならず、API はそのトークンを拒否します。サインアウトは、CSRF トークン付きの Spring Security の POST /logout で行い、その後、id_token_hint と post_logout_redirect_uri を使った OpenID Connect RP-Initiated Logout を行います。

		DefaultOAuth2AuthorizationRequestResolver resolver = new DefaultOAuth2AuthorizationRequestResolver(clients,
				"/oauth2/authorization");
		// RFC 8707: an access token for your API.
		resolver.setAuthorizationRequestCustomizer(
				(request) -> request.additionalParameters((params) -> params.put("resource", settings.apiAudience())));

		OidcClientInitiatedLogoutSuccessHandler logout = new OidcClientInitiatedLogoutSuccessHandler(clients);
		logout.setPostLogoutRedirectUri(settings.postLogoutRedirectUri());

		http.authorizeHttpRequests((authorize) -> authorize.requestMatchers("/", "/error").permitAll()
			.anyRequest()
			.authenticated())
			.oauth2Login((login) -> login.authorizationEndpoint((endpoint) -> endpoint.authorizationRequestResolver(resolver)))
			// POST /logout with the CSRF token; then OpenID Connect RP-Initiated Logout with id_token_hint.
			.logout((out) -> out.logoutSuccessHandler(logout));
		return http.build();

Spring Security は、コールバックで state を確認し、PKCE の verifier を使ってコードを交換し、ID トークンを検証します(環境の鍵による署名、RS256、iss、aud、exp、iat、nonce)。サインイン後には、ユーザーに新しいセッション ID が発行されます。セッションはサーバー上に保持されます。複数のサーバーで運用する場合は、共有ストアを使う Spring Session を利用してください。

ホームページはサインインへのリンクを表示し、サインアウトへ POST します。th:action により CSRF トークンが追加されます。

      <!-- th:action adds the CSRF token. -->
      <form th:action="@{/logout}" method="post"><button type="submit">Sign out</button></form>
    </div>
    <p th:unless="${user}"><a href="/oauth2/authorization/keenauth">Sign in</a></p>

5. API を呼び出す

ダッシュボードにはセッションが必要です。Spring Security がセッションに保持している認可済みクライアントからアクセストークンを取り出し、それを使ってご自身の API を呼び出します。トークンはサーバー上に留まり、ブラウザが受け取るのは HTML だけです。

	/** Needs a session (SecurityConfig). The access token stays on the server; the browser receives only the HTML. */
	@GetMapping("/dashboard")
	public String dashboard(@AuthenticationPrincipal OidcUser user,
			@RegisteredOAuth2AuthorizedClient("keenauth") OAuth2AuthorizedClient client, Model model) {
		ResponseEntity<String> response = this.messages.fetch(client.getAccessToken().getTokenValue());
		model.addAttribute("user", user);
		model.addAttribute("status", response.getStatusCode().value());
		model.addAttribute("messages", response.getStatusCode().is2xxSuccessful() ? response.getBody() : null);
		return "dashboard";
	}
	public ResponseEntity<String> fetch(String accessToken) {
		return this.rest.get()
			.uri("/api/messages")
			.headers((headers) -> headers.setBearerAuth(accessToken))
			.retrieve()
			.onStatus((status) -> true, (request, response) -> {
			})
			.toEntity(String.class);
	}

このサンプルには更新トークンがありません。アクセストークンの有効期限が切れると、ユーザーは再度サインインします。

6. アプリを実行する

Express のクイックスタートの API(またはこれらのトークンを受け付ける任意の API)を起動し、変数をエクスポートした状態でアプリを実行します。

mvn spring-boot:run

http://localhost:8080 を開き、サインイン を選択して、KeenAuth でサインインしてからダッシュボードを開きます。

7. ネットワークなしでテストする

テストにはネットワークも KeenAuth のアカウントも必要ありません。ループバックポート上の小さな HTTP サーバーが、環境(ディスカバリ、テスト実行用に作成した鍵セット、トークンエンドポイント、userinfo)と API の役割を果たします。テストでは、同じ registration メソッドを通じて、そのサーバーから検出したクライアント登録で置き換えます。

	/** The registration of SecurityConfig, discovered from the fake environment instead of the issuer above. */
	@TestConfiguration
	static class FakeDiscovery {

		@Bean
		ClientRegistrationRepository clientRegistrationRepository(KeenAuthSettings settings) {
			return new InMemoryClientRegistrationRepository(
					SecurityConfig.registration(ClientRegistrations.fromIssuerLocation(KEENAUTH.issuer()), settings));
		}

	}
	@Test
	public void callbackWithAnUnknownStateIsRefusedBeforeAnyTokenRequest() throws Exception {
		MockHttpSession session = new MockHttpSession();
		startSignIn(session);
		MvcResult result = returnFromKeenAuth(session, "not-the-state");
		assertEquals(result.getResponse().getRedirectedUrl(), "/login?error");
		assertTrue(KEENAUTH.requestsTo("/oauth2/token").isEmpty());
mvn dependency:list -DoutputFile=target/dependencies.txt
python3 ../scripts/maven_deps.py target/dependencies.txt    # SHA-256 lock and licences
mvn test

テストでは次の内容を確認します。設定が起動時に検証されること(どのエラーにもシークレットが含まれないこと)。サインインで PKCE S256、state、nonce、resource が送られること。コードが対応する verifier とクライアント認証によって交換されること。アクセストークンが Authorization ヘッダーで API に送られ、Cookie には決して保存されないこと。未知の state、別のクライアント向けの ID トークン、別の nonce を持つ ID トークンが、セッションなしで拒否されること。セッションのないページがサインインを開始すること。サインアウトに CSRF トークンが必要で、id_token_hint を付けてログアウトエンドポイントへ向かうこと。

実際のブラウザでの完全なサインインには KeenAuth の環境が必要です。これはオフラインテストには含まれていません。

プロキシやロードバランサーの背後で使う

APP_BASE_URL にアプリの公開 URL を設定してください。リダイレクト URI はこれから組み立てられます。プロキシが TLS を終端する場合にのみ、プロキシがアプリに到達する唯一の経路であるときに限り server.forward-headers-strategy=framework を設定してください。

現在の制限

次のステップ