ヘッドレスID APIを使ってみよう（ユーザ登録編）

## はじめに

Summer'23で、ヘッドレスID APIがリリースされました。( [Summer'23リリースノート](https://help.salesforce.com/s/articleView?id=release-notes.rn_security_headless_registration.htm&release=244&type=5&language=ja) )

これまで、Salesforceに外部ユーザを登録しようとする場合には、Experience Cloudサイトからユーザ登録する必要がありましたが、このAPIを使用することで、外部アプリケーションからユーザ登録/ログイン/パスワードリセットが可能になります。

本記事は、ヘッドレスID APIを利用して外部ユーザを新規登録するまでの設定と流れについて、公式ドキュメントを補足するものになります。
ヘッドレスID APIを試す際の一助になれば幸いです。

:::note info
公式ドキュメントは[こちら](https://help.salesforce.com/s/articleView?id=sf.headless_identity_customers_overview.htm&type=5&language=ja)です
:::

## ヘッドレスID APIを使うまでの設定について

以下が主な設定作業になります。公式ドキュメントでは、[このページ](https://help.salesforce.com/s/articleView?id=sf.headless_identity_prerequisites.htm&type=5&language=ja)が該当します。

* プロファイル, ロール
* Experience Cloudサイト
* 接続アプリケーション(外部ユーザ用と、管理者用)
* 組織の設定

公式ドキュメントのガイドに従って設定すれば良いですが、今回は「Experience Cloudサイト」と「接続アプリケーション」の設定について補足します。

### Experience Cloudサイトの作成

ヘッドレスID APIを使ったユーザ登録は、主に`ログイン＆登録`メニュー > `ヘッドレスID設定`セクションで、ポチポチ設定します。
`ヘッドレス登録 API を介したセルフ登録の許可`にチェックを入れると、その他の設定が表示されます。
本記事では、ヘッドレスID APIを使ったユーザ登録でreCAPTCHAを使用しませんので、`このAPIへのアクセスに認証を要求`にチェックを入れます。
reCAPTCHAを使用する場合には、このチェックは不要です。代わりにreCAPTCHAオプションを設定ください。

ここで、登録ハンドラーは必須となりますので、事前に作成しておいてください。登録ハンドラーのサンプルは後ほど紹介します。

![screenshot_003.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1840/681ec05e-912e-6e03-9a64-b73ddbf97816.png)


#### 登録ハンドラー(Apex)の作成

[HeadlessSelfRegistrationHandlerインタフェース](https://developer.salesforce.com/docs/atlas.en-us.apexref.meta/apexref/apex_interface_Auth_HeadlessSelfRegistrationHandler.htm)を実装したApexクラスを用意します。
このApexクラス内でユーザオブジェクトの生成や、取引先/取引先責任者の生成を実行します。ただし、ユーザオブジェクトはInsertせずに返すようにしてください。（Insertして返すとエラーになります）
自前で実装することもあり、取引先と取引先責任者の生成には組織固有のロジックを組み込めます。

```apex
/**
 * ヘッドレス 登録ハンドラー のサンプル
 */
public with sharing class CreateSiteUserHandler implements Auth.HeadlessSelfRegistrationHandler {
  private final Long CURRENT_TIME = Datetime.now().getTime();

  public CreateSiteUserHandler() {}

  /**
   * ユーザ生成処理
   * ※insertしない状態でUserオブジェクトを返す
   */
  public User createUser(Id profileId, Auth.UserData data, String customUserDataMap, String experienceId, String password) {

    // Userフィールドに値をセットして、Userオブジェクトを生成する
    User u = new User(
      ProfileId = profileId
    );

    // (追加データ用)
    CustomUserData customData = (CustomUserData)JSON.deserialize(customUserDataMap, CustomUserData.class);

    Map<Schema.SObjectField, String> registrationAttributes = new Map<Schema.SObjectField, String>{
      Schema.User.FirstName => data.firstname,
      Schema.User.LastName => data.lastname,
      Schema.User.Email => data.email,
      Schema.User.Username => data.username,
      Schema.User.MobilePhone => customData.mobile_phone
    };
    for (Schema.SObjectField field : registrationAttributes.keySet()) {
      String value = registrationAttributes.get(field);
      u.put(field, value);
    }

    //
    if (String.isBlank(u.Username)) {
      u.Username = u.Email;
    }
    // 別名とかニックネームを適当に生成しているだけです.本来はもう少しロジックを入れるべきかな、とは思います
    if (String.isBlank(u.Alias)) {
      u.Alias = generateAlias();
    }
    if (String.isBlank(u.CommunityNickname)) {
      u.CommunityNickname = generateCommunityNickname();
    }

    Account acc = createAccount(u);
    Contact c = createContact(u, acc.Id);
    u.ContactId = c.Id;

    u.languagelocalekey = UserInfo.getLanguage();
    u.localesidkey = UserInfo.getLocale();
    u.emailEncodingKey = 'UTF-8';
    u.timeZoneSidKey = UserInfo.getTimezone().getID();

    // (注) ユーザはInsertせずに、返却してください.
    return u;
  }


  private Account createAccount(User u) {
    Account acc = new Account(
      Name = u.LastName + ' ' + u.FirstName
    );
    insert acc;
    return acc;
  }

  private Contact createContact(User u, Id accountId) {
    Contact c = new Contact(
      AccountId = accountId,
      LastName = u.LastName,
      FirstName = u.FirstName,
      Email = u.Email
    );
    insert c;
    return c;
  }

  private String generateAlias() {
    String timeString = String.valueOf(CURRENT_TIME);
    return timeString.substring(timeString.length() - 8);
  }

  private String generateCommunityNickname() {
    // ニックネームは組織内でユニークであることが必要
    // ユニークとなるよう、何かしら工夫してください
    return 'SiteUser/' + CURRENT_TIME;
  }


  /**
   * (カスタム)ユーザデータ
   *  - ヘッドレスID APIから受け取ったデータをデシリアライズするためのApexクラスになります.
   *  - 必要に応じて定義すればokです.
   */
  private class CustomUserData {
    public String mobile_phone {get; private set;}

  }
}
```

#### メールテンプレート

ヘッドレスID APIを使ったユーザ登録では、検証用のワンタイムパスワードをメールで通知することが可能です。
その際のメールテンプレートは、`メール`メニュー > `メールテンプレート`セクションで設定します。
ちなみに、ユーザ登録時のワンタイムパスワードを通知するメールテンプレートは`ヘッドレス登録のワンタイムパスワード`で設定します。

![screenshot_006.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1840/f7677f42-368d-1d6f-c348-bb9910f3e3d1.png)


### 接続アプリケーションの作成(管理者用)

3rdパーティのアプリケーションから、Salesforceにユーザ登録する際には、ユーザ登録を実行できるトークンが必要となります。（Experience Cloudの設定で`このAPIへのアクセスに認証を要求`を有効にしているので）
そのため、外部ユーザが使用するものとは別に管理者用として接続アプリケーションが必要になります。

この場合の接続アプリケーションの作成方法としては、以下の記事が参考になると思います。

https://qiita.com/stomita/items/4542ce1b48e5fa849ef1

ここで1点注意が必要になります。OAuth範囲として、以下を追加ください。

* ヘッドレス登録APIにアクセス (user_registration_api)
* パスワードを忘れた場合のヘッドレスAPIにアクセス (forgot_password)

![screenshot_001.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1840/f8435c55-fe17-2853-bc51-ac9e825aab7f.png)

※「パスワードを忘れた場合のヘッドレスAPIにアクセス」はユーザ登録時には不要ですが、パスワード忘れ時の対応として必要になります。

### 接続アプリケーションの作成(外部ユーザ用)

ヘッドレスID APIを使うための接続アプリケーションを用意します。
接続アプリケーションの作成ではOAuth範囲として、以下は追加しておいてください。

* ヘッドレス登録APIにアクセス (user_registration_api)
* パスワードを忘れた場合のヘッドレスAPIにアクセス (forgot_password)

また、`認証コードおよびログイン情報フローを有効化`と、`認証コードおよびログイン情報フローの POST 本文でユーザーログイン情報を要求`にチェックを入れておきます。
その他、今回は非公開クライアントによる実装ですので、`Webサーバーフローの秘密が必要`, `更新トークンフローの秘密が必要` にもチェックを入れます。

`PKCE`についてはデフォルトでチェックが入った状態になります。ログインフローで`PKCE`を使う場合にはチェックを入れたままにしてください。（ここでは外してます）

`IDトークンを要求`ではIDトークンの要否に合わせてチェックを入れてください。IDトークンを含める場合は、OAuth範囲に`一意のユーザー識別子にアクセス`も追加ください。

![screnshot_005.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1840/e8db35bf-1ba6-0e44-b5f5-0a11d41a872a.png)

## ヘッドレスID APIを使ってみる

（ようやく本題です）

ドキュメントでは「公開クライアント」「非公開クライアント」という2つの言葉が出てきます。
「公開クライアント」とは機密情報を保持できないアプリケーションを指します。
例えば、SPAがこれにあたります。
機密情報（クライアントシークレットなど）をクライアント側で使用すると、誰でも参照できてしまう（≒公開）のでNGですよね。

逆に、「非公開クライアント」とは機密情報を(比較的安全に)保持できるアプリケーションを指します。
例えば、クライアント/サーバ構成のWebアプリケーションがこれにあたります。

お使いのシステム構成に従って、参照するドキュメントを選んでください。本記事では、非公開クライアントの場合を取り上げます。
公式ドキュメントは、[こちら](https://help.salesforce.com/s/articleView?id=sf.remoteaccess_headless_registration_private_clients.htm&type=5&language=ja)になります。

### 登録フロー(概要)

（[非公開クライアントのヘッドレス登録フロー](https://help.salesforce.com/s/articleView?id=sf.remoteaccess_headless_registration_private_clients.htm&language=ja&type=5)より引用）

![ff8b0652b231b3ec66869a7229aeb6ef.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1840/6f251595-dd8e-1223-90f4-41832f9602a9.png)

この中で、(4), (8), (11) についてもう少し掘り下げます。

### 実装ポイント1: ヘッドレス登録APIへのリクエスト(4)

フローの「3」で、ユーザはクライアントで用意したフォームにメールアドレスやユーザ名などの情報を入力します。
この入力内容を、Experience Cloudサイトのヘッドレス登録エンドポイントにPOSTする、という具合になります。

#### エンドポイント

`/services/auth/headless/init/registration`

#### HTTPメソッド

`POST`

#### ヘッダ

|キー|値|備考|
|:--|:--|:--|
|Authorization|Bearer (トークン)|Experience Cloudの[ログイン&登録]ページで、`このAPIへのアクセスに認証を要求`を有効にした場合は必須です。管理者用の接続アプリケーション経由で取得したアクセストークンを使います。<br>※公式からは、非公開クライアントの場合、有効にすることを強く推奨しています|
|Content-Type|application/json|固定値です|
|Auth-Verification-Type|email or sms|ユーザの検証に使用する方法としてEmail or SMS のいずれかを指定します。emailの場合はユーザのEmailに検証用の通知が届きます。|
|Accept-Language|ja|通知内容を日本語にする場合、指定が必要です。(※)|

※クライアント（ブラウザ側）から、上記のエンドポイントにPOSTする場合、`Accept-Language`は明示的に指定しなくても自動で付与されると思います。
もし、クライアント -> (何らかのサーバアプリケーション) -> (ヘッドレス登録API) という具合に、間に挟まるものがある場合には`Accept-Language`の指定が必要になります。

#### リクエストボディ

|キー|値|備考|
|:--|:--|:--|
|password|(フォームで入力した値)|必須です|
|userdata|(姓,名,Email,ユーザ名)|必須です|
|customdata|(そのほか追加情報)|任意です|

リクエストボディのサンプルです。

```json
{
  "userdata": {
    "firstName": "Taro",
    "lastName": "TAO",
    "email": "test_email@example.com",
    "username": "test_username@example.com"
  },
  "customdata": {
    "xxx": "xxx"
  },
  "password": "********"
}
```

`customdata` は、ヘッドレス登録用ApexハンドラでJSON形式の文字列として受け取ります。
ドキュメントでは、ヘッドレス登録用Apexハンドラ内で使用する変数を定義したApexクラスを用意して、そのApexクラスでデシリアライズしましょう、とありましたし、実際そうすべきだと思います。
例えば、紐つけしたい取引先責任者(or取引先)のキー情報などがあれば、それを定義しておいて、Apexハンドラ内で使用すると良いでしょう。

```apex
CustoUserData customData = (CustomUserData)JSON.deserialize(customUserDataMap, CustomUserData.class);
// contact_keyで該当のレコードをクエリする、など
Contact c = [Select Name From Contact Where key__c = :customData.contact_key];

...

private class CustomUserData {
  public String contact_key {get; private set;}
}
```

#### レスポンス

リクエストが成功すると、以下のようなレスポンスを受け取ります

```json
{
  "status": "success",
  "email": "test_email@example.com",
  "identifier": "xxxxxxxxxxxxxxxx"
}
```

この`identifier`は、後続処理で使用します。
また、ユーザにはメールが送信されます。このメールにワンタイムパスワード(OTP[^otp])が記載されています。

クライアント側では、このOTPを入力するためのフォームを用意して、ユーザに入力を促してください。（フローの(6), (7)にあたります）

### 実装ポイント1(+α): ユーザーが検証フォームに OTP を入力する(6)

自前のサイトで、OTPを入力させるための画面をご用意ください。入力したOTPを後続の処理で使用します。

### 実装ポイント2: 認証コードとログイン情報フローを初期化(8)

ユーザが入力したOTPと、`identifier`を使って、ヘッドレスログインAPIを使ったログイン処理を開始します。

#### エンドポイント

`/services/oauth2/authorize`

#### HTTPメソッド

`POST`

#### ヘッダ

|キー|値|備考|
|:--|:--|:--|
|Auth-Request-Type|user-registration|固定値です。|
|Authorization|Basic (要求識別子)|※1|
|Content-Type|application/x-www-form-urlencoded|固定値です|
|Auth-Verification-Type|email or sms|ユーザの検証に使用する方法としてEmail or SMS のいずれかを指定します。emailの場合はユーザのEmailに検証用の通知が届きます。<br>ヘッドレス登録APIへのリクエストで使用したものと同じ値を指定ください。|

※1
`identifier`と、`OTP`の文字列を `:` で連結して、Base64でエンコードした文字列を指定します。
例えばjsで記述するとこんな感じです。

```js
const user_request = `${identifier}:${otp}`;
const base64_encoded = Buffer.from(user_request).toString('base64');
...
const headers = {
  'Auth-Request-Type': 'user-registration',
  'Auth-Verification-Type': 'email',
  'Content-Type': 'application/x-www-form-urlencoded',
  'Authorization': `Basic ${base64_encoded}`,
};
```

#### リクエストボディ

|キー|値|備考|
|:--|:--|:--|
|client_id|(外部ユーザ用 接続アプリケーションのクライアントID)|必須です|
|response_type|code_credentials|固定値です|
|redirect_uri|(認証に成功した後のリダイレクト先URL)|接続アプリケーションのコールバックURLに含まれるもの|

認証に成功すると、Salesforceが`redirect_uri`に302リダイレクトします。以下のサンプルのようにクエリパラメータをつけてリダイレクトします。
リダイレクト先で、クエリパラメータ`code`を取得してください。

```
(サンプル)
(リダイレクト先URL)?code=aPrxC1xxxxx&sfdc_community_url=https%3A%2F%2FMyExperienceCloudSiteName.my.site.com&sfdc_community_id=0DBxxxxxxxxxxx
```

### 実装ポイント3: 認証コードを使ってアクセストークンをリクエスト(11)

上記の「リダイレクト先」での処理になります。クエリパラメータに含まれている`code`を抽出して、アクセストークンをリクエストします。

#### エンドポイント

`/services/oauth2/token`

#### HTTPメソッド

`POST`

#### ヘッダ

|キー|値|備考|
|:--|:--|:--|
|Content-Type|application/x-www-form-urlencoded|固定値です|

#### リクエストボディ

|キー|値|備考|
|:--|:--|:--|
|client_id|(外部ユーザ用 接続アプリケーションのクライアントID)|必須です|
|client_secret|外部ユーザ用 接続アプリケーションのクライアントシークレット)|必須です|
|code|(抽出したcode)|必須です|
|grant_type|authorization_code|固定値です|
|redirect_uri|(認証に成功した後のリダイレクト先URL)|接続アプリケーションのコールバックURLに含まれるもの|

#### レスポンス

Salesforce側で認証コードの検証が成功すると、以下のようなレスポンスが返ってきます。 
以降は、この中に含まれる`access_token`を使って、salesforceのAPIにリクエストを投げてください。

```json
{
  "access_token":"*******************",
  "sfdc_community_url":"https://MyExperienceCloudSiteName.my.site.com",
  "sfdc_community_id":"0DBxxxxxxxxxxxx",
  "signature":"ts6wm/svX3jXlCGR4uu+SbA04M6qhD1SAgVTEwZ59P4=",
  "scope":"openid api",
  "id_token":"XXXXXX",
  "instance_url":"https://yourInstance.salesforce.com",
  "id":"https://yourInstance.salesforce.com/id/00Dxxxxxxxxxxxx/005xxxxxxxxxxxx",
  "token_type":"Bearer",
  "issued_at":"1667600739962"
}
```

## さいごに

思ったより長くなってしまいました。すみません。最後まで読んで頂いた方には感謝です。

ヘッドレスID APIを使うと、Experience Cloudサイトを使わずにユーザ登録機能やログイン機能を提供できるので、UIの自由度が上がります。（逆にUIを実装する必要が出てきますが）
UIを細かく調整したい場合には、ヘッドレスID APIを使う、という選択肢も検討に入れてみてください。

本記事は[Salesforce Advent Calendar 2023](https://qiita.com/advent-calendar/2023/salesforce)の22日目の記事になります。

[^otp]: OTPの有効期限についてドキュメントで見つけられず分かりません。