Articles

投稿記事

Better Auth Multi Platform – Next.js / Go / Expo / Tauri でつくる統合認証

Better Auth Multi Platform – Next.js / Go / Expo / Tauri でつくる統合認証

Next.js と Go をベースとした Web アプリケーションに、
モバイル(Expo)やデスクトップ(Tauri)を後から追加できないか
という純粋な技術的好奇心から、
この「Better Auth Multi Platform」プロジェクトをスタートしました。

なぜやろうと思ったのか

動機は大きく3つあります。

1.マルチプラットフォームでの認証統合
Web、スマホ、デスクトップでそれぞれ異なる認証の仕組みを、
Better Auth を軸にどこまでスマートに一本化できるか検証したかったこと。

2.AI協調開発(ハーネスとループ)の実践
学んだ開発ループやハーネス設計を実際に運用し、
AIと二人三脚で「1人でどこまで堅牢なシステムを組み上げられるか」挑戦したかったこと。

3.プラットフォーム境界のセキュリティ担保
Webとネイティブアプリが交差する処理で生じがちな認証の隙間や潜在リスクを、
机上の空論で終わらせず、厳格な監査とテストで確実に塞ぎ切る実践をしたかったこと。

実装した機能のスコープ

WEB、モバイル、デスクトップでそれぞれ以下の機能を実装しました。
ここまでできると、残りの機能も実装できると見込めそうだからです。

  • パスワードを廃止し、マジックリンクで新規登録とログインする
  • ログアウトする
  • プロフィールを編集する
  • 退会できる
  • GoogleでOAuthログインができる(メールをルートとし、新規登録は出来ない)

システムの構成

まずは、WEBとAPIをVercelとRenderにデプロイして、動作確認しました。
それから、Expoでiosとandroidを実機からインターネット上で動くことを確認しました。
最後に、Tauriを手元のMacからインターネット上で動くことを確認して、完了です。

4つのリポジトリ

web:ログインの発行元と、ブラウザの画面(Vercel + Neon)
https://github.com/shu915/better-auth-multi-platform-web

api:JWT を検証して、プロフィールを返す Go の API(Render)
https://github.com/shu915/better-auth-multi-platform-api

mobile:iOS / Android のクライアント(Expo)
https://github.com/shu915/better-auth-multi-platform-mobile

desktop:macOS のクライアント(Tauri)
https://github.com/shu915/better-auth-multi-platform-desktop

web

分類技術バージョン
フレームワークNext.js(App Router)16.3
UIReact / Tailwind CSS19.3 / 4
言語TypeScript6.0
認証Better Auth1.7
DBPostgres(Neon)/ Drizzle ORM– / 0.45
メールResend6.32
テストVitest / Playwright5.0 / 1.64

主なエンドポイント

パス内容
/api/auth/sign-in/magic-linkマジックリンクのメールを送る
/api/auth/sign-in/socialGoogle でログイン
/api/auth/tokenJWT をもらう(5 分)
/api/auth/jwksJWT を検証する公開鍵
/api/mobile/exchange1 回限りの code を、セッションに引き換える
/api/mobile/profile名前の更新
/api/mobile/account退会

api

分類技術バージョン
言語Go(標準の net/http)1.27
DBPostgres / pgx– / 5.11
JWTlestrrat-go/jwx3.3
マイグレーションgoose3.28
実行環境Docker–

エンドポイント

メソッドパス内容
GET/health生存確認
GET/meトークンのユーザー ID
GET / PUT/me/profileプロフィール(bio)の読み書き
DELETE/me退会時のデータ削除

mobile

分類技術バージョン
フレームワークExpo(SDK)/ Expo Router58 / 58
UIReact Native / React0.88 / 19.3
言語TypeScript6.0
認証Better Auth のクライアント1.7
保管expo-secure-store58
テストVitest3

desktop

分類技術バージョン
アプリTauri2
バックエンドRustedition 2024
画面React / TypeScript / Vite19 / 6.0 / 8
HTTPreqwest0.13
保管keyring(Keychain)4
テストcargo test / Vitest– / 5

