[Salesforce] カスタム認証プロバイダの作り方 ~Client Credentials 編~

Salesforceでは、「[Salesforce で Client Credentials フローな指定ログイン情報を使いたい](https://qiita.com/sho7650/items/44db491f94e993ad5a1d)」で紹介したように、「[認証プロバイダ](https://help.salesforce.com/articleView?id=sso_authentication_providers.htm&type=5)」を利用して、外部システムとの認証・シングルサインオンを容易にしてくれる機能があります。

が、しかし、これらは次の外部認証プロバイダのみのサポートとなっています。

- ~~Amazon~~
- Apple
- Facebook
- GitHub (New)
- Google
- Janrain
- LinkedIn
- Microsoft アクセスコントロールサービス
- ~~Microsoft Azure AD~~
- Salesforce
- Twitter
- OpenID Connect プロトコルを実装しているサービスプロバイダ

(Winter'22現在)

よーく「[外部認証プロバイダの設定](https://help.salesforce.com/articleView?id=sso_authentication_providers_external.htm&type=5)」を見ると、[OAuth をサポートしているが OpenID Connect プロトコルをサポートしていないサービスプロバイダ](https://help.salesforce.com/articleView?id=sso_provider_plugin_custom.htm&type=5)というものがあるので、なんだ「**OAuth**」対応してるじゃんと思ってリンクを踏むと「**カスタム外部認証プロバイダの作成**」と書いてあるではありませんか。そう、`OAuth`の認証プロバイダは、現時点では己の手でソースコードレベルで作成が必要です。

しかも、ここを読んでも「カスタムメタデータ型の作成」と「Apexクラスおよびメソッドの作成」しか書いてありません。じゃぁ...ということで、「**SSO のカスタム認証プロバイダを作成するには、Auth.AuthProviderPluginClass 抽象クラスを拡張するクラスを作成します**」という言葉から「`Auth.AuthProviderPluginClass`」を調べてみると[カスタム認証プロバイダプラグインの作成](https://developer.salesforce.com/docs/atlas.ja-jp.apexcode.meta/apexcode/authproviderplugin.htm)が見つかるでしょう。しかし、ここを読んでも、何をしたらいいかを理解するのは難しいと判断します。いやだって難しかったもん。

ということで、今回`Client Credential`向けに準備したソースコードをもとに、何を準備したら良いかと説明しましょう。

ちなみに、全ソースコードはこちら。

```java
global class ClientCredentialsAuthProvider extends Auth.AuthProviderPluginClass {
    // define
    public static final String CUSTOM_MDT_NAME = 'ClientCredentials_Metadata__mdt';
    public static final String CALLBACK_URL = '/services/authcallback/';
    public static final String GRANT_TYPE = 'client_credentials';

    public static final String CMDT_FIELD_PROVIDER_NAME = 'Auth_provider__c';
    public static final String CMDT_FIELD_ACCESS_TOKEN_URL = 'Access_Token_URL__c';
    public static final String CMDT_FIELD_CLIENT_ID = 'Client_ID__c';
    public static final String CMDT_FIELD_CLIENT_SECRET = 'Client_Secret__c';
    public static final String CMDT_FIELD_USER_NAME = 'User_Name__c';

    // access token response
    private class TokenResponse {
        public String access_token;     //required
        public String token_type;       //required
        public Long expires_in;         //recommended
        public String refresh_token;    //optional
        public String scope;            //optional
        public String error;            //in case of error
        public String error_description;//in case of error
        public String error_uri;        //in case of error

        public Boolean isError() {
            return error != null;
        }
    }

    global String getCustomMetadataType() {
        return CUSTOM_MDT_NAME;
    }

    global PageReference initiate(Map<string,string> authProviderConfiguration, String stateToPropagate) {
        final PageReference pageRef = new PageReference(getCallbackUrl(authProviderConfiguration));
        pageRef.getParameters().put('state',stateToPropagate);

        return pageRef;
    }

    private String getCallbackUrl(Map<string,string> config) {
        return URL.getSalesforceBaseUrl().toExternalForm() + CALLBACK_URL + config.get(CMDT_FIELD_PROVIDER_NAME);
    }

    global Auth.AuthProviderTokenResponse handleCallback(Map<string,string> authProviderConfiguration, Auth.AuthProviderCallbackState callbackState) {
        TokenResponse tokenResponse = retrieveToken(authProviderConfiguration);

        if (tokenResponse.isError()) {
            throw new TokenException(tokenResponse.error);
        }

        return new Auth.AuthProviderTokenResponse(
            authProviderConfiguration.get(CMDT_FIELD_PROVIDER_NAME),    // provider
            tokenResponse.access_token,                                 // oauthToken
            'refreshToken',                                             // oauthSecretOrRefreshToken
            callbackState.queryParameters.get('state')                  // state
        );
    }

    global Auth.UserData getUserInfo(Map<string,string> authProviderConfiguration, Auth.AuthProviderTokenResponse response) {
        String userName = authProviderConfiguration.get(CMDT_FIELD_USER_NAME);
        String provider = authProviderConfiguration.get(CMDT_FIELD_PROVIDER_NAME);
        return new Auth.UserData(
            null,       // identifier
            null,       // firstName
            null,       // lastName
            null,       // fullName
            userName,   // email
            null,       // link
            userName,   // userName
            null,       // locale
            provider,   // provider
            null,       // siteLoginUrl
            new Map<String,String>()
        );
    }

    override global Auth.OAuthRefreshResult refresh(Map<String,String> authProviderConfiguration, String refreshToken) {
        TokenResponse tokenResponse = retrieveToken(authProviderConfiguration);
        return new Auth.OAuthRefreshResult(tokenResponse.access_token, tokenResponse.token_type);
    }

    private TokenResponse retrieveToken(Map<String,String> config) {
        String access_token_url = config.get(CMDT_FIELD_ACCESS_TOKEN_URL);
        String parameters =   'client_id=' + config.get(CMDT_FIELD_CLIENT_ID)
                            + '&client_secret=' + config.get(CMDT_FIELD_CLIENT_SECRET)
                            + '&grant_type=' + GRANT_TYPE;

        HttpRequest req = new HttpRequest();
        req.setEndpoint(access_token_url);
        req.setHeader('Content-Type','application/x-www-form-urlencoded;charset=UTF-8');
        req.setMethod('POST');
        req.setBody(parameters);
        Http http = new Http();

        HTTPResponse res = http.send(req);
        return (TokenResponse)JSON.deserialize(res.getBody(), TokenResponse.class);
    }

    public class TokenException extends Exception { }
}
```

## 変数定義部分の説明

抽象型 Class を継承する話は置いといて、この`ClientCredentialsAuthProvider`の各項目について、説明します。ここで `extends` した Class名は`プロバイダタイプ`になります。

変数定義の最初の部分は定数定義です。分かりやすいように名前をつけているだけですね。影響がある部分は`CUSTOM_MDT_NAME` ですね。ここは`カスタムメタデータ型`で定義したAPI名になります。「外部認証プロバイダ」で使用する`Client ID`や`Client Secret`などは、このカスタムメタデータ型で定義したレコードに保管します。

当Client Credentials型で必要なカスタムメタデータ型は次のとおりです。

- Client_ID ... (Salesfroceでいう)クライアント鍵のこと
- Client_Secret ... (Salesforceでいう)クライアントの秘密のこと
- Access_Token_URL ... 外部の認証サーバで Client Credentials を受け付ける URL
- Auth_Provider ... 自身のプロバイダ名
- User_Name ... Salesforce 側の利用者のメールアドレス

`User_Name` は、実際の稼働には必要がありません。Client Credentials型では、相手側からユーザ情報は返却されません。認証されたトークンなどの一部の情報だけです。それだとちょっとさみしいので、ユーザ名(メールアドレス)を指定して、Salesforce ユーザと連携できた風にしています。

```java

    // define
    public static final String CUSTOM_MDT_NAME = 'ClientCredentials_Metadata__mdt';
    public static final String CALLBACK_URL = '/services/authcallback/';
    public static final String GRANT_TYPE = 'client_credentials';
```

次の定数は、先程のカスタムメタデータ型で指定したフィールド名です。カスタムメタデータ型で定義された、個々のレコードの内容を取得するときに利用します。

```java
    public static final String CMDT_FIELD_PROVIDER_NAME = 'Auth_provider__c';
    public static final String CMDT_FIELD_ACCESS_TOKEN_URL = 'Access_Token_URL__c';
    public static final String CMDT_FIELD_CLIENT_ID = 'Client_ID__c';
    public static final String CMDT_FIELD_CLIENT_SECRET = 'Client_Secret__c';
    public static final String CMDT_FIELD_USER_NAME = 'User_Name__c';
```

次は、一般的に Client Credentials で返却されるフォーマットをオブジェクト型にしたものです。`//required` 部分が必ず返却される内容で、`error`部分はエラーが発生したとき以外は、特に返却されないことがほとんどな気がします。

Client Credentials 型で、相手のサーバと接続するために必要なのは、ここでいう`access_token` だけです。相手のサーバがどのような値を返却するかにもよりますが、最大をとっておけば抜け漏れることもないので、最大の状態で定義しています。

それと `isError()` メソッドを付け加えています。エラーがあったかどうかを調べるためだけのメソッドです。

```java
    // access token response
    private class TokenResponse {
        public String access_token;     //required
        public String token_type;       //required
        public Long expires_in;         //recommended
        public String refresh_token;    //optional
        public String scope;            //optional
        public String error;            //in case of error
        public String error_description;//in case of error
        public String error_uri;        //in case of error

        public Boolean isError() {
            return error != null;
        }
    }
```

## メソッド部の説明

カスタム認証プロバイダを作成する場合に`global`で指定された、次の４つのメソッドの作成は必須です。

- `global String getCustomMetadataType()`
- `global PageReference initiate(Map<string,string>, String)`
- `global Auth.AuthProviderTokenResponse handleCallback(Map<string,string>, Auth.AuthProviderCallbackState)`
- `global Auth.UserData getUserInfo(Map<string,string>, Auth.AuthProviderTokenResponse)`

最低限必須のメソッドの内容について、まず説明します。

### [String getCustomMetadataType()](https://developer.salesforce.com/docs/atlas.ja-jp.apexcode.meta/apexcode/apex_class_Auth_AuthProviderPluginClass.htm#apex_Auth_AuthProviderPluginClass_getCustomMetadataType)

このメソッドはカンタンです。定義したカスタムメタデータ型を返却すればよろしい。見たままの内容を`return`してください。

必要なのは、認証プロバイダで定義されるコールバックURLです。

```java
    global String getCustomMetadataType() {
        return CUSTOM_MDT_NAME;
    }
```

### [global PageReference initiate(Map<string,string>, String)](https://developer.salesforce.com/docs/atlas.ja-jp.apexcode.meta/apexcode/apex_class_Auth_AuthProviderPluginClass.htm#apex_Auth_AuthProviderPluginClass_initiate)

ユーザが認証されるときのリダイレクト先のURLを返却してあげます。このURLは、`Client Credentials`の場合は、次のとおりになります。

`自ホスト名` + `/services/authcallback/` + `認証プロバイダ名`

`private`で定義した、`getCallbackUrl()`でこれらを組み立てています。

もう一つ、Salesforce システムが認証要求を開始するための「状態」をパラメータに付与しておく必要があります。それが`pageRef.getParameters().put('state',stateToPropagate);` ですね。必須の項目と考えておいて良いです。Salesforceが認証要求を行う際に、この`state` クエリを見て状態の判断を行うので、素直に入れておいてください。

```java
    global PageReference initiate(Map<string,string> authProviderConfiguration, String stateToPropagate) {
        final PageReference pageRef = new PageReference(getCallbackUrl(authProviderConfiguration));
        pageRef.getParameters().put('state',stateToPropagate);

        return pageRef;
    }

    private String getCallbackUrl(Map<string,string> config) {
        return URL.getSalesforceBaseUrl().toExternalForm() + CALLBACK_URL + config.get(CMDT_FIELD_PROVIDER_NAME);
    }
```

### [global Auth.AuthProviderTokenResponse handleCallback(Map<string,string>, Auth.AuthProviderCallbackState)](https://developer.salesforce.com/docs/atlas.ja-jp.apexcode.meta/apexcode/apex_class_Auth_AuthProviderPluginClass.htm#apex_Auth_AuthProviderPluginClass_handleCallback)

実際にサーバへ`Client Credential`を送って、アクセストークンを取得する部分です。メインの部分ですね。

実際に HTTPコールアウトしている部分は `retrieveToken()`メソッドとしていますので、そちらを参考になさってください。`tokenResponse` オブジェクトは先程定義したオブジェクトクラスです。

返却されたトークン内にエラーが発生していたら`throw`してエラーを返しています。

成功したら `Auth.AuthProviderTokenResponse` オブジェクト型で内容を返却しています。注釈にも書いてあるとおり、次の４つの内容を返却します。

- provider ... プロバイダ名 (固定)
- oauthToken ... 実際に相手のサーバから受け取ったアクセストークンです
- oauthSecretOrRefreshToken ... 現在ログインしているユーザの OAuth の秘密または更新トークンが入りますが、`Client Credentials`の初期アクセス時には利用しないので、適当な文字列でやり過ごします
- state ... 先にも示された `stateToPropage` の値です。コールバックURLクエリパラメータ内の `state` を入れておきますから、この内容でセットと組み合わせで覚えておけばよいです

```java
    global Auth.AuthProviderTokenResponse handleCallback(Map<string,string> authProviderConfiguration, Auth.AuthProviderCallbackState callbackState) {
        TokenResponse tokenResponse = retrieveToken(authProviderConfiguration);

        if (tokenResponse.isError()) {
            throw new TokenException(tokenResponse.error);
        }

        return new Auth.AuthProviderTokenResponse(
            authProviderConfiguration.get(CMDT_FIELD_PROVIDER_NAME),    // provider
            tokenResponse.access_token,                                 // oauthToken
            'refreshToken',                                             // oauthSecretOrRefreshToken
            callbackState.queryParameters.get('state')                  // state
        );
    }
```

#### private TokenResponse retrieveToken(Map<String,String>)

もうひとつ必須 global メソッドがありますが、HTTPコールアウトに合わせて、ここで説明します。

HTTPコールアウト部分が、カスタム認証プロバイダでの「キモ」の部分です。`Client Credentials`以外のOAuthフローを行う場合に、クエリパラメータの内容が変わるのは、ここの部分です。とは言え、難しい内容ではありません。ここでは、必要なパラメータを一つずつセットして、コールアウトし、結果のJSONを受け取っているだけです。カンタンですよー。

- `access_token_url` ... OAuthトークンを受け取るために接続する相手先サーバをセットしています
- `parameters` ... `Client Credentials`で必要なパラメータ `client_id`, `client_secret`, `grant_type=client_credentials` を設定しています。それぞれ、カスタムメタデータ型や定数定義した変数をセットしているだけです

これらアクセス先URLとHTTPヘッダを組み立てたら、`content-type`と`POST`メソッドでHTTPコールアウトします。返却された JSONをパースして`TokenResponse` オブジェクト型に組み直してオブジェクトとして return します。この`deserialize`メソッド便利ですね、ありがたい。

```java
    private TokenResponse retrieveToken(Map<String,String> config) {
        String access_token_url = config.get(CMDT_FIELD_ACCESS_TOKEN_URL);
        String parameters =   'client_id=' + config.get(CMDT_FIELD_CLIENT_ID)
                            + '&client_secret=' + config.get(CMDT_FIELD_CLIENT_SECRET)
                            + '&grant_type=' + GRANT_TYPE;

        HttpRequest req = new HttpRequest();
        req.setEndpoint(access_token_url);
        req.setHeader('Content-Type','application/x-www-form-urlencoded;charset=UTF-8');
        req.setMethod('POST');
        req.setBody(parameters);
        Http http = new Http();

        HTTPResponse res = http.send(req);
        return (TokenResponse)JSON.deserialize(res.getBody(), TokenResponse.class);
    }
```

### [global Auth.UserData getUserInfo(Map<string,string>, Auth.AuthProviderTokenResponse)](https://developer.salesforce.com/docs/atlas.ja-jp.apexcode.meta/apexcode/apex_class_Auth_AuthProviderPluginClass.htm#apex_Auth_AuthProviderPluginClass_getUserInfo)

最後の必須メソッドが、`getUserInfo()`です。外部の認証プロバイダから取得された、現在のユーザ情報を返却します。外部での認証した情報をもとに、Salesforce でのユーザを紐付ける場合に利用します。特に相手からユーザ情報を取得するフローではありませんから、ここではダミーデータを埋めておきます。

返却されるオブジェクト型は`Auth.UserData`型で、次の内容になります。

- identifier ... 外部認証サービスが発行するユーザの識別子です。入れなくていいです
- firstName ... 外部認証サービスが発行する、認証済みユーザ名。入れなくていいです
- lastName ... 外部認証サービスが発行する、認証済みユーザ姓。入れなくていいです
- fullName ... 外部認証サービスが発行する、認証済みユーザ氏名。入れなくていいです
- email ... 外部認証サービスが発行する、認証済みユーザのメールアドレス。入れなくていいです
- link ... 外部認証サービスが発行する、認証済みユーザの固定URLリンク。入れなくていいです
- userName ... 外部認証サービスが発行する、認証済みユーザ名。入れなくていいです
- locale ... 外部認証サービスが発行する、認証済みユーザのロケール。入れなくていいです
- provider ... 外部認証サービス名。自分自身のカスタム認証プロバイダ名にしておきます
- siteLoginUrl ... サイトログイン用のURL。入れなくていいです
- attributeMap ... 外部認証サービス固有のパラメータがあれば、ここに紐付けます。入れなくていいです

要するに、何も入れなくていいみたいです。相互認証をするわけではない`Client Credentials`なので、ダミーデータを返してあげればいいです。

```java
    global Auth.UserData getUserInfo(Map<string,string> authProviderConfiguration, Auth.AuthProviderTokenResponse response) {
        String userName = authProviderConfiguration.get(CMDT_FIELD_USER_NAME);
        String provider = authProviderConfiguration.get(CMDT_FIELD_PROVIDER_NAME);
        return new Auth.UserData(
            null,       // identifier
            null,       // firstName
            null,       // lastName
            null,       // fullName
            userName,   // email
            null,       // link
            userName,   // userName
            null,       // locale
            provider,   // provider
            null,       // siteLoginUrl
            new Map<String,String>() // attributeMap
        );
    }
```

### [override global Auth.OAuthRefreshResult refresh(Map<String,String>String)](https://developer.salesforce.com/docs/atlas.ja-jp.apexcode.meta/apexcode/apex_class_Auth_AuthProviderPluginClass.htm#apex_Auth_AuthProviderPluginClass_refresh)

ここまでが必須項目ですが、相手が「リフレッシュトークン」に対応している場合には、`refresh()`メソッドをオーバーライドしておけば、トークンの期限切れにもちゃっかり対処できます。

相手先にもよりますが、リフレッシュトークン取得の方式が整っているのであれば、相手に従ってHTTPコールアウトを準備します。が、こちらのように、再度`OAuth`の認可フローをまわして再取得することでも同様の動作になります。

返り値は、`access_token` と`token_type` だけで良いです。

```java
    override global Auth.OAuthRefreshResult refresh(Map<String,String> authProviderConfiguration, String refreshToken) {
        TokenResponse tokenResponse = retrieveToken(authProviderConfiguration);
        return new Auth.OAuthRefreshResult(tokenResponse.access_token, tokenResponse.token_type);
    }
```

それ以外に必要な構成・設定については[netatmo Weather Station のデータを定期的に Salesforce に取り込んでみた](https://qiita.com/hrk623/items/129eb922bb7d9557c721)に詳しいので、ここではあくまでもソースコードの内容だけにフォーカスしました( ^ω^ )

---

## 参考記事

- [netatmo Weather Station のデータを定期的に Salesforce に取り込んでみた](https://qiita.com/hrk623/items/129eb922bb7d9557c721)
- [Salesforce で Client Credentials フローな指定ログイン情報を使いたい](https://qiita.com/sho7650/items/44db491f94e993ad5a1d)