LoginSignup
14
16

More than 3 years have passed since last update.

Salesforce Platform - Force.com における OAuth 認証フローのとりまとめ【実践篇】

Last updated at Posted at 2020-12-30

冒頭で、SalesforceのOAuth認証フローをどこかのタイミングで一度まとめようとしています。Salesforceヘルプで記載の内容をベースにして行った検証を記録する形になります。Asset Token Flow まで時間を取れなかったため、別途で補足します。

この数年の投稿は全部OAuth関連のため、Force.com上で新しいOAuth認証フローが公開されない限り、実践編の投稿を最後とします。

この記事は Salesforce Platform Advent Calendar 2020 - Qiita 第23日目の投稿です。

はじめに

Salesforceで提供するOAuth認証フローは、IETF OAuth Working Groupが開発した業界標準の認証プロトコル「OAuth2.0」に準じます。よって、元ネタは「oauth.net」または IETF (Internet Engineering Task Force) のWeb Authorization Protocol (oauth) の「Documents」で公開されています。

投稿した時点、Force.com上で公開した主なフローは以下です。順番はSalesforceヘルプと異なりますが、難易度によっておすすめの学習順を付けました。

フロー名 画面認証 Refresh Token
発行・サポート
学習順
OAuth 2.0 Web Server Flow Yes Level-1
OAuth 2.0 User-Agent Flow Yes Level-1
OAuth 2.0 Username-Password Flow No Level-1
OAuth 2.0 Refresh Token Flow - Level-1
OAuth 2.0 Device Flow Yes Level-1
OAuth 2.0 JWT Bearer Flow No Level-2
OAuth 2.0 SAML Bearer Assertion Flow No Level-2
OAuth 2.0 Asset Token Flow (-) (-) Level-3
OpenID Connect Token Introspection - Level-1

本題

さて、これから本題に入ります。
検証にあたって使った機能やツールなどは以下です。

  • Salesforce 接続アプリケーション:OAuth認証の窓口、フローによって設定が多少異なる
  • Postman:REST API 検証ツール
  • OpenSSL:証明書発行
  • VSCode:Javaクライアントサンプルコードなどの開発環境

1. Web Server Flow

ユーザーアカウントの認証にしても、データ連携にしても、一番よく利用されるフローと思われます。Salesforce開発者ドキュメントでフロー図などの詳細説明があります。サマリは以下です。

Step 1. 認証コードを要求する (HTTP GET)

HTTP_GET
https://login.salesforce.com/services/oauth2/authorize
?client_id='<Consumer Key>'
&redirect_uri='<Callback URL>'
&response_type=code

Step 2. ユーザによるアクセスの認証および承認 (Browser)
ログインを通したら以下の画面を表示します。

Step 3. Salesforce による認証コードの付与 (Response by Callback URL)
image.png
Step 4. アクセストークンの要求 (HTTP POST)

Step 5. Salesforce によるアクセストークンの付与 (Response Body)

1.1. Salesforce 接続アプリケーションの設定

  • OAuth設定

    「Require Secret for Web Server Flow」にチェックが入っていなければ、「Step 4. アクセストークンの要求 (HTTP POST)」のパラメータの中、「client_secret」を設定しなくてもAccess Token及びRefresh Tokenを取得できます。

  • OAuthポリシー

1.2. Web Server Flowの注意点

  • URLで返された認証コードは、URLエンコードされたものなので、デコードにしないとAccess Tokenを取得する際に、レスポンスのBodyでエラーが返されました。例えば、エンコードされた「%30D」に対してデコードしたら「=」に変換されます。

  • 一定な時間を経過すると、認証コードは失効なので、再度認証コードを取得する必要です。


2. User-Agent Flow

Salesforceヘルプで「このフローは、OAuth 2.0 暗黙的許可種別(OAuth 2.0 implicit grant type)を使用します。」と明記されています。しかし、OAuth2.0によると、「Implicit Flow」に対して「Legacy」が付けられました。今後どのように処置されるのを気になります。

