🔐

Auth0 の onContinuePostLogin を理解して Actions を柔軟に実装する

22 min read

こんにちは、クレスウェア株式会社の奥野賢太郎 ( @okunokentaro ) です。

今回はAuth0が提供する、ログインフローを拡張する仕組みであるActionsと、さらに強力な機能であるonContinuePostLogin()について紹介します。Auth0は認証認可の機能を扱うIDaaSのひとつで、サービス自体の詳解は省略します。

Auth0 Actionsは2021年5月にGAされた比較的新しい機能であるため、日本語情報どころか英語圏の情報がそもそも少ないため、頼りになるのは公式ドキュメント、及び試行錯誤の結果となります。今回紹介するonContinuePostLogin()については、とりわけ情報が少なかったものの、便利な活用が見込めそうな機能であったため、今後の発展を願い自分の試行錯誤の結果を記事にしたためることにしました。

セキュリティに関する事項を説明しますが、正確な理解については他の文献も併せて参照するようお願いいたします。紹介している内容はすべて2021年8月30日時点のものです。スクリーンショットおよび項目構成や名称は掲載時点のものであるため、本稿を参照する際は、常に最新情報を公式のドキュメントと比較するように心がけてください。

Auth0 Actions とは何か

Auth0 Actionsは、Node.js上で動作する関数です。LambdaやCloud FunctionsのようなFaaS、あるいはUDF (User-defined function) をイメージしてもらえばよいでしょう。

Auth0 Actions

Auth0側で定義されたいくつかのトリガーにおいて、あなたがそのトリガーに対応したハンドラー関数を実装することで、Auth0の機能を拡張できるのです。

Actions Triggers

トリガーは、複数のフローにそれぞれ定義されています。例えば『ログイン』フローで定義されたトリガーはpost-loginである、という具合です。そのトリガーに対応したハンドラー関数のことをアクションと呼ぶようです。つまり語彙の対応は以下のようになります。

  • フロー (Flow)
    • ログイン、M2M、ユーザー登録前などといった、Auth0を利用したユースケースの流れ全体
    • アクションが定義されていなくても、アクションがフローに組み込まれていても、それら全体を指してフローと称する
  • トリガー (Triggers)
    • フローごとに定義された「どういうときに何ができる」の「どういうとき」について定義したもの
  • アクション (Action)
    • そのトリガーをハンドリングするために書かれた関数自体

本稿では、訳語による解釈の阻害を避けるために以下ではFlow, Actionと記述します。

Action の作成とデバッグについて

Action を作成する

ActionはActions > Libraryから作成します。Build Customを選択するとCreate Actionダイアログが表示されるので、名称、Triggerを指定しましょう。Runtimeについては、基本的にNode 16のままで問題となることはないはずです。

作成すると、エディタ画面へと遷移します。exports.onExecutePostLoginという関数と、コメントアウトされたexports.onContinuePostLoginという関数があらかじめ記述されています。コメントアウトについては、不要であれば全削除して問題ありません。ブラウザから離れる際などは右上のSave Draftを選択し、運用上で適用したいのであればDeployを選択します。Deployは一瞬から数秒で完了します。

Actions Triggers: post-login

Login Flowにおける関数のインタフェースについては、このドキュメントにまとまっています。また、コールバック第1引数・第2引数については、下記のドキュメントにまとまっています。

Node.js 16で動作するため、console.log()を筆頭に、JavaScriptを実装する際の普段どおりのデバッグ手段で実装を試行錯誤すればよいです。

Action の作成と Flow への割当て

Actionの作成時によく起こりがちな誤りが、Actionだけを作って満足してしまうこと。なぜかActionを作ったのに、動いてなさそう…といった状況は、Flowへの割り当てを失念している場合が多いです。

ActionをFlowに割り当てる際は、まず対象のFlowを選びます。本稿ではログイン時に動作する関数を定義したいため、Actions > Flow から進んだ画面にてLoginを選択します。

掲載したスクリーンショットは筆者の作業後であるため、StartとCompleteの間にLoginという矩形と矢印が表示されています。これは、作業前はStartとCompleteのみが表示されます。たとえば今回言及していないPre User RegistrationのFlowに関しては、何も操作していないので矢印が一本になっています。

