EmDash v0.5.0 でAdmin UIにログインできなくなり、API経由でPasskeyを登録して復旧した話


はじめに

EmDashを0.5.0にバージョンアップしたらAdmin UIにログインできなくなって困り果てた末に、Claudeに相談しながらREST APIを叩いてPasskeyを登録したら、なんとか復旧できました。
MCP接続用にAPIキーを事前に取得しておいたのでCLI経由で復旧できました。

後からClaudeに「同じ問題で詰まってる人のために、手順を記事としてまとめて」とお願いして出てきたのが、この記事です。

なので以下の内容は、私の理解というよりはClaudeの解説です。 同じ症状で詰まっている人の参考になれば幸いです。


この記事が役に立つ人

  • EmDash v0.5.0 以降を Cloudflare Workers + Astro v6 環境で動かしている
  • Admin UI にログインできなくなった
  • Google OAuth はエラーで動かない
  • REST API が存在することは知っているが、Passkey 登録を API 経由でやる方法が分からない

詰まっていた状況

EmDash v0.5.0 の認証方式は3つありますが、私の環境ではすべて詰まっていました。

Google OAuth
EmDash v0.5.0 の内部実装が Astro v6 の Astro.locals.runtime.env 削除に未対応で、ログイン時にエラーが発生

Magic Link
メール送信基盤(Resend 等)の構築が別途必要。同じ Astro v6 バグを踏むリスクもある
Passkey
そもそも Admin UI にログインできないと、Passkey 登録画面にアクセスできない(鶏と卵問題)

特に Passkey の「Admin にログインしないと Passkey を登録できないが、Passkey がないとログインできない」というデッドロックが厄介でした。

解決策の本質

結論だけ先に書くと、こうです。

EmDash は ec_pat_ プレフィックスの Personal Access Token(PAT)を発行できます。
このトークンは MCP サーバー経由だけでなく REST API でも有効で、
/auth/passkey/register/options/register/verify エンドポイントを叩けば、ブラウザの WebAuthn API と組み合わせて Passkey を後付けで登録できます。

つまり、Admin UI に一度もログインしていなくても、PAT さえ手元にあれば Passkey を登録してログイン可能な状態に持っていけます。

手順

前提

  • EmDash v0.5.0 以降がデプロイ済み
  • Claude Code または別の MCP クライアントで EmDash MCP を使ったことがある(= ~/.claude.json に PAT が保存されている)
  • 対象ドメインの Admin UI に、Passkey を登録したいブラウザで到達できる

STEP 1: PAT の取得と認証確認

まず、~/.claude.json から EmDash の PAT を取り出します。