Step 1. 認証エンドポイントへのリダイレクト (HTTP GET)

HTTP_GET
https://login.salesforce.com/services/oauth2/authorize
?response_type=token+id_token
&client_id='<Consumer Key>'
&redirect_uri='<Callback URL>'
&scope=api%20refresh_token%20openid
&nonce=somevalue

Step 2. ユーザによるアクセスの認証および承認 (Browser)

Step 3. Salesforce によるアクセストークンの付与 (Browser)
image.png
「1. Web Server Flow」 の Response Body と似たような内容をURLで返しました。

Response_by_URL
https://login.salesforce.com/services/oauth2/success
#access_token='<access_token>'
&refresh_token='<refresh_token>'
&instance_url=https%3A%2F%2Fap.salesforce.com
&id=https%3A%2F%2Flogin.salesforce.com%2Fid%2F00D100000003GXbEAM%2F00510000007rhhQAAQ
&issued_at=1608908106950
&signature='<signature>'
&id_token='<id_token>'
&scope=api+refresh_token+openid
&token_type=Bearer

2.1. Salesforce 接続アプリケーションの設定

  • OAuth設定
  • OAuthポリシー

3. Username-Password Flow

一番シンプルなOAuth認証フローです。画面操作の必要がないため、ユーザーアカウント認証でなくデータ連携に向いてます。
接続アプリケーションの「Consumer Key」、「Consumer Secret」、特定なユーザーのユーザー名とパスワードを合わせて認証する仕組です。「JWT Bear Flow」と「SAML Bear Flow」のような証明書で署名して認証する仕組みと比べてセキュリティ面が弱いです。
このフローも、OAuth2.0によってLegacyが付けられました。

3.1. Salesforce 接続アプリケーションの設定
「1. Web Server Flow」または「2. User-Agent Flow」と同様にして問題ありません。


4. Refresh Token Flow

このフローでは、Web Server Flow または User-Agent Flow、Device Flow によって発行された Access Token を更新するだけなので、あまりフローの感じがしないです。HTTP GET と POST の両方をサポートします。

  • HTTP GET
  • HTTP POST

5. Device Flow

画面操作で確認コードを認証する必要のため、入力機能や表示機能が限定されたIoTデバイスで利用可能です。
サマリは以下です。

Step 1. デバイス要求認証 (HTTP POST) 及び Salesforce が確認コードを返す (Response body)

  • HTTP POST:必要なパラメータを「Params」と「Body」のどちらに指定しても認証コードを取得できる
    • 「Params」に指定する場合
    • 「Body」に指定する場合
  • HTTP GET の場合、エラーになる

Step 2. ユーザによる認証および承認 (Browser)

  • リンク「verification_uri」を開いて「user_code」を認証する

Step 3. デバイスによるトークンエンドポイントのポーリングと Salesforce によるアクセストークンの付与 (HTTP POST)

  • 「Step 1.」と同様、必要なパラメータを「Params」と「Body」のどちらに指定しても Access Token を取得できる (Step 1.で取得した「device_code」を「code」に指定する)
    • 「Params」に指定する場合
    • 「Body」に指定する場合
    • 注意:デバイスの確認コード「device_code」一回の発行で(有効期限10分)、一度でしか利用できないため、再度 Access Token を取得する場合「device_code」の再発行及び確認コード(user_code)の認証が必要です。

5.1. Salesforce 接続アプリケーションの設定

  • OAuth設定
  • OAuthポリシー

6. JWT Bearer Flow

個人的にとても好きなフローですが、証明書の発行、署名及びアサーションの生成がやや煩雑です。SSLサーバー証明書で JWT に署名し、Base64エンコードで認証用のアサーションを作成します。
画面操作の必要がありません。ETLのTalendやCI/CDのGitlabなどのツールでは、Salesforceと連携する際に JWT Bearer Flow を使用します。
JWTの構成について、以下の属性でJSON形式となります。

