---
title: Spring BootでMicrosoft Entra ID(旧Azure AD)とSAML2連携してログインするメモ
tags: ["Java", "Spring Boot", "Spring Security", "SAML", "OpenSSL", "Azure AD"]
categories: ["Programming", "Java", "org", "springframework", "security", "saml2"]
date: 2024-09-04T10:47:29Z
updated: 2024-09-04T10:47:29Z
---

Microsoft Entra ID(旧Azure AD)とSpring BootをSAML2連携するメモ。

Entra IDはOIDC Providerの機能もあるので、通常はそちらを使う方が良いです。
[先の記事](/entries/819)でIAM Identity CenterとのSAML2連携を行いましたが、動作比較をするために、あえてEntra IDでもSAML2で連携してみます。

以下は[IAM Identity Centerの場合](/entries/819)とほぼ同じですが、IAM Identity Centerでは機能しなかったシングルログアウトができています。

SAML2連携はSpring Securityで用意されています。
https://docs.spring.io/spring-security/reference/servlet/saml2/index.html


**目次**
<!-- toc -->

### 雛形プロジェクトの作成

まずはSpring Initializrで雛形プロジェクトを作成します。

```bash
curl -s https://start.spring.io/starter.tgz \
       -d artifactId=hello-saml \
       -d baseDir=hello-saml \
       -d packageName=com.example \
       -d dependencies=web,actuator,security,configuration-processor \
       -d type=maven-project \
       -d name=hello-saml \
       -d applicationName=HelloSamlApplication | tar -xzvf -
cd hello-saml
```

SAML2連携を使うために、以下のdependencyを`pom.xml`に追加します。

```xml
		<dependency>
			<groupId>org.springframework.security</groupId>
			<artifactId>spring-security-saml2-service-provider</artifactId>
		</dependency>
```