ここで重要なことは、Actionを作成するだけでは、この矢印の間に矩形が自動配置されないということです。つまりActionを作成したあと、作成したActionをStartとCompleteの間にドラッグ・ドロップする操作が必要です。この操作を忘れると、定義したActionはまったく動作しないため注意します。操作が終わったら、最後にApplyを選択します。

デバッグログの確認方法

ここで気になるのが、実際にそのActionが作動した際に、console.log()がどこに表示されるかという問題です。これについてはExtensionsを活用しましょう。

Realで検索すると"Real-time Webtask Logs"が表示されますので、このExtensionをインストールします。

インストールしたら、Installed Extensionsから内容に進めますので、これを開くことでログ画面が表示されます。この画面を表示し続けたままActionが実行されるような操作を別で行うことで、ここにあなたが書いたconsole.log()を含めその際の反応が表示されます。

他の箇所からもログを参照する手段はいくつかありますが、Real-time Webtask Logsが最も反応がリアルタイムであるため、基本的にこれを常用することになります。

また、エディタ画面からTestを表示させることで、モックのEventを使用した即席の実行も可能です。このEventは編集可能なので、実際のログから抽出したJSONなどをこの箇所にペーストすることで、細かいデバッグを行うことも可能です。

環境変数を定義し実装で使用する

Write Your First Action

Testの下にはSecretsカラムがあります。ここでは、Action単位で環境変数のKey, Valueを定義することが可能です。Add Secretを選択して表示されるダイアログにKeyとValueを記入して保存すると定義完了です。

SOMETHING_VARIABLE変数を定義したのであれば、それを実装内で使用したい場合は、event.secrets.SOMETHING_VARIABLEのようにします。

この環境変数の機能は、後述するonContinuePostLogin()の活用時に必須となる要素です。

npm パッケージの依存を定義する

Write Your First Action

Modulesカラムでは、npm installに相当する操作を行うこともできます。この内容についてはonContinuePostLogin()を活用することで、そこまで積極的に使う機会はなくなりますが、例えばonExecutePostLogin()の中でaxiosを使いたいといった状況などで重宝することでしょう。

Action ではどういったことが実装できるのか

onExecutePostLogin()であれば、たとえばif文とapi.access.deny()を組み合わせることで、特定の状況下でのログインを拒否することができます。また、api.idToken.setCustomClaim()などを実行し、発行されるアクセストークンについて、任意の値を付与するといったことも可能です。詳しくは公式のドキュメントを参照してください。

強力な拡張 onContinuePostLogin を駆使する

onContinuePostLogin とは

ここまでに紹介したActionの作成方法によって、onExecutePostLogin()の実装について学びました。onExecutePostLogin()だけでもログイン時の機能拡張はある程度可能となっています。環境変数の利用やnpmパッケージの依存も実施しやすく、割と不自由なく実装できます。

しかし、システムがより大きくなったり、独自のデータベース側との整合性を取る必要がでてくると、npmパッケージの依存だけでは事足りず、自プロダクト内で別途実装した関数などを組み合わせた処理を書きたいこともあるでしょう。そういったユースケースを満たすためのAPIがonContinuePostLogin()です。

onContinuePostLogin()は、onExecutePostLogin()内にてapi.redirect.sendUserTo() 関数を呼ぶことで利用可能になる追加のハンドリングです。この機能はとても強力ですが、残念ながらドキュメントだけではサンプルが少なく、出てくる値の対応が見通しにくいため、サンプルコードを自分のものにするには試行錯誤が求められます。

本稿後半では、公式ドキュメントのサンプルコードをもとに、注意すべき点を解説します。

onContinuePostLogin を利用する際の全体の流れを把握してみる