Step 1. OpenSSLで証明書を作成する

  • 証明証作成のコマンド
$ openssl genrsa 2048 > server_advent_2020.key
$ openssl req -new -key server_advent_2020.key > server_advent_2020.csr
$ openssl x509 -days 3650 -req -signkey server_advent_2020.key < server_advent_2020.csr > server_advent_2020.crt
  • JKS 形式キーストア作成のコマンド
$ openssl pkcs12 -export -in server_advent_2020.crt -inkey server_advent_2020.key -out domain_advent_2020.p12
$ keytool -importkeystore -srckeystore domain_advent_2020.p12 -srcstoretype PKCS12 -destkeystore server_advent_2020.jks -deststoretype JKS

キーストアパスワードの入力について
openssl pkcs12 を実行して求められたパスワード(Export Password)は、keytoolの「ソース・キーストアのパスワードを入力してください」のところで必要となります。そして、keytool を実行して求められる出力先キーストアのパスワードも、次の「Step 2. 署名、アサーションの作成」の Java クライアントで使用します。

Step 2. 署名、アサーションの作成

  • 以下のJWTExampleのメソッドgeneralToken

Step 3. Access Token 取得

  • 以下のJWTExampleのメソッドrequestAccessTokenGETrequestAccessTokenPOST
JWTExample
import java.io.FileInputStream;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpRequest.BodyPublishers;
import java.net.http.HttpResponse.BodyHandlers;
import java.security.KeyStore;
import java.security.PrivateKey;
import java.security.Signature;
import java.text.MessageFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.commons.codec.binary.Base64;

public class JWTExampleAdvent2020 {
  private static String client_id = "<Consumer Key>";
  private static String userName = "<Username>"; // eg. advent@salesforce.com, the username who has Salesforce profile 'System Administrator'.
  private static String loginUrl = "https://login.salesforce.com";
  private static String jksPath = "<Path>"; // Full path of JKS file.
  // Keytoolコマンド実行時のソース・キーストア(-srckeystore)のパスワード
  private static String srcPassword = "<Password>"; // The password of command 'openssl pkcs12'
  // Keytoolコマンド実行時の出力先キーストアのパスワード
  private static String ksPassword = "<Password>"; // The password of command 'keytool'

  /*
   * Step 2. 署名、アサーションの作成
   */
  public static String generalToken(String clientId, String userName, String loginUrl, String jksPath,
      String ksPassword, String srcPassword, int exp_minutes) {

    String header = "{\"alg\":\"RS256\"}";
    String claimTemplate = "'{'\"iss\": \"{0}\", \"sub\": \"{1}\", \"aud\":\"{2}\", \"exp\": \"{3}\"'}'";
    StringBuffer token = new StringBuffer();

    try {
      // * 1. Encode the JWT Header and add it to our string to sign
      token.append(Base64.encodeBase64URLSafeString(header.getBytes("UTF-8")));
      // Separate with a period
      token.append(".");
      // * 2. Create the JWT Claims Object
      String[] claimArray = new String[4];
      claimArray[0] = clientId;
      claimArray[1] = userName;
      claimArray[2] = loginUrl;
      claimArray[3] = Long.toString((System.currentTimeMillis() / 1000) + 60 * exp_minutes);
      MessageFormat claims;
      claims = new MessageFormat(claimTemplate);
      String payload = claims.format(claimArray);
      // * 3. Add the encoded claims object
      token.append(Base64.encodeBase64URLSafeString(payload.getBytes("UTF-8")));
      // * 4. Load the private key from a keystore
      KeyStore keystore = KeyStore.getInstance("JKS");
      keystore.load(new FileInputStream(jksPath), ksPassword.toCharArray());
      PrivateKey privateKey = (PrivateKey) keystore.getKey("1", srcPassword.toCharArray());
      // * 5. Sign the JWT Header + "." + JWT Claims Object
      Signature signature = Signature.getInstance("SHA256withRSA");
      signature.initSign(privateKey);
      signature.update(token.toString().getBytes("UTF-8"));
      String signedPayload = Base64.encodeBase64URLSafeString(signature.sign());
      // Separate with a period
      token.append(".");
      // * 6. Add the encoded signature
      token.append(signedPayload);

      return token.toString();
    } catch (Exception e) {
      e.printStackTrace();
      return null;
    }
  }