EMDASH_TOKEN=$(cat ~/.claude.json | python3 -c "
import json, sys
data = json.load(sys.stdin)
print(data['mcpServers']['emdash']['headers']['Authorization'].replace('Bearer ', ''))
")

取得できたら、認証が通ることを確認します。

curl -s -H "Authorization: Bearer $EMDASH_TOKEN" \
  https://YOUR-DOMAIN/_emdash/api/auth/me | python3 -m json.tool

role: 50(admin)の自分のユーザー情報が返ってくれば OK です。

STEP 2: 現在の Passkey 登録状況を確認

curl -s -H "Authorization: Bearer $EMDASH_TOKEN" \
  https://YOUR-DOMAIN/_emdash/api/auth/passkey | python3 -m json.tool

まだ登録がなければ、空の配列 [] が返ります。

STEP 3: ブラウザで WebAuthn API を叩いて Passkey を登録

ここが本番です。対象ドメイン(https://YOUR-DOMAIN)のタブを開き、そのタブの DevTools コンソールで以下のスクリプトを実行します。

別ドメインのタブで実行すると CORS で弾かれます。location.origin が対象ドメインになっていることを先に確認しておくと安心です。

(async () => {
  const TOKEN = 'YOUR_TOKEN_HERE'; // ec_pat_ で始まる PAT
  const BASE = 'https://YOUR-DOMAIN/_emdash/api';

  const b64urlToBuf = (s) => {
    const pad = '='.repeat((4 - s.length % 4) % 4);
    const b64 = (s + pad).replace(/-/g, '+').replace(/_/g, '/');
    return Uint8Array.from(atob(b64), c => c.charCodeAt(0)).buffer;
  };

  const bufToB64url = (buf) => {
    const bytes = new Uint8Array(buf);
    let s = '';
    for (const b of bytes) s += String.fromCharCode(b);
    return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
  };

  // Step 1: Options 取得
  const optsRes = await fetch(`${BASE}/auth/passkey/register/options`, {
    method: 'POST',
    headers: { 'Authorization': `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
    body: '{}'
  });
  const opts = (await optsRes.json()).data.options;

  // Step 2: WebAuthn credential 生成(Touch ID などの認証が走る)
  const publicKey = {
    ...opts,
    challenge: b64urlToBuf(opts.challenge),
    user: { ...opts.user, id: b64urlToBuf(opts.user.id) },
    excludeCredentials: (opts.excludeCredentials || []).map(c => ({ ...c, id: b64urlToBuf(c.id) }))
  };
  const cred = await navigator.credentials.create({ publicKey });
  const transports = cred.response.getTransports ? cred.response.getTransports() : [];

  // Step 3: Verify(credential でラップするのが最重要ポイント)
  const verifyPayload = {
    credential: {
      id: cred.id,
      rawId: bufToB64url(cred.rawId),
      response: {
        clientDataJSON: bufToB64url(cred.response.clientDataJSON),
        attestationObject: bufToB64url(cred.response.attestationObject),
        transports: transports
      },
      type: cred.type,
      clientExtensionResults: cred.getClientExtensionResults ? cred.getClientExtensionResults() : {},
      authenticatorAttachment: cred.authenticatorAttachment || undefined
    },
    name: 'My Device Name'
  };

  const verifyRes = await fetch(`${BASE}/auth/passkey/register/verify`, {
    method: 'POST',
    headers: { 'Authorization': `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
    body: JSON.stringify(verifyPayload)
  });

  console.log(`Status: ${verifyRes.status}`);
  console.log('Response:', JSON.stringify(await verifyRes.json(), null, 2));
})();

TOKENYOUR-DOMAINMy Device Name を自分の環境に合わせて書き換えてください。

実行すると Touch ID(または使っている認証器)が起動するので、認証します。
Status: 200 と登録成功のレスポンスが返ってきたらOK。

STEP 4: ログイン確認

Admin UI(https://YOUR-DOMAIN/_emdash/admin)にアクセスして、Passkey でログインできることを確認します。

ハマりどころ

私が実際に詰まった or Claudeから警告されたポイントをまとめておきます。

allow pasting の手入力

最近の Chrome は、DevTools コンソールに外部からコピーしたコードを貼り付けようとすると「self-XSS 攻撃を警告する」表示が出て、一度 allow pasting と手入力するまで貼り付けができません。

これはブラウザ側の正当なセキュリティ機能なので、スクリプトの中身を理解してから実行してください。

credential でのラップを忘れる

EmDash の /register/verify エンドポイントは、WebAuthn の credential オブジェクトをトップレベルの credential キーでラップした形を期待します。

{
  "credential": { /* ここに WebAuthn の内容 */ },
  "name": "My Device Name"
}

ラップせずに直接 credential の中身を送ると、Touch ID の認証自体は通っても、その後の verify で失敗します。最初はこれで3回くらい試行錯誤しました。

transports の欠落

cred.response.getTransports() で取得できる transports 配列(["internal", "hybrid"] など)も必須です。これがないと verify が通りません。

セキュリティ上の注意

  • PAT はパスワード相当の機密情報です。 ec_pat_ トークンは role: 50(admin)の権限を持つため、流出すると管理者権限を奪われます。他人と共有しない、公開リポジトリにコミットしない、記事やスクリーンショットに含めない、を徹底してください。
  • DevTools への貼り付けは self-XSS 攻撃の入り口になりうる ため、Chrome の警告は正当なものです。このスクリプトも、中身を理解してから実行してください。

まとめ

EmDash v0.5.0 の OAuth / Magic Link が環境依存で動かなくても、PAT と REST API さえあれば Passkey を後付けで登録してログインできる、という話でした。

EmDash 側の OAuth / Magic Link バグが修正されれば、この手順は不要になります。それまでの間の、暫定的なワークアラウンドとしてお使いください。

No comments yet