onContinuePostLogin()を活用する際にもっとも重要となるのが、全体的な流れの把握、そして登場人物の把握です。ドキュメントの流し読みでは読み飛ばしてしまう要素もしばしばあったので、改めて以下にまとめます。まずは全体的な流れを確認しましょう。

  • onExecutePostLogin()ではapi.redirect.sendUserTo()を呼ぶことができる
  • api.redirect.sendUserTo()では、あなたが実装した任意のHTTP GETに対してリダイレクトが可能である
  • 任意のHTTP GETのハンドラーはその処理の最後に、Auth0によって指定された/continueに対してリダイレクトを実行する必要がある
  • /continueへのリダイレクトはonContinuePostLogin()に繋がれる
  • onContinuePostLogin()が例外なく終了したときに、Callbackへとリダイレクトされる

さて、こうやって処理の流れを読んだとき、けっこうあちこちに振り回され、かつ指定の処理を呼ばねばならないことが多いとわかります。そのため試行錯誤を続ける上で、いま自分が何につまずいているのかを見失いがちです。こういったときにはシーケンス図を書いて全体を俯瞰することをおすすめします。

次節ではシーケンス図を用いて理解を深めます。

ログインの流れをシーケンス図で理解する

onContinuePostLogin()を理解するより先に、まずはそもそもAuth0のログインの流れがどうなっているのか再認識しましょう。

前提知識として、OpenID Connectのシーケンスを想像できるとより理解しやすいかもしれませんが、本稿では厳密なプログラム間の処理の流れではなく、概念的な流れを表現することを優先しています。すなわち、HTTPのリダイレクト処理とは実際厳密にはWebブラウザにリダイレクトレスポンスが返ってきて、それを受けてWebブラウザがリダイレクト先に再リクエストを投げるわけですが、そういったリダイレクトの部分に関してはあえて厳密さを欠いています。そのため、一般的にOAuthやOpenID Connectのシーケンス図として表現されているものよりは簡略化されていることをご了承ください。

まず、前提となる状況を説明します。ここではあなたがTypeScript, Next.js, Vercelの組み合わせでWebアプリケーションを開発し、そこでAuth0の認証機構を組み合わせようとしているものとします。Auth0 SDKにはnextjs-auth0を使います。

このとき、あなたは自分のWebアプリケーションの認証に必要なエンドポイントとして/api/auth/login, /api/auth/logout, /api/auth/callback, /api/auth/me(いずれもGETメソッド)を実装することになります。このうち今回、ログインに関与するものは/api/auth/login/api/auth/callbackです。

次の図は、/api/auth/login/api/auth/callbackを組み合わせたときの最小限のログインの流れです。

/api/auth/loginでは、nextjs-auth0 SDKの実装を活用するおかげでAuth0へのリダイレクト処理がすべて隠蔽されます。利用者目線では「ログインボタンをクリックすると、ユニバーサルログイン画面に遷移する」と映ります。ここで利用者はメールアドレスとパスワードを入力し送信ボタンをクリックします。その内容を受けてAuth0は認証処理を実施したあと、結果的に/api/auth/callbackが呼ばれます。/api/auth/callbackのハンドラーの内容はあなたが実装しますので、そこでログイン後処理などを記述し、最終的にcallbackハンドラー内で利用するAuth0 SDKが302 Foundを返却します。利用者のWebブラウザでは、そのレスポンスを受けログイン後のアプリケーションにリダイレクトし、ログインは完了となります。

あなたが実装すべきエンドポイントは2つだけですが、このように他の部分への行ったり来たりを繰り返しています。

Auth0 Actions として onExecutePostLogin を実装する

つづいてonExecutePostLogin()を使用する場合のシーケンス図を確認しましょう。

利用者がメールアドレスとパスワードを入力し送信ボタンをクリックするところまでは前節と同様です。そこでAuth0は/api/auth/callbackにリダイレクトする前に、onExecutePostLogin()を実行します。なお、シーケンス図における矢印はAuth0の内部的な処理順を厳密に表しておらず、表面的に観測可能な流れだけを示していることに注意してください。

onExecutePostLogin()関数が終了したら、/api/auth/callbackにリダイレクトし、ログイン完了となります。

このように、利用者がパスワードを入力(あるいはTwitterやGoogleなど他のプロバイダを経由してログイン)したあとに関数を挟めるのがAuth0 Actionsの特長です。