  /*
   * Step 3. Access Token 取得 (HTTP GET)
   */
  public static String requestAccessTokenGET(String token) {
    String bodyStr = "";
    HttpClient client = HttpClient.newBuilder().version(HttpClient.Version.HTTP_2).build();
    String uriStr = "https://login.salesforce.com/services/oauth2/token"
        + "?grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" + "&assertion=" + token;
    HttpRequest request = HttpRequest.newBuilder().GET().uri(URI.create(uriStr)).build();

    try {
      HttpResponse<String> response = client.send(request, BodyHandlers.ofString());
      // Body
      bodyStr = response.body();
    } catch (IOException e) {
      e.printStackTrace();
    } catch (InterruptedException e) {
      e.printStackTrace();
    }
    return bodyStr;
  }

  /*
   * Step 3. Access Token 取得 (HTTP POST)
   */
  public static String requestAccessTokenPOST(String token) {
    String bodyStr = "";
    String uriStr = "https://login.salesforce.com/services/oauth2/token";
    Map<String, Object> params = new LinkedHashMap<>();
    params.put("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer");
    params.put("assertion", token);

    StringBuilder postData = new StringBuilder();
    try {
      for (Map.Entry<String, Object> param : params.entrySet()) {
        if (postData.length() != 0)
          postData.append('&');
        postData.append(URLEncoder.encode(param.getKey(), "UTF-8"));
        postData.append('=');
        postData.append(URLEncoder.encode(String.valueOf(param.getValue()), "UTF-8"));
      }

      HttpClient client = HttpClient.newBuilder().build();
      HttpRequest request = HttpRequest.newBuilder().uri(URI.create(uriStr))
          .header("Content-Type", "application/x-www-form-urlencoded")
          .POST(BodyPublishers.ofString(postData.toString())).build();

      HttpResponse<String> response = client.send(request, BodyHandlers.ofString());
      // Body
      bodyStr = response.body();
    } catch (UnsupportedEncodingException e) {
      e.printStackTrace();
    } catch (IOException e) {
      e.printStackTrace();
    } catch (InterruptedException e) {
      e.printStackTrace();
    }
    return bodyStr;
  }

  public static String getAccessToken(String json) {
    String accessToken = "";
    ObjectMapper mapper = new ObjectMapper();
    // convert JSON string to Map
    Map<String, String> map;
    try {
      map = mapper.readValue(json, new TypeReference<Map<String, String>>(){});
      accessToken = map.get("access_token");
    } catch (JsonMappingException e) {
      e.printStackTrace();
    } catch (JsonProcessingException e) {
      e.printStackTrace();
    }
    return accessToken;
  }

  public static void main(String[] args) {

    String token = generalToken(client_id, userName, loginUrl, jksPath, ksPassword, srcPassword, 3);
    if (token != null) {
      String bodyStrGet = requestAccessTokenGET(token);
      System.out.println();
      System.out.println("-----------------------------------------");
      System.out.println("Response Body by GET: " + bodyStrGet);
      String accessTokenGet = getAccessToken(bodyStrGet);
      System.out.println("Access Token by GET: " + accessTokenGet);

      String bodyStrPost = requestAccessTokenPOST(token);
      System.out.println();
      System.out.println("-----------------------------------------");
      System.out.println("Response Body by POST: " + bodyStrPost);
      String accessTokenPost = getAccessToken(bodyStrPost);
      System.out.println("Access Token by POST: " + accessTokenPost);
    }
  }
}
  • 実行結果

6.1. Salesforce 接続アプリケーションの設定

  • OAuth設定
  • OAuthポリシー

