「APIの認証ではよくJSON Web Token (JWT)が使われている……」なんてあちこちに書かれているので、そんなに面倒なことはないだろうと思っていたら、結構な いばらの道 でした (2025年7月時点の話)。JWTの構造自体は単純で、そこそこ年数も経っているのに。
AlmaLinux 9のパッケージだけで、なんとか認証までたどり着くことができたので、自分用のメモとして残します。色々とウソもつかれたけど、調査を助けてくれたCopilotくんに感謝!
やりたかったこと:
- 異なる組織、異なる計算機にまたがってアプリを連携させたいので、APIのための安全で軽量な認証がほしい。(Basic認証とIPアドレス制限では怖い)
- できればそこそこ枯れた新しい技術を使ってみたい。
- 共通鍵ではなく、公開鍵暗号を使いたい。
- いつも使っているのがApache HTTP Serverなので、これを使いたい。
- 自前でビルドするのは避けたい。
つまずいたのは、ざっとこんなところです ↓
- Apache用のJWT認証というと、まっさきに mod_auth_jwt が出てくる (Copilotくんも)。しかし、肝心のリポジトリが見当たらない。似たようなものがGitHubに幾つかあるものの、パッケージで提供されているようなものではない。
- Apache 2.5には mod_autht_jwt というモジュールがあり、これがApacheの本命に見える。しかし、2.5はまだ出ていない。
- とりあえずトークンを自前で作ってみようとしたら、署名がうまく行かない。
今回、本当に綱渡りで動かしたので、バージョンが違うと動かないことがあるかもしれません。それはそれで、ソフトウェアの品質としてどうなのという思うところはあります。Apacheには早く mod_autht_jwt を提供していただきたいところ。
以下、ノークレームで!
Apache 2.4でJWT認証できる簡単な方法を探す
まず、Copilotくんに聞いてみたら、「mod_auth_jwt というのがあるよ」とのこと。使い方も簡単に見えたので、いざAlmalinuxでdnf searchしてみたら、ない、ないよ……
Copilotくんが指し示したリポジトリは 404 でした。
他に使えそうなものを探しまくっていたら、Apache 2.5にはmod_autht_jwtがあるとのこと。ところが、2.5なんて出ていないんですよねー。
さらにCopilotくんと頑張って探し続けていたところ、ポロリと有用な回答が。
「それ、mod_auth_openidc でもできるよ!」
OIDC特有のリダイレクト機能などを使わず、JWTの認証部分だけを使えばよいとのこと。
mod_auth_openidc の設定例では動かなかった
ドキドキしながら、AlmaLinux 9でdnf searchしてみたら、ありました、mod_auth_openidc-2.4.10-1 !
Copilotくんが示した Complete working example がこちら ↓ (httpdの設定ファイルの中に書くもの)
OIDCValidateJWT On
OIDCJWTIssuer https://your-issuer/
OIDCJWTVerifyJwksUri https://your-issuer/.well-known/jwks.json
OIDCJWTRequiredClaim aud your-audience<Location /api>
AuthType openid-connect
Require valid-user
</Location>
さっそくhttpdを再起動してみたら……、立ち上がりません _('、3」∠)_
そもそも Syntax error なので、重症です。
色々と戦った後に得られたのがこちら ↓
OIDCOAuthVerifyJwksUri https://your-issuer.example.com/.well-known/jwks.json
OIDCOAuthRemoteUserClaim sub
OIDCOAuthVerifyClaims "iss=your-issuer" "aud=your-audience"
<Location /api>
AuthType oauth20
Require valid-user
</Location>
まだ、立ち上がりません _('、3」∠)_
OIDCOAuthVerifyClaimsがおかしいので、削除してみたところ、httpd が動き始めました。
(認可周りの調査は今後の課題)
ES256にチャレンジしてみる
結論から述べると、ES256で特に面倒が増えるという感じはなかったです。強いて言えば、情報が少ないぐらい。
さくっと鍵を作ってしまいます。
$ openssl ecparam -name prime256v1 -genkey -noout -out privkey.pem
$ openssl ec -in eckey-pair.pem -pubout -out pubkey.pem
JWTは、<ヘッダー>.<ペイロード>.<署名> というシンプルな構造をしているので、とりあえず簡単なヘッダーとペイロードを作って、BASE64URLエンコードして、ピリオドで連結しておきます。
$ echo -n "{\"alg\":\"ES256\", ...}" | basenc --base64url -w0 > tmp.txt
$ echo -n "." >> tmp.txt
$ echo -n "{ペイロード...}" | basenc --base64url -w0 >> tmp.txt
改行コードが入らないように細心の注意を払います。不安だったら hexdump -Cv で確認。
署名の罠
「あとは署名だから openssl dgst -sha256 -sign privkey.pem tmp.txt で簡単やろ」と思ったら、落とし穴がわんさか……。
opensslで作った署名をBASE64URLエンコードしてくっつけても、JWTデコーダー に突っ込んでみると Invalid Signature になってしまいます。
ES256 (ECDSA P-256)
The signature is a concatenation of two 32-byte numbers: R and S.
Many libraries and tools (like OpenSSL) output ECDSA signatures in ASN.1 DER format, but JWT expects the raw format:
Raw format: [R (32 bytes)] || [S (32 bytes)] (total 64 bytes)
Then base64url-encoded without padding.
ぁー、形式が違うんですね。
Copilotくんに言われるまま、
$ openssl asn1parse -in signature.der
Error: offset out of range
$
ありゃ?
-inform der が要ります。Copilotくんの嘘つき!(
いや、この辺の処理が面倒くさいので、perlでスクリプトを書いてしまった方が早いです。use Crypt::JWT qw(encode_jwt); して、秘密鍵を読み込ませて、以下の感じで行けます (なげやり)。keyを参照で渡さないといけないところに気付くまでさらにウン十分。
my $j = encode_jwt(
payload => $payload,
alg => 'ES256',
key => \$privkey,
extra_headers => {typ=>'JWT',kid=>'012389'},
);
print $j;
やっとこさ、JWTデコーダー で Signature Verified になりました。
ウェブサーバに公開鍵を仕込む
jwks.json というファイルに、こんな風に書きます。
{
"keys": [
{
"kty": "EC",
"kid": "012389",
"use": "sig",
"crv": "P-256",
"alg": "ES256",
"x": "...",
"y": "..."
}
]
}
はて、xとyは?
以下のようにすると、公開鍵の中身が表示されます。
$ openssl ec -in pubkey.pem -pubin -text -noout
pub: の項目で、最初の 04 を除くと、残りが32バイトのx座標値と、32バイトのy座標値です (P-256/ES256なので)。コロンを削除して、16進数表記をBASE64URLに変換して、パディングを削除して……って、やってられんわー・・・・・(ノ`Д´)ノ彡┻━┻
(自動化すべき)
[2025/7/31追記] 以下のperlスクリプトで x, y 座標値が表示できます。
#!/usr/bin/perl
use Crypt::PK::ECC;
use MIME::Base64;
use JSON::PP;# Read public key from PEM
my $pem = do { local $/; open my $fh, '<', 'pubkey.pem' or die $!; <$fh> };
my $pk = Crypt::PK::ECC->new(\$pem);my $pub = $pk->export_key_jwk('public');
結果
できました!ヽ(・∀・)ノ
こんな感じで動作確認できます ↓
$ curl -H GET 'https://api.example.com/api/index.txt' -H 'Content-Type:application/json;charset=utf-8' -H 'Authorization: Bearer <作成したトークン>'
念のため、JWTを送らなかったり、わざと別のJWTを送ってみたりして、アクセスが拒否されることを確認します (重要)。
ペイロードに exp を埋め込んでおくと、その時刻以降は"正しく"認証が通らなくなります (確認済み)。
おしまい