冒頭で、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)
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)
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を取得できます。
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)
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)
「1. Web Server Flow」 の Response Body と似たような内容を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 接続アプリケーションの設定
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 の両方をサポートします。
5. Device Flow
画面操作で確認コードを認証する必要のため、入力機能や表示機能が限定されたIoTデバイスで利用可能です。
サマリは以下です。
Step 1. デバイス要求認証 (HTTP POST) 及び Salesforce が確認コードを返す (Response body)
Step 2. ユーザによる認証および承認 (Browser)
Step 3. デバイスによるトークンエンドポイントのポーリングと Salesforce によるアクセストークンの付与 (HTTP POST)
- 「Step 1.」と同様、必要なパラメータを「Params」と「Body」のどちらに指定しても Access Token を取得できる
(Step 1.で取得した「device_code」を「code」に指定する)
5.1. Salesforce 接続アプリケーションの設定
6. JWT Bearer Flow
個人的にとても好きなフローですが、証明書の発行、署名及びアサーションの生成がやや煩雑です。SSLサーバー証明書で JWT に署名し、Base64エンコードで認証用のアサーションを作成します。
画面操作の必要がありません。ETLのTalendやCI/CDのGitlabなどのツールでは、Salesforceと連携する際に JWT Bearer Flow を使用します。
JWTの構成について、以下の属性でJSON形式となります。
- iss: 接続アプリケーション(どこから)
- aud: Salesforce ユーザーまたは Salesforce コミュニティユーザーのユーザー名(誰)
- sub: https://login.salesforce.com、https://test.salesforce.com、または https://community.force.com/customers に固定
- exp: 有効時間(いつ)
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
のメソッドrequestAccessTokenGET
とrequestAccessTokenPOST
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 接続アプリケーションの設定
6.2. さらに
以下のお二方が書いた記事には、それぞれの観点からJWT Bearer Flowの詳細を記載しています。是非参照してください。
- Shinichi Tomita さん ( @stomita ) が書いた実践的な「OAuth2 JWT Bearer Token フローを使ってSalesforceへアクセスする」
- 宮本さん ( @takahito0508 ) が書いた論理的な「Salesforce の JWT ベアラーフローの図を書いてみる」
宮本さんの記事は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 テンプレート
<?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>
<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>
<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クライアントサンプル
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でエンコードのサンプルは以下です。
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();
}
}
}
Appendix 1.1. Salesforce 接続アプリケーションの設定
最後に
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/