6.2. さらに
以下のお二方が書いた記事には、それぞれの観点からJWT Bearer Flowの詳細を記載しています。是非参照してください。

宮本さんの記事はCTAのレビューボード向きのイメージです。目指していれば是非確認してみてください。


7. SAML Bearer Assertion Flow

Bearer Flow なので、JWT Bearer Flow との違いは、JWTアサーションをSAMLアサーションで入れ替えるだけです。ただ、ユースケースを見当たらないなので、どの場合このフローを利用するのをご存知の方はコメント欄に投稿いただければ幸いです。

7.1. SAMLアサーションとJWTアサーションパラメータの比較

JWT SAML 備考
iss ISSUER client_id
sub SUBJECT Salesforce ユーザのユーザ名
aud AUDIENCE https://login.salesforce.com または https://test.salesforce.com
- RECIPIENT 次の URL のいずれか
https://login.salesforce.com/services/oauth2/token
https://test.salesforce.com/services/oauth2/token
・https:///services/oauth2/token
exp - アサーションの有効時間
- NOT_BEFORE アサーションの有効期間 - 開始
- NOT_ON_OR_AFTER アサーションの有効期間 - 終了

7.2. SAML テンプレート

preCannonicalizedResponse
<?xml version="1.0" encoding="UTF-8"?>
<saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="ASSERTION_ID" IssueInstant="NOT_BEFORE" Version="2.0">
    <saml:Issuer Format="urn:oasis:names:tc:SAML:2.0:nameid-format:entity">ISSUER</saml:Issuer>
    <saml:Subject>
        <saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified">SUBJECT</saml:NameID>
        <saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
            <saml:SubjectConfirmationData NotOnOrAfter="NOT_ON_OR_AFTER" Recipient="RECIPIENT"></saml:SubjectConfirmationData>
        </saml:SubjectConfirmation>
    </saml:Subject>
    <saml:Conditions NotBefore="NOT_BEFORE" NotOnOrAfter="NOT_ON_OR_AFTER">
        <saml:AudienceRestriction>
            <saml:Audience>AUDIENCE</saml:Audience>
        </saml:AudienceRestriction>
    </saml:Conditions>
    <saml:AuthnStatement AuthnInstant="NOT_BEFORE">
        <saml:AuthnContext>
            <saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified</saml:AuthnContextClassRef>
        </saml:AuthnContext>
    </saml:AuthnStatement>
</saml:Assertion>
preCannonicalizedSignedInfo
<ds:SignedInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
    <ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"></ds:CanonicalizationMethod>
    <ds:SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"></ds:SignatureMethod>
    <ds:Reference URI="#ASSERTION_ID">
        <ds:Transforms>
            <ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"></ds:Transform>
            <ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"></ds:Transform>
        </ds:Transforms>
        <ds:DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"></ds:DigestMethod>
        <ds:DigestValue>DIGEST</ds:DigestValue>
    </ds:Reference>
</ds:SignedInfo>
signatureBlock
<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">SIGNED_INFO<ds:SignatureValue>SIGNATURE_VALUE</ds:SignatureValue>
</ds:Signature>
<saml:Subject>

7.3. Javaクライアントサンプル

SAMLBearerAssertionExample
import java.io.FileInputStream;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpRequest.BodyPublishers;
import java.net.http.HttpResponse.BodyHandlers;
import java.security.InvalidKeyException;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.Signature;
import java.security.SignatureException;
import java.security.UnrecoverableKeyException;
import java.security.cert.CertificateException;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.TimeZone;
import org.apache.commons.codec.binary.Base64;
import org.apache.commons.codec.binary.Hex;