sendUserTo と continue を使って onContinuePostLogin を実装する

さていよいよ、冒頭で箇条書きにした onContinuePostLogin() についてです。冒頭の説明だけでは一体何が起こっているのかという複雑さがありましたが、これまでに掲載したシーケンス図と次の図を比較しながら改めてみていきましょう。

onContinuePostLogin()を利用しない場合、onExecutePostLogin()関数は単に処理を終えれば十分でした。onContinuePostLogin()を利用する場合、 onExecutePostLogin()関数の中でapi.redirect.sendUserTo()を呼ぶ必要があります。

sendUserTo()が実行されると、引数に指定したURLにリダイレクトします。すなわちsendUserTo('https://YOUR_APP/api/example', { /* options */ })とすれば、https://YOUR_APP/api/exampleにリダイレクトします。

/api/exampleはあなたが実装するエンドポイントです。GETメソッドに対応させ、エンドポイントパス名はもちろんexample以外にしてかまいません。このハンドラーにはonExecutePostLogin()だけでは実現できないような、さらに拡張的な処理を記述することができます。たとえばnpmに公開できない内部モジュールを使ったり、他のマイクロサービスと連携したり、データベースに問い合わせる処理を流用したりなどが思いつきます。

/api/exampleでの様々な処理を終えたあと、レスポンスは302 Foundを返却するようにします。その際にhttps://YOUR_DOMAIN/continueをリダイレクト先として指定します。YOUR_DOMAINはたとえばsomething.jp.auth0.comといったような、Auth0でテナントを作成したときに割り当てられるものです。

https://YOUR_DOMAIN/continueにリダイレクトすると、ようやくonContinuePostLogin()関数に辿り着きます。ただし、/api/exampleを経由せずにいきなり外部から直接/continueを呼ぶこともできてしまうので、それを禁止するために実行の際には必ずstateという文字列値が必要です。こういった必要な値については後述します。

onContinuePostLogin()では、/api/exampleでの処理によって得られた任意の値を使って、続きの処理を行うことになります。もしここで何も処理をしない場合はonExecutePostLogin()/api/auth/callbackの組み合わせで十分満たせるため/api/exampleを挟む意義が薄くなります。基本的にonContinuePostLogin()を実装するのであれば、なにか値を使って処理の続きを実現したい場合が想定されます。

onContinuePostLogin()自体は値を返す必要がなく、特定の他の関数を呼ぶことも強制されないため、必要な実装が記述できたら終了でよいです。終了したら、Auth0から/api/auth/callbackにリダイレクトされ、ようやく通常のログインフローと同じ流れに戻ります。

自前処理と onContinuePostLogin 間のセキュリティについて考察する

相互のリダイレクトではなにを保証する必要があるか

シーケンス図をもとにonContinuePostLogin()の流れを説明することができました。これまでの図の中で最も矢印が多く、移動が頻発するフローであることがわかります。では次に、この矢印に乗って動く値について説明します。

ここで懸念点があります。あなたが実装する/api/exampleも、Auth0が公開するhttps://YOUR_DOMAIN/continueも、単なるGETメソッドのエンドポイントであるため、普通にログインフロー以外からでも任意のタイミングで呼べてしまうという問題です。しかし、これらの処理は「Auth0のログインフローの間」でしか呼ばれてほしくありません。そして、その通信は改竄されていないことを保証する必要があります。

この状況を解決するためのインタフェースがAuth0より公開されています。豊富なドキュメントが用意されていますが、それでもそれぞれの値や変数の対応が見通しにくい印象だったため、改めてどの値が何を意味しているのか解釈しながら理解していきましょう。

/api/exampleおよび/continueが外部から気軽に呼べないことを保証する仕組みとして、CSRFトークンの利用、そしてJWTの相互検証が存在します。次の図ではonExecutePostLogin(), /api/example, onContinuePostLogin()の三者における、署名および検証処理について示しています。

2つある赤字の部分で1ペアの署名・検証、そして2つある青字の部分で1ペアの署名・検証が実施されます。

