1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

jwt.io にトークンを貼り付けたくない人のために、完全ローカル動作の JWT Debugger を作った

1
Posted at

きっかけ

JWT のデバッグといえば jwt.io — それはそう。ただ、本番トークンをブラウザのあの input に貼り付けるの、いつも少しドキドキしませんか?

公式ドキュメントには「デコードは全てブラウザ内で行われ、トークンは送信されない」と書いてあります。信じていいんですが、信じるしかない、というのがちょっと気持ち悪い。スクリプトが将来こっそり fetch('/collect', ...) を足したら、私たちは気づけません。

そこで、ビルド内容で「何も送信しない」ことが保証できる、完全ローカルの JWT デバッガを作りました。

作ったもの

JWT Debuggerhttps://sen.ltd/portfolio/jwt-debugger/

スクリーンショット

  • Header / Payload / Signature を分解表示
  • 時刻クレーム (exp / iat / nbf / auth_time) を Unix / ISO / 相対時刻("5 分後に期限切れ" 等)で表示
  • 期限切れ・未来開始のトークンは即警告
  • UTF-8 payload 対応(日本語 claim が化けない)
  • 日英 UI

vanilla JS + HTML + CSS、ゼロ依存、ビルド不要。ネットワーク呼び出し 0 件。fetchXMLHttpRequest も使っていないので、「何も送信しない」は grep 1 発で確認できます。

「送信しない」をどう保証するか

コードを見れば fetch が存在しないことはすぐ分かるけど、ユーザーにそれを確認させるのは非現実的です。代わりに CSP header で禁止するのが現実的な保証方法:

<meta http-equiv="Content-Security-Policy" content="
  default-src 'self';
  connect-src 'none';
  img-src 'self' data:;
">

connect-src 'none' により、このページから fetch / XMLHttpRequest / WebSocket / EventSourceブラウザレベルで全部禁止されます。JavaScript に悪意があっても通信できません。ソース改ざんの監査を依頼するより、この 1 行の方がはるかに強い保証。

(実際のデプロイでは meta タグ版を使っていますが、本番では HTTP response header で配信した方がより確実です)

JWT は Base64url であって Base64 ではない

JWT の各セグメントは Base64url エンコードです。普通の Base64 との違い:

  • + の代わりに -
  • / の代わりに _
  • 末尾の = padding は省略可

URL に入れても escape が不要にする、という発想の派生版。デコードするときは、逆変換してから普通の atob に渡します。

export function base64UrlDecode(s) {
  let b64 = s.replace(/-/g, '+').replace(/_/g, '/')
  const pad = b64.length % 4
  if (pad === 2) b64 += '=='
  else if (pad === 3) b64 += '='
  else if (pad === 1) throw new Error('invalid base64url length')

  const binary = atob(b64)
  const bytes = new Uint8Array(binary.length)
  for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
  return new TextDecoder().decode(bytes)
}

ポイントは 最後の TextDecoderatob の結果はバイト列ではなく「各文字が 1 バイトを表す擬似文字列」なので、Uint8Array に詰め直してから UTF-8 デコードが必要です。これをサボると日本語 claim が化けます。

// payload に { "name": "山田太郎" } が入っているトークンの場合
{ name: "山田太郎" }  // TextDecoder を省略した場合(mojibake)
{ name: "山田太郎" }      // TextDecoder あり(正しい)

ちなみに Padding の復元ロジック (pad === 1 で invalid) は RFC 7515 の参照実装に合わせています。長さが 4k+1 になる Base64url は存在しない(4 bit 単位でエンコードするので)。

時刻クレームの「相対時刻」が一番使う機能

JWT payload に含まれる時刻クレームは全部 Unix 秒(ミリ秒ではない。ここでハマる人が多い)。

  • exp: expires at
  • iat: issued at
  • nbf: not before
  • auth_time: authentication time (OIDC)

1721462400 を見せられても「いつやねん」となるので、3 つの表現を並べます:

annotations[claim] = {
  value: ts,                                    // 1721462400
  iso: new Date(ts * 1000).toISOString(),       // 2024-07-20T08:00:00.000Z
  delta: ts - nowSec,                           // -3600 (= 1時間前)
}

UI 側では delta を「1 時間前」「5 分後」みたいな相対文字列に変換します。デバッグ中に**一番知りたい情報は「このトークンはまだ生きているか」**なので、この delta がすべて。

期限切れと未来開始はそのまま警告に回します:

if (annotations.exp && annotations.exp.delta <= 0) {
  warnings.push(`exp: expired ${-annotations.exp.delta}s ago`)
}
if (annotations.nbf && annotations.nbf.delta > 0) {
  warnings.push(`nbf: not valid for another ${annotations.nbf.delta}s`)
}

署名検証はあえてしない

本ツールは 署名検証しません。理由:

  1. 検証には secret か public key が必要。secret をブラウザに入力させるのは本末転倒(まさに jwt.io を避けた理由)
  2. HS256 の検証にサーバ秘密鍵を貼るような運用を助長したくない
  3. 署名検証はサーバ側で行うべき。クライアントツールで OK を出してしまうと危険

このツールの用途は「内容を確認する」ことだけ。「このトークンは正しく署名されているか」はサーバの仕事です、という立場を明確にしています。

エラーハンドリングはセグメントごと

JWT のパースは複数段階で失敗しうるので、どこで失敗したかを明示したい:

return { ok: false, error: 'token must be a string' }
return { ok: false, error: `expected 3 segments, got ${parts.length}` }
return { ok: false, error: `header: ${e.message}` }
return { ok: false, error: `payload: ${e.message}` }
return { ok: false, error: `header JSON: ${e.message}` }
return { ok: false, error: `payload JSON: ${e.message}` }

Base64url デコードで落ちたのか、JSON パースで落ちたのか、Header か Payload かを全部区別。UI 側でユーザーに「ここが壊れてますよ」と言えるようにするため。

大抵のエラーは「コピペで末尾が欠けた」か「truncate された」のどちらか。セグメント数エラーで「got 2」と出れば、signature が落ちてるのが即分かります。

テスト

node --test で 13 ケース。

  • 有効な HS256 / RS256 トークンのパース
  • Base64url の -_ 処理
  • padding なし、padding 2 文字、padding 1 文字
  • 日本語 payload の round-trip
  • セグメント数エラー
  • 不正な base64 文字
  • 不正な JSON
  • exp / nbf の warning 判定
npm test

おわりに

SEN 合同会社の ポートフォリオシリーズ 100+ の 6 件目です。

セキュリティ上の指摘・改善案、歓迎です。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?