Service Agent に認証済みユーザの情報を提供する

こんにちは。エンジニアの山下です。

Salesforce の四半期決算で Agentforce の導入事例が 5000 件を超えたことが発表されるなど、Salesforce の LLM 活用の波を感じるニュースが増えてきましたね。というわけで、今回は Agentforce の中核機能の1つである Service Agent について書きたいと思います。

Service Agent のユースケースは色々と考えられそうですが、典型例となりそうなのが個人向けにカスタマイズされたアドバイザーです。ユーザを取り囲む文脈を読み取って適切なサポートをしてくれるデジタル秘書ないしはコンサルタントのようなイメージが近いでしょうか。実際にどのようなサービスになるにせよ、特定の個人に最適化されたカスタム UX を Service Agent で提供したいという需要はそれなりにありそうです。

このようなサービスを構築するにはユーザの認証が必要です。また、認証済みユーザの情報を Service Agent が参照できるように実装されていなければなりません。実は Salesforce にはこれを実現するための仕組みがあるのですが、その仕組みは Service Agent ではなく、それを外部に公開する際に経由する Omni-channel の方に用意されています。

以前の記事 でも触れましたが、この2つのサービスは密接に関連するにも関わらず、ドキュメントが分断されていて情報を探すのが非常に煩雑という難点があり、例によって筆者も必要な情報を探すのに色々なドキュメントを探し回る羽目になりました。

というわけで、第2第3の被害者が出る前に、Service Agent でユーザにカスタム UX を提供する方法について記事にまとめておこうと思います。

なお、Service Agent を API 経由で使用する方法については 以前の記事 で述べているので、この記事では述べません。従って、Service Agent およびその API での利用に必要な Omni-channel に関連するリソースは既に全て作成済みであると仮定します。

概要

上で Omni-channel にはユーザ認証をサポートする仕組みがあると述べましたが、これは具体的には JWKS を使った JWT の検証機能です。この機能は認証サーバによって署名された JWT の検証を行うためのもので、Omni-channel 自身がユーザ認証機能を持つわけではありません。従って、この機能の利用には別途認証サーバが必要になります。

Omni-channel の JWKS による JWT 検証は以下のように動作します。

  1. ユーザが認証サーバにログインする
  2. 認証サーバがアクセストークンとして JWT をユーザに返す
  3. ユーザが JWT を使って Omni-channel のアクセストークン取得 API を呼ぶ
  4. Omni-channel が認証サーバから JWKS を取得する
  5. Omni-channel が取得した JWKS を使って JWT を検証する
  6. Omni-channel のアクセストークン取得 API でアクセストークンが発行される

交付された JWT の検証に必要な JWKS を認証サーバから取得し、無事検証できれば認証 OK とするという構成になっています。この辺りの流れについては こちら の公式ドキュメントに記載されています。極めて単純ではありますが、JWT は非対称鍵暗号によって保護されているため、セキュリティ上は安心安全です。

認証サーバは JWT および JWKS をサポートしていれば何でも OK なのですが、今回は Salesforce の Connected App を使って認証を行い、それを Omni-channel と連携させるという方向でいきたいと思います。

実装の具体的な手順は以下になります。

  1. Connected App を作成する
  2. Messaging for In-App and Web に JWKS の取得先を登録する
  3. Messaging Settings でユーザ認証を有効にする
  4. Service Agent で認証済みユーザの情報を読み取るようにする

以下、上から順に実際の設定内容について述べていきます。

Connected App を作成する

今回はアクセストークンとして JWT を使用する必要があるため、そのための設定を適用した Connected App を作成する必要があります。ちなみに特に何の設定もしなかった場合、Salesforce では独自のエンコード方式によって作成されたアクセストークンが使用されます。

Connected App を作成し、以下の設定を行えば OK です。

  • Enable OAuth Settings にチェックを入れる
  • Selected OAuth Scopes に以下の Scope を追加する
    • Access the identity URL service (id, profile, email, address, phone)
    • Manage user data via APIs (api)
    • Manage user data via Web browsers (web)
    • Perform requests at any time (refresh_token, offline_access)
  • Issue JSON Web Token (JWT)-based access tokens for named users にチェックを入れる

