Salesforceのサーバ間インテグレーション用の OAuth 2.0 JWT ベアラーフローを試してみる

# 1. やってみたこと

- Salesforceのサーバ間インテグレーション用の OAuth 2.0 JWT ベアラーフローで認証を行う
- 適当なSalesforce APIにアクセスしてデータ連携を行う

# 2. バージョンや背景など

- Summer '23 Patch 10.7 (2023/7/2時点)
- sfdx-cli/7.206.6 darwin-x64 node-v18.15.0
```shell
% sfdx version
sfdx-cli/7.206.6 darwin-x64 node-v18.15.0
```
- スクラッチ組織で試す
- JWTの作成にJava, Mavenを使用します
- Java
```shell
% java --version
openjdk 17.0.7 2023-04-18
OpenJDK Runtime Environment Temurin-17.0.7+7 (build 17.0.7+7)
OpenJDK 64-Bit Server VM Temurin-17.0.7+7 (build 17.0.7+7, mixed mode, sharing)
```
- Maven
```shell
% mvn --version
Apache Maven 3.9.3 (21122926829f1ead511c958d89bd2f672198ae9f)
```

# 3. 試したこと

[API インテグレーション用の OAuth 設定の有効化](https://help.salesforce.com/s/articleView?id=sf.connected_app_create_api_integration.htm&type=5)
[サーバ間インテグレーション用の OAuth 2.0 JWT ベアラーフロー](https://help.salesforce.com/s/articleView?id=sf.remoteaccess_oauth_jwt_flow.htm&type=5)
[安全な Salesforce API ユーザの作成](https://help.salesforce.com/s/articleView?id=000386144&type=1)
こちらを参照しながら試していきます

## 3.1. スクラッチ組織を作成する

以下のようなコマンドでスクラッチ組織を作成します
デフォルトのDevHub組織が既に作成および認証済みとします

```shell
sfdx org create scratch --edition developer --alias demo
```

`Your scratch org is ready.`というメッセージが表示され、`sfdx org list`で作成したスクラッチ組織を確認できます

以下のコマンドで作成したスクラッチ組織をブラウザで表示します

```shell
sfdx org open -o demo
```

## 3.2. サーバ間インテグレーション用の OAuth 2.0 JWT ベアラーフローの接続アプリケーションを設定する

サーバ間インテグレーション用の OAuth 2.0 JWT ベアラーフローの接続アプリケーションを作成します
[API インテグレーション用の OAuth 設定の有効化](https://help.salesforce.com/s/articleView?id=sf.connected_app_create_api_integration.htm&type=5)
こちらを参照しながら作成します

### 3.2.1. 署名証明書を作成する

JWTベアラーフローのための秘密鍵、署名証明書を作成します
ここでは自己署名証明書を作成します

```shell:秘密鍵の作成
openssl genrsa 2048 > demo.pem
```
```shell:署名リクエストの作成
openssl req -new -key demo.pem -out demo.csr
```
```shell:署名証明書の作成
openssl x509 -req -days 365 -in demo.csr -signkey demo.pem -out demo.crt
```

署名証明書は、接続アプリケーションの設定に使用します

### 3.2.2. 新規接続アプリケーションの作成

- `設定 > アプリケーション > アプリケーションマネージャ`で`新規接続アプリケーション`をクリックします

### 3.2.3. 基本情報の入力

基本情報を入力します

- `接続アプリケーション名`, `API 参照名`, `取引先責任者 メール`を設定します

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/13874/5578d2cf-c007-d2ad-3677-5fc5bf939b9e.png)


### 3.2.4. OAuth設定の入力

OAuth設定を入力します

- `OAuth 設定の有効化`にチェックします
- `コールバック URL`を入力します
  - JWTベアラーフローでは使用しない設定のため、任意のもので大丈夫です。ここでは`https://localhost:19999`としました
- `デジタル署名を使用`をチェックします
  - `ファイルを選択`をクリックして、`3.2.1. 署名証明書を作成する`で作成した自己証明書を設定します
  - ここでは`demo.crt`になります
- `選択した OAuth 範囲`を選択します
  - 今回はSalesforce APIを利用するため、`API を使用してユーザデータを管理 (api)`を選択します
  - `いつでも要求を実行 (refresh_token, offline_access)`を選択します
- `Web サーバフローの秘密が必要`をチェックします
- `更新トークンフローの秘密が必要`をチェックします
- `すべてのトークンを調査`をチェックします

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/13874/d30fd367-1897-e449-38be-4513fe74a587.png)


### 3.2.5. 設定の保存
- `保存`します


## 3.3. 接続ユーザーの作成

接続ユーザーはAPI限定ユーザーで作成します

[安全な Salesforce API ユーザの作成](https://help.salesforce.com/s/articleView?id=000386144&type=1)
> 組織でインテグレーションの目的にのみ使用する特別なユーザを作成します。こうすることで、実際のユーザが組織を離れた場合でも、いつでも適切な権限を持つユーザが存在することになります。


### 3.3.1. API限定ユーザーの作成 (1)

「Salesforce Integration」ライセンスの「Salesforce API Only System Integrations」 というプロファイルでAPI限定ユーザーを作成することができます
このライセンスを利用するとSalesforceライセンスを消費することがありません

- `設定 > ユーザ > ユーザ`で`新規ユーザ`をクリックします
- ユーザライセンスに`Salesforce Integration`を設定します
- プロファイルに`Salesforce API Only System Integrations`が自動的に設定されます
- `保存`して、ユーザを作成します

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/13874/750bcbaf-8bc4-9473-ca96-e899c7b799b3.png)


### 3.3.2. API限定ユーザーの作成 (2)

以下の方法でもAPI限定ユーザーを作成することができます
この場合、Salesforceライセンスを消費しますので、注意が必要です

#### 3.3.2.1. API限定ユーザープロファイルの作成

- `設定 > ユーザ > プロファイル`で`新規プロファイル`をクリックします
- `既存のプロファイル`を選択します
  - 新しく作成するユーザープロファイルの基となるものを選択します
  - ここでは`標準ユーザ`を選択します
- `プロファイル名`を入力します
  - ここでは`apionly`とします

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/13874/f2ed1871-507e-6a14-a167-9e4aa251e421.png)


- `編集`をクリックします
- `システム管理者権限 > API 限定ユーザ`をチェックします
- `保存`します

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/13874/b151b94f-8442-0de4-a95a-ab857ca775b4.png)