`spring-security-saml2-service-provider`で使われているライブラリであるOpenSAMLは、[諸事情](https://shibboleth.atlassian.net/wiki/spaces/DEV/pages/1123844333/Use+of+Maven+Central)によりMaven Centralで利用できないため、
以下のMavenレポジトリを追加します。

```xml
<project>
	<!-- ... -->
	<repositories>
		<repository>
			<id>shibboleth</id>
			<url>https://build.shibboleth.net/nexus/content/repositories/releases/</url>
		</repository>
	</repositories>
	<!-- ... -->
</project>
```

SAML2でログインするための設定を`SecurityConfig`に記述します。

```java
cat <<'EOF' > src/main/java/com/example/SecurityConfig.java
package com.example;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

import static org.springframework.security.config.Customizer.withDefaults;

@Configuration(proxyBeanMethods = false)
public class SecurityConfig {
	@Bean
	public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
		return http
				.authorizeHttpRequests(authz -> authz
						.requestMatchers("/error").permitAll()
						.anyRequest().authenticated())
				.saml2Login(withDefaults())
				.saml2Metadata(withDefaults())
				.build();
	}
}
EOF
```

SAML2で認証されたユーザー情報を表示するControllerを作成します。

```java
cat <<'EOF' > src/main/java/com/example/HelloController.java
package com.example;

import java.util.Map;

import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticatedPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class HelloController {
	@GetMapping(path = "/")
	public Map<String, Object> hello(@AuthenticationPrincipal Saml2AuthenticatedPrincipal principal) {
		return Map.of("username", principal.getName(), "attributes", principal.getAttributes());
	}
}
EOF
```

### Identity Provider (Relying Party)の設定

次にEntra IDでIdentity Provider (Relying Party)の設定を行います。

まずはアプリケーションを登録します。"Enterprise applications"画面で"New application"をクリックします。

<img width="1024" alt="image" src="https://github.com/user-attachments/assets/94d7c392-315c-4505-9250-4f9ac348a0d7">

"Create your own application"をクリックします。

<img width="1024" alt="image" src="https://github.com/user-attachments/assets/c59852b9-abf1-4187-9f60-617e06271aaa">

アプリ名は`hello-saml`を記入、"Integrate any other application you don't find int the gallery (Non-gallery)"を選択し、"Create"ボタンをクリックします。

<img width="1024" alt="image" src="https://github.com/user-attachments/assets/5b8f787f-df06-416f-9ee4-8062c71114f6">

"single-sign on method"には"SAML"を選択します。

<img width="1024" alt="image" src="https://github.com/user-attachments/assets/3e6fced9-7bc8-4b66-9ee2-e6bee61222b7">

ここで、"SAML Certificates"で"App Federation Metadata Url"のURLをコピーしてください。

<img width="1024" alt="image" src="https://github.com/user-attachments/assets/76ebda07-f87a-42a6-bc68-33b271419ddd">


このURLを次のように`application.properties`の`spring.security.saml2.relyingparty.registration.<registrationId>.assertingparty.metadata-uri`に設定します。

```properties
METADATA_URL=https://login.microsoftonline.com/****/federationmetadata/2007-06/federationmetadata.xml?appid=****
cat <<EOF > src/main/resources/application.properties
spring.application.name=hello-saml
spring.security.saml2.relyingparty.registration.entraid.entity-id=hello-saml
spring.security.saml2.relyingparty.registration.entraid.assertingparty.metadata-uri=$METADATA_URL
EOF
```

`application.properties`の設定ができたら、一旦アプリケーションを起動してください。

```bash
./mvnw spring-boot:run
```

Service Provider側のメタデータが`/saml2/metadata/<registrationId>`で公開されるので、ダウンロードします。

```bash
curl -s http://localhost:8080/saml2/metadata/entraid -o ~/Downloads/metadata-entraid.xml
```

ダウンロードしたメタデータを"Upload metadata file"でアップロードし、"Add"をクリックします。

<img width="1024" alt="image" src="https://github.com/user-attachments/assets/28210d91-030e-4165-b7f7-a943379d3d0c">

アップロードした情報が"Basic SAML Configuration"に表示されます。"Save"をクリックします。

<img width="1024" alt="image" src="https://github.com/user-attachments/assets/d734f492-632d-4b62-b929-ccb0594bdf9c">

"Test single sign-on with ..."のダイアログは"No, I'll test later"ボタンをクリックしてください。

<img width="1024" alt="image" src="https://github.com/user-attachments/assets/082abdc0-74f9-4722-b9fe-22afa19b3095">


次に、ユーザー・グループをこのアプリケーションにアサインします。"Users adn groups"で"Add user/group"をクリックします。

ユーザーあるいはグループが存在しない場合は、新規作成してください。

<img width="1024" alt="image" src="https://github.com/user-attachments/assets/6f49240c-a294-4c78-9b63-ba8c813e2d47">

任意のユーザーまたはグループをアサインしてください。

<img width="1024" alt="image" src="https://github.com/user-attachments/assets/cc654228-066a-4ac6-8b3a-1aff0cecf04b">

"Assign"ボタンをクリックして設定を保存したら、一度ログアウトします。

<img width="1024" alt="image" src="https://github.com/user-attachments/assets/c9f05fe5-a54e-4b89-b514-b7a0a83ef30c">

ログアウト後、先ほど起動したアプリケーション(http://localhost:8080)にアクセスしてください。

Entra IDのログイン画面にリダイレクトされます。

<img width="1024" alt="image" src="https://github.com/user-attachments/assets/bc21203a-ff7d-463d-8f97-206a7f7789bd">

http://localhost:8080 にリダイレクトされ、ログインユーザー情報が表示されます。

<img width="1024" alt="image" src="https://github.com/user-attachments/assets/8c60cf8a-df36-43d2-858f-562719e46702">

デフォルトのattributesをそのまま使用しても良いですが、

ここではAttributeのマッピングを行ってみます。

"Single sign-on"で"Attributes & Claim"の"Edit"ボタンをクリックしてください。

アプリケーションの"Details"の"Actions"をクリックし、"Edit attribute mappings"を選択してください。

<img width="1024" alt="image" src="https://github.com/user-attachments/assets/b9de2a06-bbce-4e81-9036-a9a8cc9c11bc">

デフォルトのマッピングは次のようになっています。

<img width="1024" alt="image" src="https://github.com/user-attachments/assets/482a5ba4-bb72-4135-ac03-8c488640a9c5">

ここではデフォルトのマッピングを削除し、次のようにマッピングしました。

* `Unique User Identitfier` (ユーザー名として使われる) ... `user.mail`
* `firstName` ... `user.givenname`
* `lastName` ... `user.surname`
* `name` ... `user.principalname`

<img width="1024" alt="image" src="https://github.com/user-attachments/assets/376218b0-25b8-46ef-9818-125dd568cc18">

アプリケーションを再起動して、再度 http://localhost:8080 にアクセスします。今度はマッピングしたattributesが表示されます。

<img width="1024" alt="image" src="https://github.com/user-attachments/assets/9e4f61f9-aaaa-488f-aaee-582284aa6f65">

これでEntra IDのユーザーを使い、SAML2でSpring Bootのアプリにログインできました。

### シングルログアウト

次にシングルログアウトの設定を行います。

`SecurityConfig`に次の設定を追加します。

```java
@Configuration(proxyBeanMethods = false)
public class SecurityConfig {
	@Bean
	public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
		return http
				.authorizeHttpRequests(authz -> authz
						.requestMatchers("/error").permitAll()
						.anyRequest().authenticated())
				.saml2Login(withDefaults())
				.saml2Logout(withDefaults()) // <---
				.saml2Metadata(withDefaults())
				.build();
	}
}
```

シングルログアウトのリクエストは署名が必要となります。次のコマンドで秘密鍵と公開鍵を設定します。

```bash
openssl req -x509 -newkey rsa:4096 -keyout src/main/resources/key.pem -out src/main/resources/cert.pem -sha256 -days 3650 -nodes -subj "/CN=@making/O=LOL.MAKI/C=JP"
```


`application.properties`に次の設定を追記します。

```properties
cat <<'EOF' >> src/main/resources/application.properties
spring.security.saml2.relyingparty.registration.entraid.signing.credentials[0].certificate-location=classpath:cert.pem
spring.security.saml2.relyingparty.registration.entraid.signing.credentials[0].private-key-location.=classpath:key.pem
spring.security.saml2.relyingparty.registration.entraid.singlelogout.binding=post
spring.security.saml2.relyingparty.registration.entraid.singlelogout.response-url={baseUrl}/logout/saml2/slo/{registrationId}
EOF
```

アプリケーションを停止し、再起動します。

```bash
./mvnw spring-boot:run
```

一度、 http://localhost:8080 にアクセスした後、 http://localhost:8080/logout にアクセスしてください。

<img width="1024" alt="image" src="https://github.com/user-attachments/assets/98224aaa-7a7e-4998-8326-d2cbc6b612ce">

"Log Out"ボタンをクリックしてください。

そうするとアプリケーションからログアウトし、Entra IDからもログアウトされます。

<img width="1024" alt="image" src="https://github.com/user-attachments/assets/100934e7-0b16-4846-9781-adca94713c39">


再度 http://localhost:8080 にアクセスするとEntra IDの再ログインが求められます。

### Attributesによる認可

SAML2でログインしたユーザー情報にはデフォルトで`ROLE_USER` authorityがついています。
[ドキュメント](https://docs.spring.io/spring-security/reference/servlet/saml2/login/authentication.html)では作成されるユーザー情報をカスタマイズする方法が紹介されています。
この方法を使って、ユーザーに任意のauthorityを追加することができます。

それよりもユーザー情報が持つattributesを使って直接認可設定できた方が便利なので、次のような`AuthorizationManager`を実装します。

```java
cat <<'EOF' > src/main/java/com/example/SamlAuthorizationManager.java
package com.example;

import java.util.List;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.function.Supplier;

import org.springframework.security.authorization.AuthorizationDecision;
import org.springframework.security.authorization.AuthorizationManager;
import org.springframework.security.core.AuthenticatedPrincipal;
import org.springframework.security.core.Authentication;
import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticatedPrincipal;
import org.springframework.security.saml2.provider.service.authentication.Saml2Authentication;
import org.springframework.security.web.access.intercept.RequestAuthorizationContext;

public class SamlAuthorizationManager<T> implements AuthorizationManager<RequestAuthorizationContext> {


	private final Function<Saml2AuthenticatedPrincipal, T> applyer;

	private final Predicate<T> predicate;

	public SamlAuthorizationManager(Function<Saml2AuthenticatedPrincipal, T> applyer, Predicate<T> predicate) {
		this.applyer = applyer;
		this.predicate = predicate;
	}

	public static SamlAuthorizationManager<List<String>> attribute(String name, Predicate<List<String>> predicate) {
		return new SamlAuthorizationManager<>(principal -> principal.getAttribute(name), predicate);
	}

	public static SamlAuthorizationManager<String> firstAttribute(String name, Predicate<String> predicate) {
		return new SamlAuthorizationManager<>(principal -> principal.getFirstAttribute(name), predicate);
	}

	public static SamlAuthorizationManager<String> username(Predicate<String> predicate) {
		return new SamlAuthorizationManager<>(AuthenticatedPrincipal::getName, predicate);
	}

	@Override
	public AuthorizationDecision check(Supplier<Authentication> authentication, RequestAuthorizationContext object) {
		Authentication auth = authentication.get();
		if (auth instanceof Saml2Authentication && auth.getPrincipal() instanceof Saml2AuthenticatedPrincipal principal) {
			T target = this.applyer.apply(principal);
			if (target == null) {
				return new AuthorizationDecision(false);
			}
			return new AuthorizationDecision(this.predicate.test(target));
		}
		else {
			return new AuthorizationDecision(false);
		}
	}
}
EOF
```

例えば、`/admin`のパスにアクセスするには、以下のいずれかを満たす必要があるとします。

* `groups` attributeに`a7443a38-70d1-709f-aa2c-4841adf65ed1`を含む
* `email` attribute(先頭の要素のみ対象)が`@example.com`で終わる
* ユーザー名が`admin`または`makingx@gmail.com`である

この条件を`SamlAuthorizationManager`を使って次のように定義できます。

```java
// ...
import static com.example.SamlAuthorizationManager.attribute;
import static com.example.SamlAuthorizationManager.firstAttribute;
import static com.example.SamlAuthorizationManager.username;
import static org.springframework.security.authorization.AuthorizationManagers.anyOf;
// ...

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
	return http
			.authorizeHttpRequests(authz -> authz
					.requestMatchers("/error").permitAll()
					// <---
					.requestMatchers("/admin").access(anyOf(
							attribute("groups", groups -> groups.contains("a7443a38-70d1-709f-aa2c-4841adf65ed1")),
							firstAttribute("email", email -> email.endsWith("@example.com")),
							username(username -> username.equals("admin") || username.equals("makingx@gmail.com"))))
					// --->
					.anyRequest().authenticated())
			.saml2Login(withDefaults())
			.saml2Metadata(withDefaults())
			.saml2Logout(withDefaults())
			.build();
}
```

`/admin`パスに対するControllerを作成します。

```java
cat <<'EOF' > src/main/java/com/example/AdminController.java
package com.example;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class AdminController {
	@GetMapping(path = "/admin")
	public String admin() {
		return "admin page";
	}
}
EOF
```

アプリケーションを再起動して、 http://localhost:8080/admin にアクセスします。

権限のあるユーザーでログインすると、次のようにページが表示されます。

<img width="1024" alt="image" src="https://github.com/user-attachments/assets/3cd16f67-1598-478d-907a-f5e52c32f26a">

権限のあるユーザーでログインすると、403エラーが表示されます。

<img width="1024" alt="image" src="https://github.com/user-attachments/assets/eeeb3605-2fd0-44c0-add9-d12f01299652">

---

Spring BootでEntra IDとSAML2連携することにより、Entra IDのユーザーを使ってアプリケーションにログインすることができました。
Entra IDの場合は、OIDCも対応しているので、あまりSAML2を使うケースはないかもしれません。