一見 JWT の設定とは無関係そうな OAuth Scopes に言及されていますが、実はちゃんと関係があります。というのは、公式ドキュメント に以下のような但し書きがあるためです。

Compared to opaque access tokens, JWT-based access tokens have different functionality and limitations. For example, JWT-based access tokens can be used only to access REST APIs.

要するに JWT を使用すると通常のアクセストークンにはない機能制限が課されるという旨が書かれています。この機能制限の影響により、OAuth Scopes に大きすぎる権限を設定すると実行時にエラーとなるため、ここでは筆者が適用した Scope を明記しています。

上記 Connected App の作成後、さらに Setup > Manage Connected App から該当のアプリを選択して以下の内容を設定します。この画面は Connected App の作成時に開かれる画面とは全く別の画面で、ここでしか設定できない項目が存在します。界隈では有名なダークパターンです。

  • Issue JSON Web Token (JWT)-based access tokens にチェックを入れる

これで Connected App の設定は完了です。

Messaging for In-App and Web に JWKS の取得先を登録する

Setup > Messaging for In-App and Web User Verification に移動し、JWKS の取得先のエンドポイントを登録します。今回は Connected App を使用するので、JWKS の取得先は Salesforce 自身になります。

JSON Web Keysets の項にある New を押下すると以下のポップアップが表示されるので、必要事項を入力します。

JWKS の取得先のエンドポイントを入力する必要がありますが、Salesforce の場合は該当ドメイン/id/keys から取得できます。

Messaging Settings でユーザ認証を有効にする