Auth0のドキュメントに記載されている内容が、CSRFトークンによる検証とJWTによるペイロードの往復を示唆している、と一発で理解できたならすばらしいのですが、ドキュメントを斜め読みしてプロパティ名や変数名だけに着目していると、それぞれの値が何のためにそこにあるのか見通しづらくなるため注意します。特にドキュメントで落とし穴となるのがAuth0外となる/api/example側をどう実装すべきなのか、サンプルコードが一切書かれていない点です。そこで、以下に筆者の実装したコードを交えて例を紹介します。

onExecutePostLogin 側で必要な署名

onExecutePostLogin()では、/api/exampleに使ってもらう値を渡すため、そして/api/exampleを呼んだのがAuth0のリダイレクトであることを証明するために、JWTの署名が必要となります。
ここで使用するように書かれているのがapi.redirect.encodeToken()です。ちなみにこの関数はドキュメント内のサンプルコードにのみ登場し、単独のAPIドキュメントとしては掲載されていません。

下記は、api.redirect.encodeToken()を実行する例です。

exports.onExecutePostLogin = async (event, api) => {
  const token = api.redirect.encodeToken({
    secret: event.secrets.REDIRECT_SECRET_A,
    payload: { email: event.user.email },
  });
};

encodeToken()は2つのプロパティを持つオブジェクト1つを引数に取ります。payloadプロパティには、/api/exampleに引き渡したい値を格納します。ここではドキュメントのサンプルコードの例を引用して、event.user.emailが渡るようにしています。secretプロパティはJWTの署名に使用するSecret文字列を指定します。たとえば$ openssl rand -hex 32の実行結果などを使用します。この値は、先述したActionsエディタのSecretsカラムより環境変数として登録して用いるのがよいでしょう。もちろん変数名はREDIRECT_SECRET_Aでなくてもかまいません。

こうすることでconst tokenには署名済みJWTが格納されます。続いて、このtokenを使って、/api/exampleへのリダイレクトを実行します。

exports.onExecutePostLogin = async (event, api) => {
  const token = api.redirect.encodeToken({
    secret: event.secrets.REDIRECT_SECRET_A,
    payload: { email: event.user.email },
  });

  api.redirect.sendUserTo("https://YOUR_APP/api/example", {
    query: { session_token: token },
  });
};

api.redirect.sendUserTo()では第1引数にリダイレクト先、第2引数にクエリパラメータを指定します。リダイレクト先は、あなたが実装するAPIの公開先URLです。クエリパラメータには先ほど署名したJWTを割り当てます。ここではキーがsession_tokenとなっていますが、別の名前にしても/api/example側で正しく拾えるのであれば特に問題はありません。

ここでクエリパラメータが使用されるのは/api/exampleGETで呼ばれるからで、payloadを直接クエリパラメータに渡さず、一旦JWTに含めたのは、クエリパラメータの改竄がないことを証明するためです。そのため、session_token以外にも任意のクエリパラメータを増やすことは可能ですが、あまり意味がありません。

/api/example側での検証

あなたが実装する/api/exampleのサンプルコードは、残念ながらAuth0のドキュメントには一切掲載されていません。ここはドキュメントを読み解き、どうあるべきかを類推する必要がありました。結論からいうと、先に出てきたsession_tokenの検証、およびonContinuePostLogin()に値を渡すための「改めての署名」が必要です。

以下に/api/exampleのサンプルコードを全量掲載します。

import jwt, { JwtPayload } from "jsonwebtoken";
import { NextApiRequest, NextApiResponse } from "next";

export default async function example(
  req: NextApiRequest,
  res: NextApiResponse
): Promise<void> {
  const payload = jwt.verify(
    `${req.query.session_token}`,
    process.env.REDIRECT_SECRET_A
  ) as JwtPayload;

  // ここに任意の実装

  const newToken = jwt.sign(
    {
      sub: payload.sub,
      exp: payload.exp,
      iss: process.env.MY_DOMAIN,
      state: req.query.state,
      foo: "bar",
      baz: "qux",
    },
    process.env.REDIRECT_SECRET_B,
    { algorithm: "HS256" }
  );

  res.redirect(
    302,
    `${process.env.AUTH0_BASE_URL}/continue?state=${req.query.state}&my_token=${newToken}`
  );
}

