🚶‍♂️

Auth0 Actionsで Progressive Profiling を実装してみた

に公開

https://medium.com/nextbeat-engineering/auth0-actionsで-progressive-profiling-を実装してみた-8be443a9c789

はじめに

こんにちは。エンジニアの久保です。
最近は保育士バンク!というサービスを中心にプロダクト横断の基盤部分を開発しています。
今回は認証基盤であるAuth0を使ったProgressive Profilingについて記事を書かせていただきます。

本記事を読むことで分かること

  • Auth0を用いた Progressive Profiling の概要
  • 公式ドキュメントには無い具体的な実装例
  • IDaaSの便利さ

免責事項

  • 本記事では一部セキュリティに言及しますが、そこが本題ではないため割愛している部分があります。他文献も参考にしてください。
  • サンプルコードで一部 Local Storage や cookie を用いていますが、あくまでサンプルコードですので利用するストレージは別途ご検討ください
  • Auth0に関する仕様やUIは2023年4月10日時点のものです。最新の公式ドキュメントも併せてご参考ください。

概要

皆さんは Progressive Profiling についてご存知でしょうか。
簡単に言いますと「段階的なフォーム入力体験」のことを指します。

どんなサービスでもそうですが、コンバージョン率を上げるために登録フォームの入力項目はなるべく減らしたいのが常かと思います。
ですがIDとパスワード以外にも欲しいユーザーデータは多々あります。
そこで一度にすべての情報入力を求めるのではなく段階的に入力を求めれば、まずは登録だけはしてくれるかもしれません。

ということで今回は Auth0 の Actions という機能を用いてそれを実装してみます。
Auth0 はアカウントの認証・管理機能を提供しているいわゆる IDaaS です。
実装するフォームのステップとしては下記の通りです。

  1. ID/Passwordを入力して登録(New Universal Login)
  2. 氏名や住所などの追加情報を入力(独自実装)
  3. 登録完了

基本的な認証処理の説明は省かせていただきます。
また実装は弊社サービスでも導入している SvelteKit で行います。

シーケンスの確認と解説

シーケンス図
シーケンス図

「追加情報入力フェーズ」以外の部分はありふれた認証フローです。
では9番から15番までを作っていきます。

Auth0 Actions を使用する

Auth0には様々なアクションをトリガーにして予め設定していた処理を実行させることができます。
ではさっそく作っていきましょう。
サイドメニューの 「Actions > Library」 から遷移し、画面上部のBuild Customを押します。
名前は任意で、トリガーには「Login / Post Login」を選びます。

Auth0 の Action作成画面
自分だけのActionを作ります

今回は登録時に追加情報を入力してもらうのが目的です。
なので「Post User Registration」かと思われるかもしれないのですが、このトリガーは非同期で実行されるので今回の目的であるProgressive Profilingには使えません。
各Actionの概要はこちらからご確認いただけます。

また途中離脱などされて再開した場合もやはり入力してほしいはずです。
そうなると「Login」が最適です。 Login は登録したあとの初回ログイン、離脱・ログアウト後のログイン時にも処理が走るためです。

作成するとこのようなテンプレートが既に用意されています。

exports.onExecutePostLogin = async (event, api) => {
};
// exports.onContinuePostLogin = async (event, api) => {
// };

ログインをトリガーにしてこの 「onExecutePostLogin」が呼び出されます。シーケンス図でいうところの9番の処理です。
onContinuePostLogin」の方は後ほど解説します。
まずは最初の関数を利用して追加情報画面にリダイレクトしてあげましょう。

onExecutePostLoginの実装 (シーケンス:⑨, ⑩)

