ユーザエージェントフローでsalesforce認証する

ウェブアプリからSalesforceで認証する処理を学習しました。
バックエンドサーバを使用せずVueで作成したウェブアプリからSalesforceへ接続してみます。 OAuth2のImplicit Grantになるかと思います。

Salesforceの設定

接続アプリケーションの作成

Salesforce に接続アプリケーションを作成します。
コールバックURLを設定します。
コンシューマキーが必要なので控えておきます。

CORSの設定

Salesforce でCORSの設定をします。
httpsにしましたが、localhostを使用する時はhttpでもいいかもしれないです。

VUE

https 設定

vite.configを変更してhttpsに変更する。
証明書の作り方は省略します。

1
2
3
4
5
6
  server: {
    https: {
      key: fs.readFileSync('./serverinstall_key.pem'),
      cert: fs.readFileSync('./server.crt'),
    }
  }

コールバック

Salesforceの接続アプリケーションのコールバックに設定したURLをルーティングする。
AuthCallback.vueコンポーネントで処理するように設定します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
  const router = createRouter({
    history: createWebHistory(),
    routes: [
      {
        path: '/',
        name : 'HelloWorld',
        component: HelloWorld
      },
      {
        path: '/authcallback',
        name : 'AuthCallback',
        component: AuthCallback
      }
    ]
  })

ログインページの作成

HelloWorld.vue ファイルにログインボタンとログアウトボタンを表示します。
ログインしていない場合はログインボタン、ログインしている場合はログアウトボタンを表示します。
ログアウトボタンはマウスカーソルがユーザアイコンにホバーした時に表示するようにします。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
<script setup>
import { onMounted, ref } from 'vue'
import { storeToRefs } from 'pinia'
import { userStore } from '@/stores/userStore'

const user = userStore()
const { isLoggedIn } = storeToRefs(user)

const formAction = ref("" +
    import.meta.env.VITE_SF_URL
    + "/services/oauth2/authorize"
    + "?client_id=" + import.meta.env.VITE_SF_KEY
    + "&redirect_uri=" + import.meta.env.VITE_AUTH_CALLBACK
    + "&response_type=token")

onMounted(() => {
  if (user.isLoggedIn) {
    // ログイン済みの場合、トークンが有効か確認する
    // チェック処理は非同期で行われる
    user.checkToken()
  }

})

function logout() {
  user.logout()
}
</script>

<template>
  <form :action="formAction" method="post">
    <button v-show="!isLoggedIn"
            type="submit"
            class="button is-info"
    >
      ログイン
    </button>
  </form>
  <button v-show="isLoggedIn"
          class="button is-info is-rounded user-info">
    <font-awesome-icon icon="fa-regular fa-user" />
    <aside class="menu popover">
      <p class="menu-label">
        {{ user.displayName }}
      </p>
      <ul class="menu-list">
        <li>
          <button class="button is-info"
                  @click="logout">
            ログアウト
          </button>
        </li>
      </ul>
    </aside>
  </button>
 </template>

<style scoped lang="scss">
.user-info {
  width: 1px;
  position: relative;
  .popover {
    position: absolute;
    top: 40px;
    left: 0;
    color: black;
    background-color: white;
    transition: 1s;
    opacity: 0;
    visibility: hidden;
  }
  &:hover {
    .popover {
      opacity: 1;
      visibility: visible;
      transition: .5s;
    }
  }
}
</style>

コールバックページの作成

AuthCallback.vue ファイルにコールバック処理を書きます。
mount されたらURLのハッシュ以降を取得してログイン結果を取得します。
ログインに成功したら login を 失敗したら logout を呼びます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
<script setup>
import { onMounted, ref } from 'vue'
import { userStore } from '@/stores/userStore'
import { useRoute } from 'vue-router'

const user = userStore()
const route = useRoute()
const status = ref("")

onMounted(() => {
  const hashElements = {}
  let hasError = false
  let hasToken = false
  if (route.hash) {
    route.hash.substring(1).split('&').forEach(e => {
      const hashSet = e.split('=')
      hashElements[hashSet[0]] = hashSet[1]
      if (hashSet[0] === "error") {
        status.value = hashSet[1]
      }
      hasError ||= (hashSet[0] === "error")
      hasToken ||= (hashSet[0] === "access_token")
    })
  }
  if (!hasToken && !hasError) {
    if (route.query.error) {
      status.value = route.query.error
      hasError = true
    }
    if (route.params.error) {
      status.value = route.params.error
      hasError = true
    }
  }
  if (hasError) {
    user.logout()
    status.value = "ログインに失敗しました。"
  }
  if (hasToken) {
    status.value = "ログインしました。"
    user.login(hashElements)
    window.location.replace("/")
  }
})

