背景
自分のツールサイトに、ユーザー名とパスワードから Basic認証のヘッダーを作るページを置いています。
https://hashitosystem.com/tools/basicauth/
中身を読み返していたら、Base64 にする部分が btoa(user + ':' + pass) の1行ではなく、TextEncoder を通す8行の関数になっていました。
なぜ遠回りしているのか、自分でも忘れていたので、1行版に戻すと何が起きるか試しました(理由をコメントに書かないのは本当に悪い癖です…)。
Basic認証のヘッダーの形
前提です。
Basic認証は、HTTP のリクエストにユーザー名とパスワードを載せて認証する、いちばん単純な方式だ。
仕様は RFC 7617 で、ユーザー名:パスワード をコロンでつないだ文字列を Base64 にして、Authorization: Basic {Base64の文字列} というヘッダーで送ります。
Base64 は、任意のバイト列を英数字と + / = だけの文字列に置き換える符号化で、暗号化ではありません(誰でも元に戻せます)。
ASCII だけなら btoa 1行で作れます。
console.log(btoa('tanaka:p@ss')); // dGFuYWthOnBAc3M=
1行版に日本語を入れる
btoa はブラウザにも Node.js にもある Base64 化の関数で、引数の文字列を「1文字=1バイト」とみなして変換する。
なので 0〜255 に収まらない文字が来ると変換できません。
下記は Node.js 24 で、ユーザー名を「田中」にしたものです。
try {
btoa('田中:p@ss');
} catch (e) {
console.log(e.name, e.message); // InvalidCharacterError Invalid character
}
例外で落ちるので、こちらはまだ気付けます。
厄介なのは、0〜255 に収まる文字のほうでした。
console.log(btoa('café:x')); // Y2Fm6Tp4
é は Latin-1(1バイトで西欧の文字を表す文字コード)の範囲にあるので、例外は出ずに 0xE9 の1バイトとして変換されます。
ところが UTF-8 では é は 0xC3 0xA9 の2バイトなので、サーバーが UTF-8 で照合していると、この値は別人のパスワードになる。
エラーは出ないのに認証だけが通らない、という一番いやな壊れ方です。
UTF-8 のバイト列にしてから渡す
ツールで使っているのが下記です。
str には ユーザー名 + ':' + パスワード をつないだ文字列を渡します。
function b64encodeUtf8(str) {
const bytes = new TextEncoder().encode(str); // UTF-8 のバイト列
let bin = '';
for (const b of bytes) bin += String.fromCharCode(b); // 1バイト1文字の文字列に
return btoa(bin);
}
console.log(b64encodeUtf8('田中:p@ss')); // 55Sw5LitOnBAc3M=
console.log(b64encodeUtf8('café:x')); // Y2Fmw6k6eA==
TextEncoder で一度 UTF-8 のバイト列にし、各バイトを 0〜255 の1文字に置き直してから btoa に渡している。
田中:p@ss は Node.js の Buffer.from('田中:p@ss', 'utf8').toString('base64') と同じ 55Sw5LitOnBAc3M= になりました。
café も、1行版の Y2Fm6Tp4 から Y2Fmw6k6eA== に変わっています。
並べると下記のとおりです。
入力 btoa だけ TextEncoder 経由
tanaka:p@ss dGFuYWthOnBAc3M= dGFuYWthOnBAc3M= (ASCII は同じ)
田中:p@ss InvalidCharacterError 55Sw5LitOnBAc3M=
café:x Y2Fm6Tp4 (Latin-1 の値) Y2Fmw6k6eA== (UTF-8 の値)
なぜ UTF-8 に揃えるかというと、RFC 7617 がサーバー側に charset="UTF-8" を宣言する手段を用意していて、実質の標準がそちらだからです。
ただし古いサーバーには Latin-1 前提のものもあるらしく、そこは相手次第になります。
ユーザー名のコロンは弾く
もう1つ、ツールにはユーザー名にコロンがあると生成を止める分岐が入っています。
RFC 7617 では、ユーザー名にコロンを含めてはいけない、と決まっている。
受け取る側は最初のコロンで分割するので、試すと下記のようになりました。
const s = 'dev:team' + ':' + 'secret';
const i = s.indexOf(':');
console.log([s.slice(0, i), s.slice(i + 1)]); // [ 'dev', 'team:secret' ]
ユーザー名 dev:team のつもりが、ユーザー名 dev・パスワード team:secret として読まれます。
逆にパスワード側のコロンは問題なく、user:a:b:c はパスワード a:b:c として正しく戻りました。
Basic認証のヘッダーを JavaScript で作るなら、btoa に直接渡さず TextEncoder で UTF-8 にしてから Base64 にし、ユーザー名にコロンがあれば作る前に止めてください。
本記事はAI補助で執筆した、個人開発の紹介記事です。