認証認可基盤にパスキーを実装するときに悩んだRPID管理の話

この記事は株式会社ビットキー Advent Calendar 2023、21日目の記事です。

https://qiita.com/advent-calendar/2023/bitkey

# はじめに

ビットキーは現在、パスキー認証を導入しようとしています。

ビットキーのシステムは、お客様が実際に触れられるサービス——[workhub](https://www.workhub.site/), [homehub](https://www.homehub.site/homehubapp) を開発するhubsuite領域と、hubsuiteの認証認可を支えるプラットフォーム領域とで構成されています。

私は認証認可を支えるbitkey platform(以下、BKP)を開発するチームに属しており、つい最近パスキー認証のライブラリ選定から設計・実装までを一通り完了しました。

この記事では、「複数サービスを運営する企業の認証認可基盤」ならではの課題とその解決策を共有します。

# 対象読者

- パスキー認証についてはご存知の方
- パスキー認証を自分のアプリケーションに実装予定の方

# なぜパスキー認証を実装したのか

一言で言うと、ビットキーが提供する全サービスをより安全で便利にするためです。

BKPが認証機能を提供しているhubsuiteではメールアドレスとパスワードを入力してもらう形になっています。

パスワードによる認証はフィッシング詐欺やサーバーからの漏洩等の被害を受けやすく、安全性に難がありますが、パスキーによる認証はUXを損ねることなく、パスワード以上のセキュリティを実現できます。

認証認可基盤であるBKPがパスキー認証によるログインを実現し、hubsuiteへ提供することが、お客様の安全で快適なサービス利用への貢献につながると考えます。

# 使用したライブラリ

BKPはGoで実装されています。

- メンテナンスが頻繁であること
- ドキュメントが充実していること
- 実装参考例が多いこと ([hanko](https://www.hanko.io/pricing)も使用している)
- 無料で使用できること

上記の観点から、パスキー認証のためのライブラリとしてはgo-webauthn/webauthnを選択しました。
開発時点での最新バージョンはv0.9.4でした。

https://github.com/go-webauthn/webauthn

<details><summary>参考 - 他に検討したライブラリ</summary>

| ライブラリ名 | 不採用理由 |
| --- | --- |
| [duo-labs/webauthn](https://github.com/duo-labs/webauthn) | ・スターがかなり多いが、アーカイブ済み<br>・[go-webauthn/webauthn](https://github.com/go-webauthn/webauthn)はこれをforkしたもの |
| [koesie10/webauthn](https://github.com/koesie10/webauthn) | ・比較的スターを得てはいるが、ここ3年間メンテナンスされていない |
| [keys-pub/go-libfido2](https://github.com/keys-pub/go-libfido2) | ・メンテナンス頻度が低い<br>・ドキュメントが充実していない |
| [fxamacker/webauthn](https://github.com/fxamacker/webauthn) | ・ここ3年間メンテナンスされていない |
| [stutonk/passkey](http://github.com/stutonk/passkey) | ・メンテナンス状況が不明<br>・godocからGithubリポジトリが開けない |

</details>

# RPIDの管理について

本題です。

冒頭でもお伝えしたように、ビットキーは現在2つのサービス——workhub, homehub を運営しています。

サービスはそれぞれ異なるドメインを使用しているのですが、ドメインの異なるサービス2つにパスキー認証機能を提供したいとなったとき、RPIDの設定方法に工夫が必要でした。

workhubは`example.com`、homehubは`example.net`のようになっているとお考えください。

## 前提

`webauthn.New()`するとき、`RPID`にはパスキー認証のRelying Partyとなるアプリケーションのドメインをセットしなければなりません。

https://pkg.go.dev/github.com/go-webauthn/webauthn@v0.9.4/webauthn#Config

RPIDに設定したドメインとログイン画面を実装しているアプリケーションのDNSサフィックスがマッチしない場合、ブラウザで以下のようなエラーが出て処理に失敗します。

```javascript
The relying party ID is not a registrable domain suffix of, nor equal to the current domain.
```

例えばRPIDとして`example.com`を設定した場合、パスキー認証を使用したいアプリケーションのオリジンは`login.example.com`, `admin.example.com`のようになっている必要があります。

https://webauthn.wtf/how-it-works/relying-party#relying-party-id

また、パスキーはRPID(= ドメイン)ごとに作成されるので、`example.com`ドメインのwebサイトで登録したパスキーを`example.net`ドメインのwebサイトで認証に使用することはできません。

## 課題

実装初期では、go-webauthn/webauthnのREADMEの実装例を参考に、サーバー起動時にwebauthn.New()した結果をグローバル変数へ格納し、パスキー認証の処理時に参照するようにしていました。

https://github.com/go-webauthn/webauthn/tree/v0.9.4?tab=readme-ov-file#initialize-the-request-handler

しかし、この方法だとRPIDには1つしか値をセットできません。

`RPID: "example.com"`とするとhomehubからはパスキー認証が使えませんし、 `RPID: "example.net"`とするとworkhubからパスキー認証が使えません。

## 対応方針

選択肢は2つありました。

A案: RPIDの設定をhubsuiteに応じて動的に変更し、hubsuiteそれぞれでパスキーの設定を行ってもらう
B案: hubsuite共通のログイン用webサイトを構築し、新しいドメインでホストする。hubsuiteはこのサイトを経由してパスキー認証を行うようにする

|  | メリット | デメリット |
| --- | --- | --- |
| **A案** | hubsuiteの実装者が各々パスキー認証への導線を設計できるので、サービスに合った形でパスキー認証を導入できる | お客様はworkhub, homehubそれぞれでパスキーの設定を行う必要がある |
| **B案** | ・hubsuiteでパスキーを共有でき、「homehubで登録したパスキーでworkhubにもログインできる」というシームレスな体験が実現できる<br>・お客様からするとパスキーの設定が1回で済む | サービスのログイン導線を実装し直すことになるので、BKP, hubsuite双方の開発者にとって実装コストが高い |

私たちはA案を選択しました。理由は2つあります。

1. お客様からするとworkhub, homehubはそもそも別のサイトなので、別々にパスキーの設定をすることになったとしてもUXを損なうことにはならない
2. workhub(会社のアカウント)とhomehub(個人のアカウント)が一緒になるのはセキュリティ的に良くないケースがある

## 実装

「RPIDの設定をhubsuiteに応じて動的に変更」するために、`webauthn.New()`する箇所を以下のように変更しました。

- `webauthn.New()`をパスキー登録/認証APIへのリクエストのたびに行うようにする。RPIDにはリクエストヘッダのOriginから独自ドメインを抽出した文字列をセットする
    - (例)
      - http://admin.example.com からのパスキー登録APIリクエスト → `RPID:"example.com"`としてwebauthnをinit
      - http://www.example.net からのパスキー登録APIリクエスト → `RPID:"example.net"`としてwebauthnをinit
- RPIDとなりうるドメインのホワイトリストを用意し、許可されていないOriginはバリデーションで弾く

動作検証したところ、ホワイトリストに登録さえされていれば、複数の異なるドメインのwebサイトからパスキー登録~認証ができることを確認しています。

サンプルコードも載せておきます。
Originから独自ドメインを抽出する処理を書くにあたって”net/publicsuffix”というパッケージが有用でした。FQDNから`.co.jp`や`.com`などのpublic suffixを切り取ってくれます。

https://pkg.go.dev/golang.org/x/net/publicsuffix

```go
package example

import (
	"errors"
	"net/http"
	"net/url"
	"strings"

	"golang.org/x/net/publicsuffix"
)

var (
	// 例: WEB_AUTHN_ALLOWED_RP_IDS="example.com,example.net"
	webAuthnRPIDs = os.Getenv(`WEB_AUTHN_ALLOWED_RP_IDS`)

	// 例: WEB_AUTHN_ALLOWED_RP_IDS="https://admin.example.com,https://app.example.net"
	webAuthnRPOrigins = os.Getenv(`WEB_AUTHN_ALLOWED_RP_ORIGINS`)
)

func BeginRegistrationWebAuthnPublicKeyCredential(
	req *http.Request
) error {
	// リクエストOriginからドメインを抽出
	url, err := url.Parse(req.Header.Get("Origin"))
	if err != nil {
		return fmt.Errorf(err, "failed to parse url, origin may be invalid")
	}
	fqdn := url.Hostname()
	if fqdn == "" {
		return errors.New("invalid origin")
	}

	var domain string
	if strings.LastIndex(fqdn, ".") == -1 {
		domain = fqdn
	} else {
		// icann準拠かどうかは問わない
		suffix, _ := publicsuffix.PublicSuffix(fqdn)
		fullDomain := fqdn[:len(fqdn)-len(suffix)-1]

		if idx := strings.LastIndex(fullDomain, "."); idx != -1 {
			domain = fullDomain[idx+1:] + "." + suffix
		} else {
			domain = fullDomain + "." + suffix
		}
	}

	// RPIDとなりうるドメインか検証
	if !slices.Contains(strings.Split(webAuthnRPIDs, ","), domain) {
		return errors.New("permission denied")
	}

	// webAuthnをinit
	webAuthn, err := webauthn.New(&webauthn.Config{
		RPID:      domain,
		RPOrigins: strings.Split(webAuthnRPOrigins, ","),
	})
	if err != nil {
		return err
	}

	// ...
}


```

## 振り返って

今回当たった実装上の課題は、

- 認証認可基盤を自社で開発している
- 認証機能を提供するサービスを、それぞれ異なるドメインを使用して運営している

という条件のもとで発生するのかなと思いました。複数のサービスをサブドメインを切ることで運営していれば発生し得ないと思われます。

# さいごに

認証認可基盤にパスキーを実装した私たちのプラクティスをご紹介しました。

私たちもまだ手探りなので、ぜひ情報交換しましょう！勉強会や「ちょっと話したい！」等のご連絡もお待ちしております。

22日目は[@NaotoFujihiro](https://qiita.com/NaotoFujihiro)が担当します！

# 参考

https://developers.google.com/identity/passkeys/developer-guides?hl=ja

https://blog.agektmr.com/2023/12/passkey-mythbusting