検証と「改めての署名」の2つが要旨であることを念頭に置いて読むと、それらがそのまま記述されていることがわかります。

まず、クエリパラメータに含まれるsession_tokenの検証を行います。検証は、ここではjsonwebtokenを使います。jwt.verify()の第1引数にJWT、第2引数にSecretを指定することで、問題なければpayloadが得られます。ここではTypeScriptサンプルコードを簡便にするためas JwtPayloadとしていますが、より厳格なランタイム型チェックをお好みな方は、そのように処理を追記してください。

ここで、そもそもこの検証に失敗する(つまりAuth0からのリダイレクトではない)場合は外部からの意図しないアクセスであることがわかり、失敗に倒すことができます。

つづいて、payloadを獲得したらその値を使って任意の処理を実装します。ここはいつもどおり好きにすればよいです。

/api/example側での改めての署名とリダイレクト

任意の処理を終えて、Auth0側にリダイレクトし直す部分を実装します。ここでは「改めての署名」が必要です。

ドキュメントを読むと、そもそもサンプルコードがないことと相まって冒頭のsession_tokenを引き続き流用するのかと筆者は一度勘違いしたのですが、そうではなく、行きと同じように帰りも別途署名をし直して、その新しいJWTをリダイレクトに乗せることが必要です。

これは「HS256アルゴリズムで署名されるJWTがどういうものなのか」という、JWTそのものの構造を理解しておく必要があります。

JWTはHeader, Payload, SignatureがそれぞれBase64でエンコードされ、その結果が.で連結された文字列にすぎません。Payload自体を暗号化しているのではなく、発地と着地において内容全体が改竄されていないことを証明するための手段です。HS256アルゴリズムを採用する場合、共通鍵のみで署名と検証が行えますので、あなたのテナントのAuth0 Actionsとあなたの/api/exampleの双方(つまり両方あなたの管理下である)において、この共通鍵の漏洩を防げるのであれば、シンプルな検証手段として有効なのです。

行きと帰りでPayloadの内容が異なるのであれば、それに伴ってそのつど再署名が必要になり使い回す発想には至らない、というわけです。

さて、その改めての署名にはjwt.sign()を使います。第1引数にはPayloadのオブジェクト、第2引数にはSecret、第3引数にはアルゴリズムを指定します。

第1引数のPayloadでは、sub, expクレームが必須となります。これは特にこだわりがない限り、クエリパラメータのJWTに乗っていたPayloadからそのまま引き継げばよいでしょう。issクレームはJWTの発行者を示します。必須ではないですが、例ではあなたのWebアプリケーションのドメインを指定するものとしましょう。stateは、/api/exampleへのアクセス時にくっついていたクエリパラメータstateをそのまま渡します。この値についてはonContinuePostLogin()の節で後述します。以降のカスタムクレームは自由です。本稿では動作する例としてfooおよびbazを定義していますが、この構造についてはJWT自体のベストプラクティスを別途参照するようにしてください。

第2引数ではSecretを指定します。本稿ではprocess.env.REDIRECT_SECRET_Bとしています。これは後述するonContinuePostLogin()での検証に使うevent.secrets.REDIRECT_SECRET_Bとの対称性を示唆していますが、REDIRECT_SECRET_A, REDIRECT_SECRET_Bを使い分ける必要は実際にはなく、Auth0のサンプルコード上でも同じものが使われています。

第3引数ではアルゴリズムを指定します。これはAuth0によってHS256であることが必須とされています。

これで再署名が完了しました。あとはAuth0側にリダイレクトするだけです。リダイレクトは、Next.jsのAPIとしてres.redirect()を使います。

302 Foundを返却するようにして、リダイレクト先をhttps://YOUR_DOMAIN/continueとします。サンプルコード内ではYOUR_DOMAIN 部分はprocess.env.AUTH0_BASE_URLとしています。

