💡

HTMX を使用した kratos browser flow の実装サンプル

2024/02/21に公開

本記事の概要

以下の記事で、ory kratosのユースケース概要と、shell scriptを使用した簡単な実行サンプルを紹介しました。

本記事では、ブラウザからkratosの各種self-service flowを実行する、golang とHTMXを使用したサンプルコードの紹介と一部抜粋して解説します。

サンプルコード

サンプルコードは以下のリポジトリです。

解説は後述します。

self-service flow

改めてself-service flowについて簡単に説明します。

kratosには、ユーザー自身によるユーザー登録やログイン、アカウント復旧といった、SelfService flowと呼ばれる実装がなされており、この仕様はNISTやIFTF、Microsoft Research、Google Research、Trou Huntによって確立されたベストプラクティスに基づいているとのことです。

self-ervice flowに従うことで、各種攻撃やCSRFに対するセキュリティが確保されます。

self-service flowの流れは、以下のドキュメントに記載されています。

https://www.ory.sh/docs/kratos/self-service#browser-flows-for-server-side-apps-nodejs-php-java-

self-service flowの実装方式には、以下3つの方法があります。

  • Browser-based flows
    • Browser-based flows for server-side apps
    • Browser-based flows also support client-side applications
  • API flows

Webアプリであれば、Browser-based flows、ネイティブアプリであればAPI flowsを使用します。

最も大きな違いはセッションの管理方法で、Browser-based flowsはCookieを使用しますが、API flowsはTokenを使用します。

また、Webアプリの場合で、ReactやVueのようなSPAの場合はBrowser-based flows also support client-side applicationsを使用します。

この場合、kratosへのAPIリクエスト時に、Accept: application/json ヘッダを付与し、結果をJSONで受け取り、ダイレクトは発生しません。

SPAを使用せず、PHPやJava等のMVCフレームワーク等を使用してサーバサイドでHTMLをレンダリングする場合はBrowser-based flows for server-side appsを使用します。

この場合は、kratosへのAPIリクエスト時に、Accept: text/html ヘッダを付与し、結果をHTMLで受け取り、またリダイレクトが返却されることもあります。

但し、本記事のサンプルでは、サーバサイドでHTMLをレンダリングしているものの、HTMXを使用したHDA(Hypermedia-Driven Applications)であるため、上記のユースケースに該当しません。

そこで、ブラウザからkratosへ直接アクセスするのではなく、APIを経由してアクセスし、kratosから返却された情報をHTMXを使用してレンダリングする構成としています。

APIからkaratosへアクセスする際には、JSONでやりとりすることになるため、Browser-based flows also support client-side applicationsを使用しています。

APIからkratosを使用しているため、一見、API flowsを使用するべきようにも見えますが、API flowsはあくまでも、Cookieを利用不可能なネイティブアプリに使用することを想定したものです。

構成の詳細は後述します。

flowの種類と実行の流れ

flowには以下の種類があります。

  • Registration flow (ユーザーの登録)
  • Verification flow (メールアドレス検証)
  • Login flow (ログイン)
  • Recovery flow (パスワードリセット)
  • Settings flow (プロフィール更新/パスワード変更)

基本的には、最初にflowを作成し、発行されたflowに所定のパラメータを指定して、flowを更新します。

kratosには専用のDBが存在し、flow情報はDBに保管されます。

uiカラムには、レンダリングに必要な情報が格納され、create時とupdate時の結果によって内容が更新されます。

idを指定してflowを取得するAPIも用意されており、画面遷移等の都合でflow情報を取得し、csrf_token等の必要な情報を取得することがあります。

browser flwoの場合にのみ使用する、csrf_tokenも格納されています。

flowには有効期限expires_atがあります。

flowによっては、状態を持ち、更新ステップが2回必要なものもあります。

flowの情報は、flowの種類ごとにkratosのDBのselfservice_xxxxxx_flowsテーブルに保管されており、flowの状態はstateカラムに保管されています。

flow間の遷移

Registration flow と Recovery flowについては、flowの完了時点で別のflowが作成されます。

Registration flow から Verification flow への遷移