public class SAMLBearerAssertionExampleAdvent2020 {
  private String issuer = "<Consumer Key>";
  private String subject = "<Username>"; // eg. advent@salesforce.com, the username who has Salesforce profile 'System Administrator'.
  private String audience = "https://login.salesforce.com";
  private String action = "https://login.salesforce.com/services/oauth2/token";
  private String notBefore;
  private String notOnOrAfter;
  private String assertionId;
  private static String jksPath = "<Path>"; // Full path of JKS file.
  // Keytoolコマンド実行時のソース・キーストア(-srckeystore)のパスワード
  private static String srcPassword = "<Password>"; // The password of command 'openssl pkcs12'
  // Keytoolコマンド実行時の出力先キーストアのパスワード
  private static String ksPassword = "<Password>"; // The password of command 'keytool'
  // SAML Template
  private String preCannonicalizedResponse = "<saml:Assertion xmlns:saml=\"urn:oasis:names:tc:SAML:2.0:assertion\" ID=\"ASSERTION_ID\" IssueInstant=\"NOT_BEFORE\" Version=\"2.0\"><saml:Issuer Format=\"urn:oasis:names:tc:SAML:2.0:nameid-format:entity\">ISSUER</saml:Issuer><saml:Subject><saml:NameID Format=\"urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified\">SUBJECT</saml:NameID><saml:SubjectConfirmation Method=\"urn:oasis:names:tc:SAML:2.0:cm:bearer\"><saml:SubjectConfirmationData NotOnOrAfter=\"NOT_ON_OR_AFTER\" Recipient=\"RECIPIENT\"></saml:SubjectConfirmationData></saml:SubjectConfirmation></saml:Subject><saml:Conditions NotBefore=\"NOT_BEFORE\" NotOnOrAfter=\"NOT_ON_OR_AFTER\"><saml:AudienceRestriction><saml:Audience>AUDIENCE</saml:Audience></saml:AudienceRestriction></saml:Conditions><saml:AuthnStatement AuthnInstant=\"NOT_BEFORE\"><saml:AuthnContext><saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified</saml:AuthnContextClassRef></saml:AuthnContext></saml:AuthnStatement></saml:Assertion>";
  private String preCannonicalizedSignedInfo = "<ds:SignedInfo xmlns:ds=\"http://www.w3.org/2000/09/xmldsig#\"><ds:CanonicalizationMethod Algorithm=\"http://www.w3.org/2001/10/xml-exc-c14n#\"></ds:CanonicalizationMethod><ds:SignatureMethod Algorithm=\"http://www.w3.org/2000/09/xmldsig#rsa-sha1\"></ds:SignatureMethod><ds:Reference URI=\"#ASSERTION_ID\"><ds:Transforms><ds:Transform Algorithm=\"http://www.w3.org/2000/09/xmldsig#enveloped-signature\"></ds:Transform><ds:Transform Algorithm=\"http://www.w3.org/2001/10/xml-exc-c14n#\"></ds:Transform></ds:Transforms><ds:DigestMethod Algorithm=\"http://www.w3.org/2000/09/xmldsig#sha1\"></ds:DigestMethod><ds:DigestValue>DIGEST</ds:DigestValue></ds:Reference></ds:SignedInfo>";
  private String signatureBlock = "<ds:Signature xmlns:ds=\"http://www.w3.org/2000/09/xmldsig#\">SIGNED_INFO<ds:SignatureValue>SIGNATURE_VALUE</ds:SignatureValue></ds:Signature><saml:Subject>";
  // millisecs
  static final long ONE_MINUTE_IN_MILLIS = 60000;
  SimpleDateFormat dateTimeFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'");

  public SAMLBearerAssertionExampleAdvent2020() {
    Calendar utcCal = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
    dateTimeFormat.setTimeZone(utcCal.getTimeZone());
    Date utcDate = utcCal.getTime();
    long nowLong = utcDate.getTime();
    Date notBeforeDt = new Date(nowLong - 2 * ONE_MINUTE_IN_MILLIS);
    notBefore = dateTimeFormat.format(notBeforeDt);
    Date notOnOrAfterDt = new Date(nowLong + 5 * ONE_MINUTE_IN_MILLIS);
    notOnOrAfter = dateTimeFormat.format(notOnOrAfterDt);

    try {
      Double random = Math.random();
      MessageDigest digest = MessageDigest.getInstance("SHA-256");
      assertionId = new String(Hex.encodeHex(digest.digest(("assertion" + random).getBytes())));
    } catch (NoSuchAlgorithmException e) {
      e.printStackTrace();
    }
  }

