はじめに
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));
})();
TOKEN と YOUR-DOMAIN と My 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