mobile と desktop は、自分では API を持たず、web と api の上のエンドポイントを呼びます。

アーキテクチャ図

全体像

ログインの流れ

ER図

学びと気づき

様々な学びや気づきがあったので、まとめました。

DBをあえて分けたことによる学び

DBを認証とビジネスロジックで分割したことは、
メリットとデメリットが有りました。
メリットは、 お互い独立しているので、
Go側はユーザーの識別ID(JWTのsub)だけを検証すれば良く、
Better Authが持つ複雑なテーブルの中身を一切知らなくて済むことです。
API側の作りがとてもシンプルになりました。

デメリットは、 データベースが2つに分かれているため、
外部キーで一発で消すような仕組みが使えないことです。
退会するときは「Go側のデータを消してから、Web側のデータを消す」
というように順番に処理する必要があり、
途中でエラーが起きてもやり直せるように工夫するのが少し大変でした。

初めてモバイルとデスクトップアプリを触って

普段はWebを書いているので、
ExpoとTauriは、どちらも初めてでした。
「ログインの考え方」はWebとほとんど同じでした。
HTTPで呼び、JWTを検証し、セッションを保管する。そこは変わりません。
変わったのは、その周りです。

Webの感覚が、そのまま使えたこと:
認証の設計は、使い回せました。
モバイルで作った「ログインの戻り方」や「判断のロジック」は、
デスクトップでもそのまま手本になりました。
2つ目のクライアントは、1つ目よりずっと速く作れました。

Webの感覚が、通用しなかったこと:
「保存して、ブラウザで開けば動く」が、ありませんでした。
モバイルは、シミュレーターと実機で
接続先が違い(localhostや、Androidの10.0.2.2)、
実機に入れるには署名やUSBデバッグの設定が要ります。
デスクトップは、署名のないアプリだと、キーチェーンが毎回許可を聞いてきますし、
ディープリンクは、ビルドしたアプリを決まった場所に置かないと届きません。
開発用の起動(tauri dev)では動いても、
ビルドしたアプリでは動かない、ということもありました。
コードが正しくても、「OSがどう扱うか」で動きが変わります。

Rustは、最初とまどいました:
Tauriでは、画面はReact、裏側はRustです。
cargoやクレートといった言葉も、最初は分かりませんでした。
それでも、Rustは「画面から切り離して、安全な処理だけを書く場所」と決めると、
役割がはっきりしました。
通信、保管、判断をRustに集め、画面にはトークンも渡さない。
Webでいう「サーバー側」を、手元のアプリの中に持つ感覚でした。

実感:
ネイティブは、コードを書く時間より、
「環境を整えて、実機で確かめる」時間のほうが長くかかります。
ただ、Webで身につけた認証の知識は、そのまま土台になりました。
初めての環境でも、土台が同じなら、迷うのは「OSの流儀」だけで済みます。

セキュリティ境界の違い

Webの世界では、ブラウザがCookieの面倒を見てくれます。
HttpOnlyでJavaScriptから読めなくし、
SameSiteで別のサイトからは送られないようにし、
Secureでhttpsのときだけ送る。
ブラウザ側が勝手に管理してくれるので、
開発者が意識することも少なくて済みます。

ですが、Expo(モバイル)やTauri(デスクトップ)のような
ネイティブ環境になると話がガラッと変わります。

ネイティブアプリには、ブラウザのような「自動のCookie管理」がありません。
ログインの結果(セッション / JWT)を、アプリが自分で受け取り、
自分で保管し、自分で送る必要があります。
そのため、「Webで認証して発行されたトークンを、
アプリ側でどう安全に受け取って保管するか」を
しっかり考える必要がありました。

トークンを受け取るまでの工夫:
ログインが終わってブラウザからアプリへ戻るとき、
URLにセッションを直接載せるのは危険です。
アプリへ戻るURL(独自スキーム)は、
同じスキームを登録した別のアプリにも読めてしまううえ、
履歴やログにも残るからです。
そこで、URLには使い捨ての「code」だけを載せ、
アプリがその code をWebに送って、セッションと引き換える形にしました(60秒、1回限り)。
このとき、PKCEの仕組みを使っています。
アプリは最初に、自分だけが知る合言葉(verifier)を作り、
その指紋(challenge)だけをサーバーに預けます。
引き換えのときに合言葉を見せ、指紋と合ったときだけ、
セッションが渡されます。
code を盗み見られても、合言葉を知らない人は、セッションを手に入れられません。