#### 3.3.2.2. ユーザーの作成

- `設定 > ユーザ > ユーザ`で`新規ユーザ`をクリックします
- ユーザライセンスに`Salesforce`を設定します
- プロファイルに`3.3.2.1. API限定ユーザープロファイルの作成`で作成したプロファイルを設定します
- `保存`して、ユーザを作成します

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/13874/05ee7b17-9e88-8f32-d8ab-0fa510a6ff4e.png)


## 3.4. 接続アプリケーションのポリシーを設定

接続アプリケーションで認証できるプロファイルを設定します

- `設定 > アプリケーション > アプリケーションマネージャ`の該当アプリケーションの右端▼から`Manage`をクリックします
- `ポリシーを編集`をクリックします
- `OAuth ポリシー > 許可されているユーザ`を`管理者が承認したユーザは事前承認済み`に設定します
  - `このオプションを有効にすると、現在このアプリケーションを使用しているすべてのユーザがアクセスを拒否されます。アプリケーションを使用中のユーザを確認するには、接続アプリケーションの OAuth 利用状況レポートを参照してください。`というダイアログが表示されるので、OKをクリックします
- `保存`します

- `プロファイル > プロファイルを管理する`で、`3.3. 接続ユーザーの作成`で設定したプロファイルを設定します

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/13874/f86533f0-6f1a-2336-b317-a010d708df2a.png)


## 3.5. アクセストークンを要求する