リダイレクト時のクエリパラメータには、statemy_tokenを指定します。state/api/exampleへのリダイレクト時にくっついていたクエリパラメータstateを、そしてmy_tokenには改めて署名したnewTokenを渡しています。

/continue のCSRFトークン検証

https://YOUR_DOMAIN/continueが呼ばれてからonContinuePostLogin()の処理が開始されるまでの間にひとつ検証が入ります。これはAuth0 Actionsとしてあなたが実装する必要のあるものではなく、Auth0側で自動的に実行されます。それがCSRFトークンの検証です。

CSRF (Cross-Site Request Forgery) 自体についての解説は省略しますが、ここでCSRFトークンの検証を行うことによってonExecutePostLogin(), /api/example, https://YOUR_DOMAIN/continue, onContinuePostLogin()がすべて一貫した通信であることを保証します。CSRFトークンとして用いられるものが、すでに何度か登場しているstateです。

state/api/exampleを呼ぶ際に必ずクエリパラメータに追加される値で、この値が付いたままhttps://YOUR_DOMAIN/continueにリダイレクトすることで、その一貫性を検証します。そのため/api/exampleの実装ではしばしばstateの取り扱いが出たことと思います。

stateはJWTの署名・検証とは別個の用途として用いられるため、初めて取り組む際は他の環境変数、JWTなどに混ざって複雑な文字列の量に圧倒されがちですが、ひとつひとつ用途を理解して実装するように注意します。

stateが付与されており、改竄されていないことがわかれば、onContinuePostLogin()の処理が開始されます。

onContinuePostLogin 側での検証

ようやくonContinuePostLogin()にたどり着きました。ここでやるべきことは、/api/exampleで署名したJWTの検証、そして得られたPayloadを使っての続きの処理の実装です。次のコードはonContinuePostLogin()の最小限の例です。

exports.onContinuePostLogin = async (event, api) => {
  const payload = api.redirect.validateToken({
    secret: event.secrets.REDIRECT_SECRET_B,
    tokenParameterName: "my_token",
  });

  // 任意の処理
};

ここでは、/api/exampleからやってくるJWTの検証をapi.redirect.validateToken()を使って行います。引数のオブジェクトにはsecrettokenParameterNameのプロパティがあり、secret/api/exampleにて署名に使用したSecretを指定します。つまりREDIRECT_SECRET_Bです。tokenParameterNameでは、https://YOUR_DOMAIN/continueを呼ぶ際のクエリパラメータで、JWTをどのキーに割り当てたかを指定します。前節に出てきたmy_tokenがこの箇所と対応しています。この名前は一致すれば任意で変更してかまいません。そしてtokenParameterNameプロパティを省略した場合は、自動的に初期値としてsession_tokenが使用されますので、特にこだわりがなければ指定しないことも可能です。

これにてようやくonContinuePostLogin()にて/api/exampleのPayloadを獲得することができました。const payloadに格納されたオブジェクトや値を使って、以降は自由に実装することができます。

以上が、サンプルコードを用いたonContinuePostLogin()利用時の全体的なフローの実装例です。署名と検証を中心にいくつかの規則を守れば、簡単に実装できることがわかりました。

フロー全体で起こる例外への対応

正常系の最小限のコードが実装できたところで、次に課題となるのは異常系対応です。正直起こりうるすべての可能性について異常系処理を書いていくとなかなかの量になり、このサンプルコードの本旨をぼかしてしまうため、ここまでは省略していました。

そして、すべてを網羅しようとすると、システム全体の状況に応じて取りうるべき対応が異なってくるため、本稿で詳解することはできません。ここでは、基礎的な例外対応の想定をサンプルとして紹介するに留め、その例をもとにあなたの実装で臨機応変に実装してもらえればと思います。

こういったバックエンドのリダイレクトシステムにおいて、どこでエラーが起きて、どこで寸断されたかを知ることはとても重要です。そのためにエラートレースログを仕込んだり、トラフィックログを仕込んだりすることは一般的です。