手元での安全な保管(Keyring / SecureStore):
受け取った認証情報を平文のファイルやLocalStorageのような場所に置くのは危険です。
そのため、ExpoではOS標準の暗号化領域を使うexpo-secure-store
(iOSはKeychain、AndroidはKeystore)を採用し、
Tauri(macOS)ではOSネイティブの「キーチェーン(macOS Keychain)」と
連携するkeyringクレートを使って安全に保存しました。
Tauriでは、セキュリティに関わる処理をすべてRust側に置き、
画面(React)には、セッションもトークンも渡していません。

ハーネスとループを回してみた実感

Web・API・Mobile・Desktopの4つ、
TypeScript・Go・Rustの3言語を1人で行き来すると、
普通は「言語ごとの作法」を思い出すだけで手戻りが増えます。
ここでは、AIに実装を任せつつ、ハーネス(自動の見張り)と
ループ(実装→チェック→レビュー→修正)を回してみました。
手戻りは確かに減りました。
ただし、減ったのは「決まった種類のミス」で、
設計そのもののやり直しは減りませんでした。

ハーネスで防げたこと:
各リポジトリに、危ない操作を止める仕組みと、
毎回の自動チェックを置きました。
たとえば、git push、ロックファイルの手書き編集、
型を黙らせる書き方(any や @ts-ignore)、
Rustの警告の握りつぶしは、実行前に止まります。
応答が終わるたびに、型、lint、テストが走り、
失敗したら差し戻されます。
これで、「動くけれど、あとで困る書き方」や
「テストを通すためにテストを弱める」ことが、
ほとんど起きませんでした。
言語が変わっても、同じルールで見張ってくれるので、
Go、Rust、TypeScriptを行き来しても、
品質の基準がぶれませんでした。

ループで見つかったこと:
実装のあとに、別の窓でレビューを回すと、
自分では気づかない穴が見つかりました。
たとえば、ライブラリのプロキシに、任意のサイトへ飛ばせる穴と、
別のアカウントでログインさせられる穴がありました。
退会の応答を、本文を見ずに「成功」と決めつけていた箇所もありました。
どれも、実機で動かす前の段階で直せたので、あとから見つかるより、ずっと安く済みました。

ハーネスでは防げなかったこと:
一方で、実機でしか分からない問題は、
ハーネスもレビューも防げませんでした。
ブラウザの「アプリを開きますか?」のポップアップが、繰り返すと出なくなること。
署名のないアプリでは、キーチェーンが毎回許可を聞いてくること。
テストはすべて通っていても、使ってみると「カッコ悪い」と感じる設計。
こうした問題は、人が実機で触って初めて分かり、
設計の見直し(ポップアップに頼らない方式への変更)につながりました。

実感:
ハーネスとループは、「うっかり」と「抜け」を減らしてくれます。
ただ、「何を作るか」「この動きでよいか」を決めるのは、
人です。AIに任せる範囲を広げるほど、
実機で確かめる時間と、設計を判断する目が、大事になると感じました。

まとめ

Web・API・Mobile・Desktopの4つで、
同じログインを共有する構成を、本番で動かすところまで作りました。

分かったこと:
認証の考え方は、プラットフォームが変わってもほとんど同じでした。
変わるのは、ブラウザからアプリへ戻る処理と、トークンの保管の仕方です。
そして、危ないのはその境界で、
実際に攻撃の形にして試し、テストで塞ぐことが大切でした。
AIとの開発では、手を動かす時間が減る代わりに、
実機で確かめる時間と、設計を判断する目が大事になりました。

ここまでできたので、残りの機能も同じ型で足せそうだ、
というのが一番の手ごたえです。コードと設計は、リポジトリに残しています。