[サーバ間インテグレーション用の OAuth 2.0 JWT ベアラーフロー](https://help.salesforce.com/s/articleView?id=sf.remoteaccess_oauth_jwt_flow.htm&type=5)
こちらを参照しながら試していきます

### 3.5.1. client_idの確認

- `設定 > アプリケーション > アプリケーションマネージャ`の該当アプリケーションの右端▼から参照をクリックします
- `API (OAuth 設定の有効化) > コンシューマキーと秘密のコンシューマの詳細を管理`をクリックします
- 確認コードを要求されたら入力して、`検証`をクリックします
- `コンシューマ鍵`と`コンシューマの秘密`が表示されます
- `コンシューマ鍵`が`client_id`、`コンシューマの秘密`が`client_secret`に該当します
- `コンシューマ鍵`のみをコピーして控えます

### 3.5.2. JWTの作成

- JWT ヘッダーを次の形式で作成します`{"alg":"RS256"}`
- JWT の JSON 要求セットを作成します
  - iss: client_id (コンシューマ鍵)
  - sub: 接続するユーザー名
  - aud: 認可サーバーの URL
    - `https://login.salesforce.com` または `https://test.salesforce.com`
    - 本番環境では前者、テスト環境(SandboxやScratch)では後者になります
  - exp: 有効性は 3 分以内のアサーションの有効時間である必要があります (UTC で測定された 1970-01-01T0:0:0Z からの秒数として表記)

JSON 要求セットは以下のような形式になります
```json
{"iss": "3MVG99OxTyEMCQ3gNp2PjkqeZKxnmAiG1xV4oHh9AKL_rSK.BoSVPGZHQ
ukXnVjzRgSuQqGn75NL7yfkQcyy7", 
"sub": "my@email.com", 
"aud": "https://login.salesforce.com", 
"exp": "1333685628"}
```

今回は、JavaでJWTを作成します

#### 3.5.2.1. 秘密鍵をDERフォーマットに変換

Javaで秘密鍵を読み取るために、`3.2.1. 署名証明書を作成する`で作成した秘密鍵をDERフォーマットに変換します

```shell:秘密鍵をDERフォーマットに変換
openssl pkcs8 -topk8 -inform PEM -outform DER -in demo.pem -out demo.der -nocrypt
```

#### 3.5.2.2. JavaでJWTを作成する

今回、JWTを作成するライブラリとして以下を使用することとします
[auth0/java-jwt](https://github.com/auth0/java-jwt)

また、Javaのjshellスニペットで処理を記述したいと思います
[johnpoth/jshell-maven-plugin](https://github.com/johnpoth/jshell-maven-plugin)を使用して、ライブラリの依存性解決を行います

以下に続くファイルを作成します

JWTを作成するために必要な情報を環境変数に設定します
```shell:env
export JWT_ISS="******"
export JWT_SUB="******@******.***"
export JWT_AUD="https://test.salesforce.com"
export JWT_PRIVATEKEY_PATH=demo.der
```

`jshell-maven-plugin`、およびライブラリ`java-jwt`を使用するために以下のファイルを作成します
```xml:pom.xml
<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>com.example</groupId>
  <artifactId>demo</artifactId>
  <version>1.0</version>
 
  <dependencies>
    <dependency>
      <groupId>com.auth0</groupId>
      <artifactId>java-jwt</artifactId>
      <version>4.4.0</version>
    </dependency>
  </dependencies>

  <build>
    <pluginManagement>
      <plugins>
        <plugin>
          <groupId>com.github.johnpoth</groupId>
          <artifactId>jshell-maven-plugin</artifactId>
          <version>1.3</version>
        </plugin>
      </plugins>
    </pluginManagement>
  </build>

</project>
```

jshellのスニペットファイルを作成します
```java:demo.jshell-snipet
import java.time.OffsetDateTime

// Definitions
var jwtHeader = """
{"alg":"RS256"} """

var iss = System.getenv("JWT_ISS")
var sub = System.getenv("JWT_SUB")
var aud = System.getenv("JWT_AUD")
var privateKeyPath = System.getenv("JWT_PRIVATEKEY_PATH")
var exp = OffsetDateTime.now().plusMinutes(3).toInstant()

// generate RSAKey from private_key file
import java.nio.file.Paths
import java.nio.file.Files
import java.security.KeyFactory
import java.security.spec.PKCS8EncodedKeySpec
import java.security.interfaces.RSAKey

var privateKeyFile = Paths.get(privateKeyPath)
var privateKeyContent = Files.readAllBytes(privateKeyFile)
var keySpec = new PKCS8EncodedKeySpec(privateKeyContent)
var keyFactory = KeyFactory.getInstance("RSA")
var rsaPrivateKey = keyFactory.generatePrivate(keySpec)

// Generate JWT
import com.auth0.jwt.algorithms.Algorithm
import com.auth0.jwt.JWT

var algo = Algorithm.RSA256((RSAKey)rsaPrivateKey)

var jwtBuilder = JWT.create()
jwtBuilder.withIssuer(iss)
jwtBuilder.withSubject(sub)
jwtBuilder.withAudience(aud)
jwtBuilder.withExpiresAt(exp)
jwtBuilder.withHeader(jwtHeader)

var jwt = jwtBuilder.sign(algo)

// Print generated JWT
System.out.println(jwt)

/exit
```

上記の準備ができたら、以下のコマンドを実行します

```shell:JWTの作成に必要な情報を環境変数に設定
source env
```
```shell:JWTの作成
mvn jshell:run -Djshell.scripts=demo.jshell-snipet
```

コマンドが正常に実行されたら、JWTが出力されます


### 3.5.3. アクセストークンの取得

以下のようなコマンドで、アクセストークンを取得することができます

`assertion`には`3.5.2. JWTの作成`で作成したJWTを設定します

```shell:アクセストークンの取得
curl -X POST -H 'Content-Type: application/x-www-form-urlencoded' 'https://MyDomainName.my.salesforce.com/services/oauth2/token' --data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer' --data-urlencode 'assertion=*********'
```
上記の`https://MyDomainName.my.salesforce.com`の部分は`https://test.salesforce.com`でも代替可能です

そうすると以下のようなレスポンスが返ってきます
`access_token`の値を使用してAPIにアクセスを行うことができます
また、`instance_url`の値はユーザーの組織のインスタンスを示すURLになります

```json:response
{
  "access_token": "******",
  "scope": "api",
  "instance_url": "https://MyDomainName.my.salesforce.com",
  "id": "https://test.salesforce.com/id/******/******",
  "token_type": "Bearer"
}
```

JWTベアラーフローでは、リフレッシュ トークンは発行されません
セッションタイムアウト内で取得されるアクセストークンは同じものになります

# 4. Appendix

## 4.1. `OAuth 範囲`に`refresh_token`を設定しなかった場合は？

アクセストークンを取得しようとすると以下のようなレスポンスが返ってきます
`refresh_token`が必要だというエラーメッセージがあり、`refresh_token`の設定は必要なことが分かります
```json:response
{
  "error": "invalid_request",
  "error_description": "refresh_token scope is required and the connected app should be installed and preauthorized."
}
```

## 4.2. JWTのaudに`https://MyDomainName.my.salesforce.com`を設定したら？

アクセストークンを取得しようとすると以下のようなレスポンスが返ってきます
`aud`が正しくないことが分かります
```json:response
{
  "error": "invalid_grant",
  "error_description": "audience is invalid"
}
```
## 4.3. JWTのexpに設定したアサーションの有効時間を超えてアクセストークンを取得しようとしたら？

アクセストークンを取得しようとすると以下のようなレスポンスが返ってきます
有効期限切れであることが分かります
```json:response
{
  "error": "invalid_grant",
  "error_description": "expired authorization code"
}
```

## 4.4. 接続アプリケーションに設定したデジタル証明書の有効期限を超えたら？

TODO

## 4.5. API限定ユーザーでWebログインしようとしてみたら？

以下のように表示され、Webログインが拒否されたことが分かります

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/13874/3ac7b5ce-a02a-165c-5fcd-bf3c76c9d4dc.png)

## 4.6. 正しくない秘密鍵でJWTを作成してアクセストークンを取得しようとしたら？

アクセストークンを取得しようとすると以下のようなレスポンスが返ってきます
`client credentials`が正しくないことが分かります
```json:response
{
  "error": "invalid_client",
  "error_description": "invalid client credentials"
}
```

# 参考
[API インテグレーション用の OAuth 設定の有効化](https://help.salesforce.com/s/articleView?id=sf.connected_app_create_api_integration.htm&type=5)
[サーバ間インテグレーション用の OAuth 2.0 JWT ベアラーフロー](https://help.salesforce.com/s/articleView?id=sf.remoteaccess_oauth_jwt_flow.htm&type=5)
[安全な Salesforce API ユーザの作成](https://help.salesforce.com/s/articleView?id=000386144&type=1)

おわり。