ユーザ認証の仕上げとして Setup > Messaging Settings で該当チャネルにおけるユーザ認証を有効にします。一覧画面でチャネル名の横に表示されているドロップダウンリストから Edit を選択します。一覧からチャネル名を選択して開いた画面では以下の設定はできないのでご注意ください。界隈では有名なダ(略

設定項目の中に Add User Verification という項目があるのでチェックを入れて有効にします。

上記設定の保存後、今度は一覧から該当のチャネル名を選択して詳細確認画面に遷移します。こちらにはこちらで一覧から Edit を選択して遷移した画面では設定できない項目が存在します。界(略

Add User Verification が有効の場合、画面の最下部に User Verification Configuration という項目が表示されます。そちらの New を押下して設定を追加します。なお、こちらの項目は作成後に削除することができないので注意しましょう。一応、作成後に無効化することは可能なので機能上は問題ありませんが、一覧には項目が残存し続けます。この機能問題多くないか。

Keyset に前章で作成した JSON Web Keysets を選択すれば OK です。

ユーザ認証をテストしてみる

この時点で Messaging for In-App and Web にユーザ認証を追加する工程自体は完了しているので、ちゃんとユーザ認証が要求されるかどうかを確認してみましょう。

Messaging for In-App and Web にはアクセストークンを発行するための API が2種類あり、JWT 検証機能付きの generateAccessTokenForAuthenticatedUser と JWT 検証が不要なゲストユーザ向けの generateAccessTokenForUnauthenticatedUser を用途に応じて使い分ける構成になっています。今回は前者が成功し、後者が失敗することを確認します。

まずは JWT 検証機能付きの API が成功することを確認します。JWT さえ取得できれば認証方法は何でもよいのですが、今回は気分を盛り上げるために Salesforce のログイン画面を経由する Authorization Code Flow を使用したいと思います。

Python でテスト用スクリプトを書いてみます。

from dotenv import load_dotenv
load_dotenv(override=True)

import os
import requests
import urllib.parse as urlparse
import webbrowser
from http.server import BaseHTTPRequestHandler, HTTPServer


SF_INSTANCE_URL = os.environ['SF_INSTANCE_URL']
SF_CLIENT_ID = os.environ['SF_CLIENT_ID']
SF_CLIENT_SECRET = os.environ['SF_CLIENT_SECRET']

SF_MIAW_ORG_ID = os.environ['SF_MIAW_ORG_ID']
SF_MIAW_DEV_NAME = os.environ['SF_MIAW_DEV_NAME']
SF_MIAW_URL = os.environ['SF_MIAW_URL']


CODE_CHALLENGE = 'ASf42cqjec0s_0XwM1E7HROSkJCLxztVX7wtFLkoAOo'
CODE_VERIFIER = 'f3YW2kiXRiw6YdczpZNr3MwWGjTER2nnY0gN2HJ90ODmoWOoAonufVu81BWnJfhvanIYlDnYw8XsLiPgPVmlx8tJVjj6KpC79n94biNvX95ESVdkZYaKk_zCW_5gaWs0'
REDIRECT_URL = 'http://localhost:8080/callback'


def retrieve_jwt(code):
    res = requests.post(
        f'{SF_INSTANCE_URL}/services/oauth2/token',
        data={
            'grant_type': 'authorization_code',
            'code': code,
            'client_id': SF_CLIENT_ID,
            'client_secret': SF_CLIENT_SECRET,
            'redirect_uri': REDIRECT_URL,
            'code_verifier': CODE_VERIFIER,
        }
    )
    print(res.status_code)
    print(res.text)
    return res.json()['access_token']


def retrieve_authed_user_miaw_token(jwt):
    res = requests.post(
        f'{SF_MIAW_URL}/iamessage/api/v2/authorization/authenticated/access-token',
        headers={
            'Content-Type': 'application/json',
        },
        json={
            'orgId': SF_MIAW_ORG_ID,
            'esDeveloperName': SF_MIAW_DEV_NAME,
            'capabilitiesVersion': '1',
            'platform': 'Web',
            'authorizationType': 'JWT',
            'customerIdentityToken': jwt,
        },
    )
    print(res.status_code)
    print(res.text)
    return res.json()['accessToken']


# Create a simple HTTP server to handle the callback
class AuthenticationCodeHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        self.send_response(200)
        self.end_headers()

        parsed = urlparse.urlparse(self.path)
        params = urlparse.parse_qs(parsed.query)

        code = params.get('code', [None])[0]
        if code:
            jwt = retrieve_jwt(code)
            token = retrieve_authed_user_miaw_token(jwt)
            self.wfile.write(f'Authentication successful. Token: {token}'.encode())
        else:
            self.wfile.write(b'Failed to retrieve JWT.')


if __name__ == '__main__':
    endpoint = f'{SF_INSTANCE_URL}/services/oauth2/authorize'
    params = {
        'response_type': 'code',
        'client_id': SF_CLIENT_ID,
        'redirect_uri': REDIRECT_URL,
        'scope': 'api',
        'code_challenge': 'ASf42cqjec0s_0XwM1E7HROSkJCLxztVX7wtFLkoAOo',
        'code_challenge_method': 'S256',
    }
    url = f'{endpoint}?{urlparse.urlencode(params)}'
    webbrowser.open(url)

    httpd = HTTPServer(('localhost', 8080), AuthenticationCodeHandler)
    httpd.serve_forever()

このスクリプトを実行するとブラウザで Salesforce のログイン画面が開き、そこで認証を行うと Salesforce から取得した JWT に対して Omni-channel の JWT 検証機能付きアクセストークン発行 API がコールされるようになっています。

成功すると以下の文字列がブラウザの画面に表示されます。

Authentication successful. Token: {your-access-token}

ここまでくれば認証としては一区切りで、あとは Service Agent を調整して認証済みユーザの情報を取得する作業のみとなります。

ゲストユーザ用の API を呼んだ場合も確認しておきます。こちらは認証不要なので比較的簡単なスクリプトで検証できます。

if __name__ == '__main__':
    res = requests.post(
        f'{SF_MIAW_URL}/iamessage/api/v2/authorization/unauthenticated/access-token',
        headers={
            'Content-Type': 'application/json',
        },
        json={
            'orgId': SF_MIAW_ORG_ID,
            'esDeveloperName': SF_MIAW_DEV_NAME,
            'capabilitiesVersion': '1',
            'platform': 'Web',
        },
    )
    print(res.text)

実際のレスポンスが以下になります。

{
    "message": "Incorrect auth mode setup. The auth mode (User Verification setting) in the Messaging channel that's associated with the esDeveloperName is incorrectly selected (set to true). Set the auth mode to false.",
    "errorCode": "BAD_REQUEST"
}

ちゃんとエラーになっていますね。善哉善哉。

Service Agent で認証済みユーザの情報を読み取るようにする

Messaging for In-App and Web の JWT 検証を有効にすると、Service Agent との会話開始時に MessagingEndUser に認証済みユーザの情報が登録されるという嬉しい副次作用があります。MessagingEndUser は Service Agent のコンテキストとして提供される MessagingSession とリレーションを持つオブジェクトで、Service Agent から参照することが可能です。

具体的には MessagingEndUser の MessagingPlatformKey フィールドに以下の形式で認証済みユーザの JWT の sub の値が登録されます。

v2/iamessage/AUTH/{config-name}/uid:{sub}

config-name には Messaging Settings で設定した Configuration Name の名前が入ります。この文字列をパースして得られる sub の値は RFC

The "sub" (subject) claim identifies the principal that is the subject of the JWT.

と説明されており、ユーザを一意に特定するための情報が格納されることになっています。実際に格納される情報は実装依存ですが、Salesforce の JWT ではユーザ ID が格納されるようです。これを利用すればコンテキストからユーザ情報を引き出してカスタム UX を提供できそうですね。

というわけで、Service Agent から MessagingEndUser を参照するための設定を行っていきましょう。

権限の設定

Service Agent のユーザに適用される Einstein Agent プロファイルには標準オブジェクトに対するアクセス権限が一切設定されていません。そのため、事前に権限セット等で MessagingEndUser へのアクセス権限を付与しておく必要があります。

また、Service Agent にコンテキスト変数として提供される MessagingSession にはフィールド単位でのアクセス可否を定める設定があるので、そちらも確認しておく必要があります。この設定は Agent Builder からアクセスすることができます。

今回参照する MessagingEndUserId はデフォルトで MessagingSession に含まれるので特に設定変更の必要はありませんが、他のフィールドへのアクセスを要する際は忘れずに確認するようにしましょう。

アクションの作成

MessagingSession の MessagingEndUserId が参照できることさえ確認できればよいので、MessagingEndUserId の値を出力するよう Service Agent に指示するのが最も簡単に思えますが、実はこの方法ではうまくいきません。というのは、どうも Einstein Trust Layer が ID を直接出力することを禁止しているらしく、実際に試してみると Service Agent が回答を拒否してくるのです。

そのため、今回は MessagingEndUser の ID を入力として受け取る適当なアクションを用意し、このアクションの実行ログで MessagingEndUserId の内容が入力に指定されているかを確認するというやや遠回りな方法を採ります。

実装するアクションは何でもよいのですが、ID に対応する MessagingEndUser レコードを取得し、そのレコードの情報を返すという内容にしておきます。このアクションはフローで以下のように簡単に実装できます。

入力として受け取った ID で検索を行い、取得した検索結果を Text Template で整形して出力に放り込んでいるだけです。参考までに出力は以下の形式にしています。

name: {!retrieve_messaging_user.Name}
id: {!retrieve_messaging_user.MessagingPlatformKey}

このアクションに対して以下のような指示を Topics で与えておきます。

When a user greets, use CustomUXTest action and pass the value of MessagingSession.MessagingEndUserId found in the context variable. And then, follow the below instructions:

  1. Extract the name from the output.
  2. Say "Hello, {name}."

アクションの出力結果を参照させることで、さりげなくアクションを経由する必要性を注入しているのがミソです。

実行

必要な実装は全て済んだので、後は実行するだけです。Agent Builder の Playground は環境が特殊なためか MessagingEndUserId がうまく参照されないという問題があるため、Python スクリプトを使って API 経由で実行します。

ユーザ認証のテストの際に書いたスクリプトを少し改修します。

def create_conversation(access_token, conversation_id):
    return requests.post(
        f'{SF_MIAW_URL}/iamessage/api/v2/conversation',
        headers={
            'Content-Type': 'application/json',
            'Authorization': f'Bearer {access_token}',
        },
        json={
            'conversationId': conversation_id,
            'esDeveloperName': SF_MIAW_DEV_NAME,
        }
    )


def send_message(access_token, conversation_id, message):
    return requests.post(
        f'{SF_MIAW_URL}/iamessage/api/v2/conversation/{conversation_id}/message',
        headers={
            'Content-Type': 'application/json',
            'Authorization': f'Bearer {access_token}',
        },
        json={
            'message': {
                'id': str(uuid.uuid4()),
                'messageType': 'StaticContentMessage',
                'staticContent': {
                    'formatType': 'Text',
                    'text': message,
                },
            },
            'esDeveloperName': SF_MIAW_DEV_NAME,
            'isNewMessagingSession': False,
        }
    )


# Create a simple HTTP server to handle the callback
class AuthenticationCodeHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        self.send_response(200)
        self.end_headers()

        parsed = urlparse.urlparse(self.path)
        params = urlparse.parse_qs(parsed.query)

        code = params.get('code', [None])[0]
        if code:
            jwt = retrieve_jwt(code)
            token = retrieve_authed_user_miaw_token(jwt)

            conversation_id = str(uuid.uuid4())
            res = create_conversation(token, conversation_id)

            # Wait a few seconds for the conversation to finish creating
            time.sleep(10)

            res = send_message(token, conversation_id, 'Hello.')

            self.wfile.write(f'Authentication successful. Token: {token}'.encode())

        else:
            self.wfile.write(b'Failed to retrieve JWT.')

ユーザ認証後に適当な挨拶を Service Agent に送信し、Topics に書いた指示に従うように誘導しています。

このスクリプトには Service Agent からの応答を受信する処理を全く入れていないので、画面からは結果を確認することができません。結果は Agent Builder の実行ログで確認します。

アクション実行時のログは以下のようになっていました。

{
    "actionName": "CustomUXTest",
    "actionId": "XXXXXXXXXXXXXXXXXX",
    "plannerId": "",
    "isSuccess": true,
    "duration": 159,
    "rawInput": "{\"recordId\":\"XXXXXXXXXXXXXXXXXX\"}",
    "rawOutput": "{output=name: Guest\nid: v2/iamessage/AUTH/Salesforce/uid:uid:XXXXXXXXXXXXXXXXXX}",
    "outputErrors": []
}

上では置換していますが、rawInputMessagingEndUserId の値が指定されていること、rawOutputid に認証済みユーザの ID が入っていることが確認できました。ヨシ!

あとはアプリケーションの要求に基づいて、rawOutput に格納される sub の値から必要なデータを引っ張ってくれば OK ですね。

まとめ

というわけで、今回は Service Agent でユーザにカスタム UX を提供する方法についてまとめました。

LLM の活用において文脈の注入は極めて重要な要素であり、ユーザ単位での文脈注入は結構重要な要素になりそうな気がしています。この記事は紹介できませんでしたが、Omni-channel には他にも Pre-Chat を活用した文脈注入などもあり、お客様ごとに固有の文脈を取得する方法が色々と用意されていて感心しました。

一方で、Omni-channel は機能としてはそこそこ充足しているのですが、UI のクセが強かったりドキュメントが Service Agent と分断されていたりと、色々と苦労する点が多い印象です。Salesforce が Agentforce の営業に力を入れているのは伝わってくるのですが、もう少し開発エクスペリエンスの改善にも力を入れてほしいですね。

この記事で Agentforce を使った開発に携わる技術者のフラストレーションを少しでも軽減できれば幸いです。

以上です。最後までお読みいただき、ありがとうございました。