const redirectToValidationPage = async (event, api) => {
  const YOUR_DOMAIN = event.secrets.YOUR_DOMAIN 
  const token = api.redirect.encodeToken({ // ①-1
    secret:           event.secrets.AUTH0_ACTIONS_SECRET, // ①-2
    payload: {
      continue_uri:   `https://${YOUR_DOMAIN}/continue`, // ②
      name:           event.user.name ?? '', // ③-1
      address:        event.user.user_metadata.address ?? '', // ③-2
    }
  })
}
exports.onExecutePostLogin = async (event, api) => {
  // 追加情報が未入力であれば追加情報入力画面へリダイレクトする
  if (!event.user.name || !event.user.user_metadata.address) { // ④
    await redirectToValidationPage(event, api)
  }
};
  • まず引数の eventapi ですが、こちらにはActions内で使える便利なメソッドやデータが用意されています(event, api
  • ①-1:リダイレクト時はGETアクセスですので、api.redirect.encodeToken を使うことでやり取りするデータを署名し正当性を担保します
  • ①-2:この AUTH0_ACTIONS_SECRET が共通秘密鍵で、これで署名検証します。ハードコーディングは避けられるように、Actionsには環境変数を登録ことができます。Actionsのエディタ左側にある鍵マークから登録しましょう。もちろん名称は何でも良いです。ちなみに値も特段決まりはありませんが私はUUIDを生成して使いました
  • ②:これが追加情報入力後の遷移先で、先程出てきた onContinuePostLogin を呼び出すエンドポイントです
  • ③:この2つが今回の追加情報です。以前に片方だけ入力した場合を想定してこのような実装にしています
  • ④:この関数はログインのたびに呼び出されますので、既に入力済みな場合を想定してガードを書きましょう

/auth/login/step/2 の実装 (シーケンス:⑪〜⑭)

このActionsからリダイレクトされた先の実装はサービスの仕様によって様々ですが、今回は追加情報入力フォームをあと1段階だけ挟みます。

+page.server.ts (シーケンス:⑪)

import { env } from '$env/dynamic/private'
import * as jwt from 'jsonwebtoken'
import { error } from '@sveltejs/kit'
/** @type {import('./$types').PageServerLoad} */
export async function load({ url }) {
  const query        = url.searchParams
  const sessionToken = query.get('sessionToken')
  const state        = query.get('state')
  if (!sessionToken || !state) {
    throw error(400, { message: 'Bad Request' })
  }
  let decodedToken: DecodedSessionToken
  try {
    decodedToken = jwt.verify(sessionToken, env.AUTH0_ACTIONS_SECRET, { // トークンの検証
      issuer:     env.AUTH0_DOMAIN,
      algorithms: ['HS256'],
    }) as DecodedSessionToken
  } catch {
    throw error(400, { message: 'Bad Request' })
  }
  // クライアントに値を返却
  return {
    userId:      decodedToken.sub,
    continueUri: decodedToken.continue_uri,
    name:        decodedToken.name,
    address:     decodedToken.address,
    state
  }
}

Actionsから返ってきたトークンはJWTなのでAuth0公式が公認しているJWTライブラリで検証してあげましょう。
シークレットなどは環境変数に保存して呼び出しています。
無事検証できればActionsから送られたpayloadが手に入ります。
continue_uri や state は後続処理を呼び出す際に必要なので手間ですが引き回しましょう。

+page.svelte(シーケンス:⑫)

<form method="POST" action="?/continue">
  <FormGroup floating label="氏名">
    <Input type="text" name="name" value="{$page.data.name}" required />
  </FormGroup>
  <FormGroup floating label="住所">
    <Input type="text" name="address" value="{$page.data.address}" required />
  </FormGroup>
  <input type="hidden" name="userId" value="{$page.data.userId}" />
  <input type="hidden" name="continueUri" value="{$page.data.continueUri}" />
  <input type="hidden" name="state" value="{$page.data.state}" />
  <Button>登録</Button>
</form>

レイアウトのためにBootstrapを使っていますが普通のタグに読み替えてもらって問題ありません。
SvelteのForm actionsを使って、先程の +page.server.ts に入力値を返却します。

+page.server.ts(シーケンス:⑬, ⑭)

先程の +page.server.ts に追加でフォームの受け口を作ります

/** @type {import('./$types').Actions} */
export const actions = {
  continue: async ({request}) => {
    const data        = await request.formData();
    const name        = data.get('name')
    const address     = data.get('address')
    const userId      = data.get('userId')
    const continueUri = data.get('continueUri')
    const state       = data.get('state')
    if (!name || !address || !userId || !continueUri || !state) {
      throw error(400, { message: 'Bad Request' })
    }
    const sessionToken =
      jwt.sign(
        { continue_uri: continueUri, name, address, state },
        env.AUTH0_ACTIONS_SECRET,
        {
          algorithm: 'HS256',
          subject:   userId,
          issuer:    env.APP_URL,
          expiresIn: 60,
        })
    throw redirect(302, `${continueUri}?state=${state}&sessionToken=${sessionToken}`)
  }
};

ポイントとしては jwt.sign の部分です。
ここで再度Actionsに返すのですが、やはり改ざんがあれば気付けるように署名する必要があります。
issuer(発行者) には自身のアプリケーションのURLを設定しましょう。

onContinuePostLoginの実装(シーケンス:⑮)

// ユーザーデータを更新するためのメソッドを用意
// https://community.auth0.com/t/updating-user-profile-from-actions/85206
const updateUser = async (event, data) => {
  const ManagementClient = require('auth0').ManagementClient
  
  const management = new ManagementClient({
    domain:       event.secrets.AUTH0_DOMAIN,
    clientId:     event.secrets.CLIENT_ID,
    clientSecret: event.secrets.CLIENT_SECRET
  })
  const params = { id: event.user.user_id }
  await management.updateUser(params, data, function (err) {
    if (err) {
      console.log(err)
    }
  })
}
exports.onContinuePostLogin = async (event, api) => {
  let payload
  // トークンを検証して取り出し
  try {
    payload = api.redirect.validateToken({
      secret:             event.secrets.AUTH0_ACTIONS_SECRET,
      tokenParameterName: 'sessionToken'
    })
  } catch (e) {
    console.log(e)
    api.access.deny('トークンの検証に失敗しました')
    return
  }
  // 追加情報が未入力であれば追加情報入力画面へリダイレクトする
  if (!payload.name || !payload.address) {
    await redirectToValidationPage(event, api)
    return
  }
  // 問題がなければデータを更新して完了
  api.user.setUserMetadata('address', payload.address)
  await updateUser(event, { name: payload.name })
};

以上のプロセスが完了するとシーケンス図の16番、 /api/auth/callback に返ってきます。
そこからの処理は他の方記事や公式ドキュメントが充実していますのでそちらをご確認ください。
ここまで完了したらActionsエディタの右上ので「Deploy」ボタンでデプロイしましょう。

フローに乗せる

作ったActionsはFlowに組み込むことで実際にトリガーされます。
Actions > Flows から 「Login」 を選択しましょう。

Auth0 の フローの一覧
フローの一覧

Flowの全体像が表示されます。右側の「Custom」を選ぶと先程デプロイした自作のActionsが出てきますので、ドラッグアンドドロップで組み込みましょう。
右上のApplyをして反映完了です。

Auth0 の フローの編集画面
フローの編集画面

最後に

Auth0はドキュメントが豊富ではあるのですが当時は多くが英語で、またActions自体の記事もあるにはありますが具体的な事例もあると良いなと思い本記事を書かせていただきました。
(追記:最近日本語化も進んできました🎉)

データ保持部分等はかなり省略していますが、やはりユーザーの管理をここまで任せられ、かつ柔軟なビジネス要件にも対応しているAuth0は調査してみてとても勉強になりました。
今回利用したソースはこちらに上げてあります。
本記事が何かのお役に立てますと幸いです。
ありがとうございました。

nextbeat Tech Blog

Discussion