LoginSignup
8
6

More than 1 year has passed since last update.

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

Last updated at Posted at 2020-08-11

Salesforceでは、「Salesforce で Client Credentials フローな指定ログイン情報を使いたい」で紹介したように、「認証プロバイダ」を利用して、外部システムとの認証・シングルサインオンを容易にしてくれる機能があります。

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

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

(Winter'22現在)

よーく「外部認証プロバイダの設定」を見ると、OAuth をサポートしているが OpenID Connect プロトコルをサポートしていないサービスプロバイダというものがあるので、なんだ「OAuth」対応してるじゃんと思ってリンクを踏むと「カスタム外部認証プロバイダの作成」と書いてあるではありませんか。そう、OAuthの認証プロバイダは、現時点では己の手でソースコードレベルで作成が必要です。

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

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

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

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 IDClient 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 ユーザと連携できた風にしています。


    // 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';

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

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

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

    // 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で指定された、次の4つのメソッドの作成は必須です。

  • 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()

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

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

    global String getCustomMetadataType() {
        return CUSTOM_MDT_NAME;
    }

global PageReference initiate(Map, String)

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

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

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

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

    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, Auth.AuthProviderCallbackState)

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

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

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

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

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

もうひとつ必須 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-typePOSTメソッドでHTTPコールアウトします。返却された JSONをパースしてTokenResponse オブジェクト型に組み直してオブジェクトとして return します。このdeserializeメソッド便利ですね、ありがたい。

    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, Auth.AuthProviderTokenResponse)

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

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

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

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

    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(MapString)

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

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

返り値は、access_tokentoken_type だけで良いです。

    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 に取り込んでみたに詳しいので、ここではあくまでもソースコードの内容だけにフォーカスしました( ^ω^ )


参考記事

8
6
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
6