  public String getSAMLResult() {
    preCannonicalizedResponse = preCannonicalizedResponse.replaceAll("ASSERTION_ID", assertionId);
    preCannonicalizedResponse = preCannonicalizedResponse.replaceAll("ISSUER", issuer);
    preCannonicalizedResponse = preCannonicalizedResponse.replaceAll("AUDIENCE", audience);
    preCannonicalizedResponse = preCannonicalizedResponse.replaceAll("RECIPIENT", action);
    preCannonicalizedResponse = preCannonicalizedResponse.replaceAll("SUBJECT", subject);
    preCannonicalizedResponse = preCannonicalizedResponse.replaceAll("NOT_BEFORE", notBefore);
    preCannonicalizedResponse = preCannonicalizedResponse.replaceAll("NOT_ON_OR_AFTER", notOnOrAfter);

    try {
      // * 1. Prepare the Digest
      MessageDigest digest = MessageDigest.getInstance("SHA-1");
      byte[] result = digest.digest(preCannonicalizedResponse.getBytes());
      String digestString = Base64.encodeBase64String(result);
      // * 2. Prepare the SignedInfo
      preCannonicalizedSignedInfo = preCannonicalizedSignedInfo.replaceAll("ASSERTION_ID", assertionId);
      preCannonicalizedSignedInfo = preCannonicalizedSignedInfo.replaceAll("DIGEST", digestString);
      byte[] input = preCannonicalizedSignedInfo.getBytes("UTF-8");
      KeyStore keystore = KeyStore.getInstance("JKS");
      keystore.load(new FileInputStream(jksPath), ksPassword.toCharArray());
      PrivateKey privateKey = (PrivateKey) keystore.getKey("1", srcPassword.toCharArray());
      // * 3. Sign the SignedInfo
      Signature signature = Signature.getInstance("SHA1withRSA");
      signature.initSign(privateKey);
      signature.update(input);
      String signatureString = Base64.encodeBase64String(signature.sign());
      // * 4. Prepare the signature block
      signatureBlock = signatureBlock.replaceAll("SIGNED_INFO", preCannonicalizedSignedInfo);
      signatureBlock = signatureBlock.replaceAll("SIGNATURE_VALUE", signatureString);
    } catch (SignatureException e) {
      e.printStackTrace();
    } catch (InvalidKeyException e) {
      e.printStackTrace();
    } catch (NoSuchAlgorithmException e) {
      e.printStackTrace();
    } catch (UnsupportedEncodingException e) {
      e.printStackTrace();
    } catch (CertificateException e) {
      e.printStackTrace();
    } catch (KeyStoreException e) {
      e.printStackTrace();
    } catch (UnrecoverableKeyException e) {
      e.printStackTrace();
    } catch (IOException e) {
      e.printStackTrace();
    }

    preCannonicalizedResponse = preCannonicalizedResponse.replaceAll("<saml:Subject>", signatureBlock);
    return preCannonicalizedResponse;
  }