そしてもうひとつ重要なこととして、利用者に最終的に何が起こったかを知らせることです。バックエンドのエラーは、実装が間に合っていない場合、そのまま回復できずタイムアウトしてしまい502や504エラーに繋がってしまうこともしばしばあります。回復したとしても、適切でなければ500エラーになってしまいます。認証で失敗したのであれば、401エラーが返るようにするのが適切といえるでしょう。そこに今回は繰り返されるリダイレクトが組み合わさります。そのため、どこでどういったエラーを伝播させるとよいかを検討しました。

Auth0 Actionsではapi.access.deny()という関数が提供されています。onExecutePostLogin()onContinuePostLogin()で呼ぶことができ、このスコープ内で起こる例外については、おおよそapi.access.deny()に倒してしまってよいでしょう。

問題は/api/exampleの中で起こった例外です。ここで厄介なのは、/api/exampleが500エラーなどを返してしまってはだめで、どう例外が起こったとしても死んではならずにhttps://YOUR_DOMAIN/continueにエラーが起こったことを伝えきる必要があります。もしエラーの詳細を伝えることができなかったとしても、処理のバトンを途絶えさせるわけにはいかないのです。

以下は/api/exampleの例外対応を考慮したコード例です。

export default async function example(
  req: NextApiRequest,
  res: NextApiResponse
): Promise<void> {
  let payload: JwtPayload;

  try {
    payload = jwt.verify(
      `${req.query.session_token}`,
      process.env.REDIRECT_SECRET_A
    ) as JwtPayload;
  } catch (e) {
    res.redirect(
      302,
      `${process.env.AUTH0_BASE_URL}/continue?state=${req.query.state}`
    );
    return;
  }

  const newToken = jwt.sign( // 省略

まず起こるであろう想定ができる例外が、jwt.verify()の検証失敗です。ここを例に挙げてみましょう。

ここではconst payloadlet payloadtry-catchに変更し、例外があった場合は即座にres.redirect(302, ...)として戻しています。実際に業務で実装する場合は、エラーオブジェクトeがJWTの検証失敗であるとは限らないため、catch内分岐が必要になりますが、ここでは省略します。

res.redirect(302, ...)とすることで、後続の処理が不可能だったとしても、とにかくそれをAuth0側に伝えます。?stateが付いているためCSRFトークン検証に失敗することはなく、その後のonContinuePostLogin()側でsession_tokenが無いためエラーになる想定ができます。

エラーの詳細をPayloadとして乗せたい場合は、catch内でJWTの署名をすることも考えられますし、簡便に判別したいのであれば、そのままstateの後に別のクエリパラメータを追加することもできます。たとえば、${process.env.AUTH0_BASE_URL}/continue?state=${req.query.state}&error=messageといった具合です。

とにかく/api/exampleの中では、何があっても302を返却するということに徹したほうがよいと筆者は考えます。

例外対応でシステムが複雑になると、もうひとつ面倒なのがトランザクションの分断とロールバックの考慮です。今回の例だと、/api/exampleでDBへの書き込みを実施して、その結果をonContinuePostLogin()側に渡してonContinuePostLogin()で例外が起こったらどうするのか、などといった懸念が生まれます。この点に関しては本稿で具体例を挙げて解説することは難しいのですが「どこで何が起こったら、それまでに起こった別のことをどうすべきか」は、常に考慮しながら全体を俯瞰して設計していくのがよいでしょう。ロールバックについては、他社が公開しているようなマイクロサービス関連のロールバック知見なども役に立つと思われます。

総括

ここまで、Auth0 Actionsとは何か、ログインフローの拡張とはどういうことか、そしてとても強力な機能であるonContinuePostLogin()の実装手順について紹介してきました。

システムは闇雲に複雑にすべきではないので、まずは最小限のActionsで試行錯誤するとよいと思いますが、onContinuePostLogin()という強力な拡張手段が提供されていることで、Auth0とあなたのWebアプリケーションを組み合わせた際の効果はより大きなものとなることでしょう。本稿で、Auth0 Actionsに関するコミュニティが拡大していくことを筆者は願います。

本稿は以上です。よろしければ記事のLikeや拡散、筆者のフォロー、サポートなどいただけましたら励みになります。最後までありがとうございました、それではまた。

Discussion

ログインするとコメントできます