</script>

<template>
  <div class="m-4 has-text-centered">
    {{ status }}
    <br>
    <a href="/">Top</a>
  </div>
</template>

<style scoped>

</style>

ログイン情報をstoreに保存

ログイン情報は store に保存することにします。
localstorageに保存することが最善であるかどうかは、ここでは考えないことにします。
以下の3つの変数と3つのfunctionをexportします。

変数

  • displayName: ユーザ名。
  • token: アクセストークン。
  • isLoggedIn: ログイン。

function

  • login: 認証情報を保存する。
  • logout: 認証情報を破棄する。
  • checkToken: 認証情報が有効かチェックする。
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
import {computed} from 'vue';
import {defineStore} from 'pinia'
import {useLocalStorage} from "@vueuse/core"
import axios from "axios"

export const userStore = defineStore('user', () => {

    const auth = useLocalStorage("auth", "")
    const user = useLocalStorage("user", "")

    const isLoggedIn = computed(() => {
        if (auth.value) {
            return true
        }
        return false
    })
    const token =  computed(() => {
        if (auth.value) {
            const a = JSON.parse(auth.value)
            return a.access_token
        }
        return null
    })
    const displayName =  computed(() => {
        if (user.value) {
            const u = JSON.parse(user.value)
            return u.display_name
        }
        return null
    })

    function save(a) {
        auth.value = JSON.stringify(a)
    }

    function clear() {
        auth.value = ""
        user.value = ""
    }

    function print() {
        console.log("saved auth in local storage %o", auth.value)
        console.log("saved user in local storage %o", user.value)
    }

    function login(a) {
        revoke()
        save(a)
        print()
    }

    function logout() {
        revoke()
        print()
    }

    function checkToken() {
        if (auth.value) {
            const a = JSON.parse(auth.value)
            axios.get(
                a.id,
                {
                    params: {
                        access_token: a.access_token,
                        format: "json"
                    }
                })
                .then(function (response) {
                    console.log(response);
                    user.value = JSON.stringify(response.data)
                })
                .catch(function (error) {
                    console.log(error);
                    user.value = ""
                    logout()
                })
        }
    }
    function revoke() {
        if (auth.value) {
            const a = JSON.parse(auth.value)
            axios.get(
                a.instance_url + "/services/oauth2/revoke",
                {
                    headers: {
                        "Content-Type" : "application/x-www-form-urlencoded"
                    },
                    params: {
                        token: a.access_token
                    }
                })
                .then(function (response) {
                    console.log(response);
                })
                .catch(function (error) {
                    console.log(error);
                })
                .then(function () {
                    // always executed
                    clear()
                })
        } else {
            clear()
        }
    }

    return {
        //auth, user, // devtools で確認したいときはコメントアウトを外す
        displayName,
        token,
        isLoggedIn,
        login, logout, checkToken
    }
})

画面遷移

  1. HelloWorld.vue のログインボタン

  2. Salesforce のユーザ入力画面

  3. Salesforce の認証画面

  4. HelloWorld.vue のユーザ表示

  5. HelloWorld,vue のログアウトボタン

その他のファイル

環境情報はenvファイルに記載しています。

1
2
3
4
NODE_ENV='local'
VITE_SF_URL="salesforceの認証URL"
VITE_SF_KEY="salesforce接続アプリケーションのコンシューマ鍵"
VITE_AUTH_CALLBACK="https://localhost:5173/authcallback"

スタイルはbulma、アイコンはfontawesomeを使用しています。 package.json

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
"dependencies": {
  "@fortawesome/fontawesome-svg-core": "^6.2.1",
  "@fortawesome/free-regular-svg-icons": "^6.2.1",
  "@fortawesome/vue-fontawesome": "^3.0.2",
  "@vueuse/core": "^9.5.0",
  "@vueuse/router": "^9.5.0",
  "axios": "^1.2.0",
  "bulma": "^0.9.3",
  "pinia": "^2.0.24",
  "vue": "^3.2.41",
  "vue-router": "^4.0.16"
},

気になったこと

トークンの有効時間

Salesforceから受け取る情報の中にアクセストークンの有効期間が含まれていませんでした。 セッションの有効期間と同じと思われます。下記のような処理を考えておいた方がよさそうです。

  • ウェブアプリ側で、トークン取得から一定時間経過後にトークンを再取得する。
  • ステータスコード401の場合に、再認証を促すようにする。

Last Mod: Jan 7, 2023