  public void postSAML() {
    String bodyStr = "";
    String saml = getSAMLResult();

    try {
      String assertion = Base64.encodeBase64URLSafeString(saml.getBytes("UTF-8"));
      Map<String, Object> params = new LinkedHashMap<>();
      params.put("grant_type", "urn:ietf:params:oauth:grant-type:saml2-bearer");
      params.put("assertion", assertion);

      StringBuilder postData = new StringBuilder();
      for (Map.Entry<String, Object> param : params.entrySet()) {
        if (postData.length() != 0)
          postData.append('&');
        postData.append(URLEncoder.encode(param.getKey(), "UTF-8"));
        postData.append('=');
        postData.append(URLEncoder.encode(String.valueOf(param.getValue()), "UTF-8"));
      }

      HttpClient client = HttpClient.newBuilder().build();
      HttpRequest request = HttpRequest.newBuilder().uri(URI.create(action))
          .header("Content-Type", "application/x-www-form-urlencoded")
          .POST(BodyPublishers.ofString(postData.toString())).build();
      HttpResponse<String> response = client.send(request, BodyHandlers.ofString());
      bodyStr = response.body();
      System.out.println();
      System.out.println("-----------------------------------------");
      System.out.println("Response Body by POST: " + bodyStr);
    } catch (UnsupportedEncodingException e) {
      e.printStackTrace();
    } catch (IOException e) {
      e.printStackTrace();
    } catch (InterruptedException e) {
      e.printStackTrace();
    }
  }

  public static void main(String[] args) {
    SAMLBearerAssertionExampleAdvent2020 samlExample = new SAMLBearerAssertionExampleAdvent2020();
    samlExample.postSAML();
  }
}
  • 実行結果

7.4. Salesforce 接続アプリケーションの設定
「6. JWT Bearer Flow」と同様です。


8. Asset Token Flow

別途で補足します。


Appendix 1. OpenID Connect Token Introspection

厳密に言うと、OAuth認証フローではないですが、Access TokenやRefresh Tokenなどの状態を確認する機能なので、紹介したいです。

例 1. 無効になったAccess Token

例 2. 有効な Refresh Token

例 3. 基本認証(Basic Authentication)の利用
一般的なBasic認証では、ユーザー名とパスワードの組みをコロン ":" でつなぎ、Base64でエンコードして送信します。Salesforce OAuth認証の基本認証では、ユーザー名とパスワードでなく、「Consumer Key」と「Consumer Secret」を組むこととなります。Base64でエンコードのサンプルは以下です。

BasicAuthorization
import java.io.UnsupportedEncodingException;
import org.apache.commons.codec.binary.Base64;

public class BasicAuthorization {
  public static void main(String[] args) {
    String client_id = "<Consumer Key>";
    String client_secret = "<Consumer Secret>";
    String basic = client_id + ":" + client_secret;
    System.out.println("basic: " + basic);

    try {
      String token = Base64.encodeBase64String(basic.getBytes("UTF-8"));
      System.out.println("Authorization Basic Token: " + token);
    } catch (UnsupportedEncodingException e) {
      e.printStackTrace();
    }
  }
}
  • ヘッダーで基本認証の設定
  • Access Token ステータスの確認

Appendix 1.1. Salesforce 接続アプリケーションの設定

  • OAuth設定
  • OAuthポリシー

最後に

OAuth認証を通して REST API データ連携の事例がよく見かけていますが、ユーザーアカウント認証の事例は、私の経験上でまだないです。企業内部ユーザーの認証管理は、OAuthよりSSOのほうが向いてます。

OAuth認証フローは、Salesforce特有なものではないですが、「OAuth アクセスポリシーの管理」のようなSalesforceならでは的な機能があります。ただし、プラットフォームとしてForce.comが公開するOAuth認証フローの種類が充実しています。Salesforceで学んだOAuth関連の知識を他のプラットフォームでも活かせます。システムアーキテクトを目指すなら、必修科目の一つとなります。

他の参考資料

1. Salesforce Identity - Github
OAuth関連だけでなく、Salesforceプラットフォーム認証系のサンプルソースコードが大量にあります。
https://github.com/salesforceidentity

2. 株式会社 Authlete の 川崎 貴彦さんの技術ブログ
ほとんど認証系の記事が書かれています。図形や動画の説明が多く記載され、非常に勉強になりました。
https://qiita.com/TakahikoKawasaki

3. 川崎 貴彦さんの英語の技術ブログ
「2.」とほとんど同じ内容ですが、英語に慣れた方は是非こちらへ
https://darutk.medium.com/

14
16
0

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
14
16