ユーザー登録時に、Registration flowが使用されます。

Identity Schemaの IdentifierにEmailが存在し、なおかつEmailを使用してVeificationを実行するように指定している場合は、Registration flow完了後に、Emailを検証するためのVerification flowが作成され、検証メールの送信までが実施されます。

kratos/general/identity.schema.user_v1.json
 "properties": {
    "traits": {
      "type": "object",
      "properties": {
        "email": {
          "type": "string",
          "format": "email",
          "title": "E-Mail",
          "ory.sh/kratos": {
            "credentials": {
              "password": {
                "identifier": true
              }
            },
            "verification": {
              "via": "email"
            },
            ...
          }
        },
        ...

通常、Verification flowの作成をkratos APIを通じて行った場合、Verification flow のstateはchoose_methodになり、検証対象のEMailによる更新を待つ状態となりますが、Registration flow完了後はこのステップまでが実施された状態となり、Verification flowのstateもsent_emailに更新されます。

この後は、検証メールに記載された検証コードを使用して、Verification flowを更新すると、stateがpassed_challengeとなり、Verification flowが完了します。

Recovery flow から Settings flow への遷移

パスワードリセット時に、Recovery flowが使用されます。

メールアドレスを入力してRecovery flowを作成し、送信された復旧コードを使用して、Recovery flowが完了すると、パスワードを設定するためのSettings flowが作成されます。

Recovery flow完了時にセッションも発行されるため、作成されたSettings flowを使用して、パスワードを設定することができます。

サンプルの構成

改めて、サンプルコードのリポジトリは以下です。

Golang実装のAPIサーバーと、kratos、およびkratosのDBが起動します。

kratosからメール送信を行うため、ローカル確認用のメールサーバであるMailSlurperも起動しています。

起動コマンド

docker compose up

起動するコンテナ

docker compose で以下のコンテナが起動します。

コンテナ 外部公開エンドポイント 概要
kratos - kratosサーバー
kratos-migrate - kratosのDBマイグレーション(実行後終了)
db-kratos postgres://kratos:secret@db-kratos:5432/kratos kratosで使用する Postgres
mailslurper http://localhost:4436 ローカル用メールサーバ
app-sample http://localhost:3000 kratosを使用したサンプルアプリケーション

ディレクトリ構成

.
├── app // サンプルアプリケーションのAPI(Golang)
│   └── sample
│       ├── Dockerfile
│       └── cmd
│         └── server // サンプルアプリケーションのエントリーポイント
│       ├── go.mod
│       ├── go.sum
│       ├── handler // HTTP Request/Responseのハンドリング
│       ├── kratos  // kratos API呼び出しをラッピング
│       ├── static // 静的ファイル
│       └── templates // HTMLテンプレート
└── kratos 
    ├── config.yml // kratos設定ファイル
    ├── identity.schema.user_v1.json // Identity Schema
    └── templates // メールテンプレート

HTMXによるHDAの採用

本記事のサンプルでは、golangでAPIサーバを実装し、APIサーバを経由してkratosへアクセスする構成としています。

ReactやVueのようなSPAを使用しないサーバサイドレンダリングですが、HTMXによるHDA(Hypermedia-Driven Applications)を採用しています。

HDAをざっくり説明すると、HTMLの一部分(フラグメント)をレンダリングして返却するが、ページ全体を読み込み直すことなく部分的に描画可能な方法です。

最初のURLアクセスでは、ページ全体のHTMLが返却され(通常のサーバサイドレンダリング)、読み込んだページの中で非同期でAPIを呼び出すと(AJAX)、HTMLのフラグメントが返却されます。

このように、サーバサイドレンダリングとクライアントサイドレンダリングが合成されたような仕組みがHDAです。

今後の開発で効率が良くなりそうと考え、実験的にHDA(HTMX)を採用しています。

HDAを前提としたBrowser-based flowsの実装方針

HDAの仕組み上、ブラウザからkratosへ直接アクセスする方式には少々不都合があります。

まず、Browser-based flows also support client-side applicationsでは、返却値がJSONであるため、そのまま直接は使用できません。

また、Browser-based flows for server-side appsでは、エラー時等の画面遷移の際に、kratosからリダイレクトが返却されます。

リダイレクト先の処理で、改めてflowやエラー情報を取得し、レンダリングしたHTMLを返却する実装も可能ですが、HDA的にあまり直感的でないように思いました。

非同期でAPIを呼び出してもしエラーが発生した場合、エラー情報も含めてレンダリングされたHTMLが返却されて欲しいところです。

HDAでもリダイレクトによる画面遷移を使用することはありますが、処理の内容によって、リダイレクトするか、HTMLフラグメントを返却するかは選択できる方が直感的な制御ができると思います。

上記を考慮すると、kratosへのアクセスはAPIサーバを経由し、APIサーバからレンダリングされたHTMLを返却する構成が最適と考えました。

APIサーバーからkratosへアクセスする際には、JSONでやりとりしたいためBrowser-based flows also support client-side applicationsを使用しています。

APIサーバー内で、kratos SDKを使用して実装しており、SDK使用時にはAccept: application/jsonが付与されており、自ずとBrowser-based flows also support client-side applicationsが使用されます。

ユーザー登録

ユーザー登録から、メールアドレスの検証までの流れを解説します。

Registration flow -> Verification flowの流れで実行します。

Registration flowの作成と更新

ユーザー登録は、最初にRegistration flowを作成します。

app/auth-general/handler/handler_auth.go
type handleGetAuthRegistrationdRequestParams struct {
	cookie string
	flowID string
}

func (p *Provider) handleGetAuthRegistration(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	session := getSession(ctx)

	reqParams := handleGetAuthRegistrationdRequestParams{
		cookie: r.Header.Get("Cookie"),
		flowID: r.URL.Query().Get("flow"),
	}

	// Registration Flow の作成 or 取得
	// Registration flowを新規作成した場合は、FlowIDを含めてリダイレクト
	output, err := p.d.Kratos.CreateOrGetRegistrationFlow(kratos.CreateOrGetRegistrationFlowInput{
		Cookie: reqParams.cookie,
		FlowID: reqParams.flowID,
	})
	if err != nil {
		w.WriteHeader(http.StatusOK)
		tmpl.ExecuteTemplate(w, templatePaths.AuthRegistrationIndex, viewParameters(session, r, map[string]any{
			"ErrorMessages": output.ErrorMessages,
		}))
		return
	}
  ...続く

kratosへのアクセスはkratosパッケージを使用してラッピングしています。

app/auth-general/kratos/selfservice.go
type CreateOrGetRegistrationFlowInput struct {
	Cookie string
	FlowID string
}

type CreateOrGetRegistrationFlowOutput struct {
	Cookies       []string
	FlowID        string
	IsNewFlow     bool
	CsrfToken     string
	ErrorMessages []string
}

// Registration Flow がなければ新規作成、あれば取得
// csrfTokenは、本来は *kratosclientgo.RegistrationFlow から取得できるはずだが、
// kratos-client-go:v1.0.0 に不具合があるため、http.Response から取得し返却している
func (p *Provider) CreateOrGetRegistrationFlow(i CreateOrGetRegistrationFlowInput) (CreateOrGetRegistrationFlowOutput, error) {
	var (
		err              error
		response         *http.Response
		registrationFlow *kratosclientgo.RegistrationFlow
		output           CreateOrGetRegistrationFlowOutput
	)

	// flowID がない場合は新規にRegistration Flow を作成
	// flowID がある場合はRegistration Flow を取得
	if i.FlowID == "" {
		registrationFlow, response, err = p.kratosPublicClient.FrontendApi.
			CreateBrowserRegistrationFlow(context.Background()).
			Execute()
		if err != nil {
			slog.Error("CreateRegistrationFlow Error", "RegistrationFlow", registrationFlow, "Response", response, "Error", err)
			output.ErrorMessages = getErrorMessages(err)
			return output, err
		}
		slog.Info("CreateRegistrationFlow Succeed", "RegistrationFlow", registrationFlow, "Response", response)

		output.IsNewFlow = true

	} else {
		registrationFlow, response, err = p.kratosPublicClient.FrontendApi.
			GetRegistrationFlow(context.Background()).
			Id(i.FlowID).
			Cookie(i.Cookie).
			Execute()
		if err != nil {
			slog.Error("GetRegistrationFlow Error", "RegistrationFlow", registrationFlow, "Response", response, "Error", err)
			output.ErrorMessages = getErrorMessages(err)
			return output, err
		}
		slog.Info("GetRegisrationFlow Succeed", "RegistrationFlow", registrationFlow, "Response", response)
	}

	output.FlowID = registrationFlow.Id

	// SDKを使用しているので、本来は上記レスポンスの第一引数である
	// *kratosclientgo.RegistrationFlow から csrf_token その他を取得するところだが、
	// goのv1.0.0のSDKには不具合があるらしく、仕方ないのでhttp.Responseのbodyから取得している
	// https://github.com/ory/sdk/issues/292
	b, err := readHttpResponseBody(response)
	if err != nil {
		slog.Error(err.Error())
		return output, err
	}
	output.CsrfToken = getCsrfTokenFromResponseBody(b)

	// browser flowでは、kartosから受け取ったcookieをそのままブラウザへ返却する
	output.Cookies = response.Header["Set-Cookie"]

	return output, nil
}

FlowIDの指定がなければ新規にRegistration Flowを作成し、あれば作成済みのRegistration Flowを取得します。

Registration FlowにはCSRF Tokenが含まれており、Browser-based flowsでは、flowの更新時にCSRF Tokenが必要となるため、取得しています。

kratosへのアクセスにはkratos-client-goのSDKに不具合があるようなので、http.Responseから取得しています。

また、Browser-based flowsでは、kratosからCookieも返却され、flowの作成時に、CSRF用のCookieも作成されています。

Browser-based flowsでのflow更新時には、CSRF TokenとCSRF Cookieの両方が必要です。

kratosへ直接アクセスする場合は、kratosから返却されたCookieがそのままブラウザで読み込まれるため(withCredentials有効時)、意識する必要はありませんが、本サンプルのようにAPIサーバーを経由する場合は、kratosから返却されたCookieを、明示的にAPIサーバーからブラウザへ返却する必要があります。

作成したRegistration FlowのFlowIDCsrfTokenを埋め込み、HTMLをレンダリングします。

app/auth-general/handler/handler_auth.go
  ...続き
	// kratosのcookieをそのままブラウザへ受け渡す
	setCookieToResponseHeader(w, output.Cookies)

	if output.IsNewFlow {
		redirect(w, r, fmt.Sprintf("%s?flow=%s", routePaths.AuthRegistration, output.FlowID))
		return
	}

	// flowの情報に従ってレンダリング
	w.WriteHeader(http.StatusOK)
	tmpl.ExecuteTemplate(w, templatePaths.AuthRegistrationIndex, viewParameters(session, r, map[string]any{
		"RegistrationFlowID": output.FlowID,
		"CsrfToken":          output.CsrfToken,
	}))
}
app/sample/templates/auth/registration/_form.html
<form 
  id="registration-form"
  hx-post="/auth/registration?flow={{.RegistrationFlowID}}" 
  hx-swap="outerHTML" 
  hx-target="this"
>
  <input
    name="csrf_token"
    type="hidden"
    value="{{.CsrfToken}}"
  />

  <div class="mt-2 mb-4">
    <label class="form-control">
      <div class="label">
        <span class="label-text font-semibold">メールアドレス</span>
      </div>
      <input 
        id="email"
        name="email" 
        value="yoshinori.satoh.tokyo@gmail.com"
        placeholder="例) niko-chan@kratos-example.com"
        {{if .ValidationFieldError.Email}}
        class="input input-bordered input-error"
        {{else}}
        class="input input-bordered"
        {{end}}
      >
      {{if .ValidationFieldError.Email}}
      <div class="text-sm text-red-700 my-2">{{.ValidationFieldError.Email}}</div>
      {{end}}
    </label>

    <label class="form-control">
      <div class="label">
        <span class="label-text">パスワード</span>
      </div>
      <input 
        id="password"
        type="password" 
        name="password" 
        value="Overwatch2024!@"
        {{if .ValidationFieldError.Password}}
        class="input input-bordered input-error"
        {{else}}
        class="input input-bordered"
        {{end}}
      >
      {{if .ValidationFieldError.Password}}
      <div class="text-sm text-red-700 my-2">{{.ValidationFieldError.Password}}</div>
      {{end}}
    </label>
  </div>
	...
	<div class="mx-auto text-center">
    <button class="btn btn-primary btn-wide">次へ</button>
  </div>

HTMXでは、hx-postのような記述で、formの場合はsubmitをトリガーにAJAX通信が行われます。

クエリパラメータのflowと、hiddenのcsrf_token、および各種input要素のemailpasswordがbodyに含まれて送信されます。

上記の場合POST /auth/registrationへAJAXでアクセスし、正常にflowを更新できればVerification flowのコード入力画面へ遷移し、もしエラーがあればレスポンスとしてHTMLフラグメントが返却されます。

Registration flowの更新後のVerfiication flowへの遷移

上記のPOSTによって、Registration flowの更新が完了すると、検証メールが送信され、Verification flow が state=sent_emailで作成され(上図参照)、またVerification flow IDが返却されます。

app/auth-general/kratos/selfservice.go
func (p *Provider) UpdateRegistrationFlow(i UpdateRegistrationFlowInput) (UpdateRegistrationFlowOutput, error) {
	var output UpdateRegistrationFlowOutput

	// Registration Flow の送信(完了)
	updateRegistrationFlowBody := kratosclientgo.UpdateRegistrationFlowBody{
		UpdateRegistrationFlowWithPasswordMethod: &kratosclientgo.UpdateRegistrationFlowWithPasswordMethod{
			Method:   "password",
			Password: i.Password,
			Traits: map[string]interface{}{
				"email": i.Email,
			},
			CsrfToken: &i.CsrfToken,
		},
	}
	successfulRegistration, response, err := p.kratosPublicClient.FrontendApi.
		UpdateRegistrationFlow(context.Background()).
		Flow(i.FlowID).
		Cookie(i.Cookie).
		UpdateRegistrationFlowBody(updateRegistrationFlowBody).
		Execute()
	if err != nil {
		slog.Error("UpdateError", "Response", response, "Error", err)
		output.ErrorMessages = getErrorMessages(err)
		return output, err
	}
	slog.Info("UpdateRegisrationFlow Succeed", "SuccessfulRegistration", successfulRegistration, "Response", response)

	// SDKを使用しているので、本来は上記レスポンスの第一引数である
	// *kratosclientgo.SuccessfulNativeRegistration から必要な値を取得するところだが、
	// goのv1.0.0のSDKには不具合があるらしく、仕方ないのでhttp.Responseから取得している
	// https://github.com/ory/sdk/issues/292
	b, err := readHttpResponseBody(response)
	if err != nil {
		slog.Error(err.Error())
		return output, err
	}
	output.VerificationFlowID = getContinueWithVerificationFlowId(b)

	// browser flowでは、kartosから受け取ったcookieをそのままブラウザへ返却する
	output.Cookies = response.Header["Set-Cookie"]

	return output, nil
}

Verification flow ID取得の際もCsrf Tokenの場合と同様に、kratos-client-goのSDKに不具合があるようなので、http.Responseから取得しています。

ここで、少しおさらいで、Registration flowからVerification flowへの遷移の流れ図を再掲します。

Verification flowには以下のステップがあり、stateが更新されます。

  1. Verification flowの作成 (state -> choose_method)
  2. 検証対象のEmailを指定してflowを更新 (state -> sent_email)
  3. 2.で指定したEmailへ送信された検証コードを指定してflowを更新 (state -> passed_challenge)

Registration flow完了時点では、kratos側で上記の手順2.までが実行されています。

ここで検証対象のEmailとは、ユーザー登録しようとしているEmailです。

残る手順は3.のみであり、Emailへ送信された検証コードと、Registration flow完了時に返却されたVerification flow IDを使用して、Verification flowを更新します。

Verification flowの更新 (state -> passed_challenge)

Registration flow完了時に返却されたVerification flow IDを使用して、検証コード入力フォームをレンダリングします。

HTMLテンプレートの一部を抜粋します。

app/auth-general/templates/verification/_code_form.html
<form 
  id="verification-form" 
  hx-post="/auth/verification/code?flow={{.VerificationFlowID}}"
  hx-swap="outerHTML" 
  hx-target="this"
  > 
  <input
    name="csrf_token"
    type="hidden"
    value="{{.CsrfToken}}"
  />

  <div class="mt-2 mb-4">
    <label class="form-control">
      <div class="label">
        <span class="label-text">検証コード</span>
      </div>
      <input 
        name="code" 
        min="6" 
        max="6" 
        {{if .ValidationFieldError.Code}}
        class="input input-bordered input-error"
        {{else}}
        class="input input-bordered"
        {{end}}
      />
      {{if .ValidationFieldError.Code}}
      <div class="text-sm text-red-700 my-2">{{.ValidationFieldError.Code}}</div>
      {{end}}
    </label>
  </div>

  <div class="mx-auto text-center">
    <button class="btn btn-primary btn-wide">送信</button>
  </div>

  {{ template "_alert.html" }}
</form>

Registration flowと同様、上記HTMLには、VerificationFlowIDCsrfTokenが埋め込まれています。

formがsubmitされると、POST /auth/verification/codeがAJAXでアクセスされ、正常にflowを更新できればVerification flowを完了してログイン画面へ、もしエラーがあればレスポンスとしてHTMLフラグメントが返却されます。

app/auth-general/kratos/selfservice.go UpdateVerificationFlow
func (p *Provider) UpdateVerificationFlow(i UpdateVerificationFlowInput) (UpdateVerificationFlowOutput, error) {
	var (
		output     UpdateVerificationFlowOutput
		updateBody kratosclientgo.UpdateVerificationFlowWithCodeMethod
	)

	// email設定時は、Verification Flowを更新して、アカウント検証メールを送信
	// code設定時は、Verification Flowを完了
	if i.Email != "" && i.Code == "" {
		updateBody = kratosclientgo.UpdateVerificationFlowWithCodeMethod{
			Method:    "code",
			Email:     &i.Email,
			CsrfToken: &i.CsrfToken,
		}
	} else if i.Email == "" && i.Code != "" {
		updateBody = kratosclientgo.UpdateVerificationFlowWithCodeMethod{
			Method:    "code",
			Code:      &i.Code,
			CsrfToken: &i.CsrfToken,
		}
	} else {
		err := fmt.Errorf("parameter convination error. email: %s, code: %s", i.Email, i.Code)
		slog.Error("Parameter convination error.", "email", i.Email, "code", i.Code)
		return output, err
	}

	// Verification Flow の送信(完了)
	updateVerificationFlowBody := kratosclientgo.UpdateVerificationFlowBody{
		UpdateVerificationFlowWithCodeMethod: &updateBody,
	}
	successfulVerification, response, err := p.kratosPublicClient.FrontendApi.
		UpdateVerificationFlow(context.Background()).
		Flow(i.FlowID).
		Cookie(i.Cookie).
		UpdateVerificationFlowBody(updateVerificationFlowBody).
		Execute()
	if err != nil {
		slog.Error("UpdateVerificationFlow Error", "Response", response, "Error", err)
		output.ErrorMessages = getErrorMessages(err)
		return output, nil
	}
	slog.Info("UpdateVerification Succeed", "SuccessfulVerification", successfulVerification, "Response", response)

	// browser flowでは、kartosから受け取ったcookieをそのままブラウザへ返却する
	output.Cookies = response.Header["Set-Cookie"]

	return output, nil
}

flow ID, CSRF Tokenおよび検証コードを受け取り、Verification flowを更新しています。

更新に成功すると、stateはpassed_challengeに更新されます。

Registration flow から Verification flow へ遷移する場合だけでなく、Verification flowを最初から実行するケースもあります。

(登録後の画面から検証コード入力し損ねて、別画面へ遷移してしまった場合など)

このため、Emailが指定された場合は、

2. 検証したいEmailを使用して、flowを更新 (state -> sent_email)を、

Codeが指定された場合は

3. 2.で指定したEmailへ送信された、検証コードを使用して、flowを更新 (state -> passed_challenge)

を実行します。

アカウント復旧(パスワードリセット)

パスワードリセット時の流れを解説します。

Recovery flow -> Settings flow (password)の流れで実行します。

Recovery flowの作成と更新

Recovery flowの更新は、以下の2ステップ必要です。
ここでも、Recovery flowからSettings flowへの遷移の流れ図を再掲します。

Verification flowには以下のステップがあり、stateが更新されます。

  1. Recovery flowの作成 (state -> choose_method)
  2. アカウント復旧したいメールアドレスを指定して更新 (state -> sent_email)
  3. メールアドレスに送信されたアカウント復旧コードを指定して更新 (state -> passed_challenge)

Recovery flowの作成は、Registration flowの作成と同様です。

手順2.は、Recovery flowを更新する際のパラメーターにEmailを指定するだけで、Registration flowの更新と同様のため、コード解説は割愛します。

手順3.では、メールに送信されたアカウント復旧コードを指定して、Recovery flowを更新すると、パスワード変更のためのSettings flowが作成され、またログインセッションも作成されます。(Browser-based flowsの場合はCookieでログインセッションが返却されます)

Recovery flowの更新後のSettings flowへの遷移

Settings flowを使用することでパスワードの変更が可能ですが、Settings flowの実行には、ログイン状態である必要があります。

アカウント復旧(パスワードリセット)時は、通常のログインができない状態であるため、Recovery flowの実行によって取得したログインセッションを使用する必要があります。

Recovery flowの完了後には、Settings flowが作成されますが、Registration flow -> Verification flowの場合のように、そのまま継続するのではなく、一度ブラウザ上でリダイレクトが必要です。

CSRF等のセキュリティを確保するためだと思いますが、以下で関連トピックが議論されているようです。

https://github.com/ory/kratos/discussions/2959

https://github.com/ory/kratos/issues/2884

Recovery flowの更新後に成功すると、status code 422 (browser_location_change_required) のエラーが返却されるのですが、エラー内のredirect_browser_toにリダイレクト先のURLが含まれています。

app/auth-general/kratos/selfservice.go
func (p *Provider) UpdateRecoveryFlow(i UpdateRecoveryFlowInput) (UpdateRecoveryFlowOutput, error) {
  ...

	// Recovery Flow を更新
	updateRecoveryFlowBody := kratosclientgo.UpdateRecoveryFlowBody{
		UpdateRecoveryFlowWithCodeMethod: &updateBody,
	}
	recoveryFlow, response, err := p.kratosPublicClient.FrontendApi.
		UpdateRecoveryFlow(context.Background()).
		Flow(i.FlowID).
		Cookie(i.Cookie).
		UpdateRecoveryFlowBody(updateRecoveryFlowBody).
		Execute()
	if err != nil {
		slog.Error("Update Recovery Flow Error", "RecoveryFlow", recoveryFlow, "Response", response, "Error", err)
		// browser location changeが返却された場合は、リダイレクト先URLを設定
		if response.StatusCode == 422 {
			output.RedirectBrowserTo = getRedirectBrowserToFromError(err)
			output.Cookies = response.Header["Set-Cookie"]
		} else {
			output.ErrorMessages = getErrorMessages(err)
		}
		return output, err
	}
	slog.Info("UpdateRecovery Succeed", "RecoveryFlow", recoveryFlow, "Response", response)

	// browser flowでは、kartosから受け取ったcookieをそのままブラウザへ返却する
	output.Cookies = response.Header["Set-Cookie"]

	return output, nil
}

Seggins flowの更新 (state -> success)

redirect_browser_toには、Settings flowのUI URLが?flow=のクエリパラメータ入りで入っています。

UI URLは、kratosのconfigで設定可能です。

kratos/config.yml
selfservice:
  ...
  flows:
    settings:
      ui_url: http://localhost:3000/my/password
			...

上記のUI URLへにリダイレクトされた際に、クエリパラメータで付与されたflow IDから、作成されたSettings flowを取得します。

flow IDが指定された場合は、CreateOrGetSettingsFlow関数で作成済みのSettings flowを取得しています。

(Settings flowには、更新できる対象として、password以外にもTraits内の項目(Emailや、例えばNickname等のプロフィール項目)の更新が可能です。)

app/auth-general/handler/handler_auth.go
type handleGetMyPasswordRequestParams struct {
	cookie string
	flowID string
}

func (p *Provider) handleGetMyPassword(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	session := getSession(ctx)

	reqParams := handleGetMyPasswordRequestParams{
		cookie: r.Header.Get("Cookie"),
		flowID: r.URL.Query().Get("flow"),
	}

	// Setting Flow の作成 or 取得
	// Setting flowを新規作成した場合は、FlowIDを含めてリダイレクト
	output, err := p.d.Kratos.CreateOrGetSettingsFlow(kratos.CreateOrGetSettingsFlowInput{
		Cookie: reqParams.cookie,
		FlowID: reqParams.flowID,
	})

	if err != nil {
		tmpl.ExecuteTemplate(w, "my/password/index.html", viewParameters(session, r, map[string]any{
			"ErrorMessages": output.ErrorMessages,
		}))
	}

	// kratosのcookieをそのままブラウザへ受け渡す
	setCookieToResponseHeader(w, output.Cookies)

	// flowの情報に従ってレンダリング
	tmpl.ExecuteTemplate(w, "my/password/index.html", viewParameters(session, r, map[string]any{
		"SettingsFlowID":       output.FlowID,
		"CsrfToken":            output.CsrfToken,
		"RedirectFromRecovery": reqParams.flowID == "recovery",
	}))
}
app/sample/templates/my/password/_form.html
<form 
  id="password-form"
  hx-post="/my/password?flow={{.SettingsFlowID}}" 
  hx-swap="outerHTML" 
  hx-target="this"
>
  <input
    name="csrf_token"
    type="hidden"
    value="{{.CsrfToken}}"
  />
  <div class="mt-2 mb-4">
    <label class="form-control">
      <div class="label">
        <span class="label-text">パスワード</span>
      </div>
      <input 
        id="password"
        type="password" 
        name="password" 
        value="Overwatch2024!@"
        class="input input-bordered"
        onkeyup="this.setCustomValidity('')"
        hx-on:htmx:validation:validate="
          if(this.value != document.getElementById('password-confirmation').value) {
            this.setCustomValidity('パスワードが一致しません') 
            htmx.find('#settings-form').reportValidity()
          }
        "
      >
    </label>

    <label class="form-control">
      <div class="label">
        <span class="label-text">パスワード確認</span>
      </div>
      <input 
        id="password-confirmation"
        type="password" 
        name="password-confirmation" 
        value="Overwatch2024!@"
        class="input input-bordered"
        onkeyup="this.setCustomValidity('')"
      >
    </label>
  </div>

  <div class="mx-auto text-center">
    <button class="btn btn-primary btn-wide">送信</button>
  </div>

上記画面から、POST /my/passwordへAJAXでアクセスし、正常にflowを更新できれば完了です。

もしエラーがあればレスポンスとしてHTMLフラグメントが返却されます。

まとめ

ブラウザから、kratosの各種self-service flowを実行するサンプルコードを紹介しました。

kratosへのアクセスをAPIでラッピングし、レンダリングにHTMXを使用したHDAの構成でのサンプルです。

kratosが持つセキュリティの恩恵に預かるためには、kratosのself-service flowを理解した実装が必要です。

flowによっては、Registration flow から Verification flow へ遷移するなど、flow間の遷移についても理解が必要で、仕様理解が大変な部分だと思います。

本サンプルは、私自身の実装リファレンスとしての役割が主ですが、よろしければご参考にしてください。

Discussion