片方のHTTPSだけ通る謎:token交換は成功、/users/meはX509 code 20の正体
Rust + Docker(slim)でX OAuthを実装したら、
oauth2クレートのトークン交換は通るのに、自前のreqwestで/users/meを叩くとだけunable to get local issuer certificateで死んだ。3日溶かした原因は「TLSバックエンドが2種類混在」だった。
環境
- Rust nightly (
rustlang/rust:nightly-bookworm) / edition 2021 / axum -
oauth2 = "4.4"(内部で reqwest 0.11 を引き、その TLS は rustls + webpki-roots) -
reqwest = "0.12"(アプリ直依存。default = native-tls / OpenSSL) - runtime:
debian:bookworm-slim
症状は二段ロケット
- authorizeでXが「問題が発生しました/アプリにアクセスを許可できません」
- PKCE直したら今度はcallbackが500で
{"code":"SRV_001"}
ログを掘ると最後はこれ:
reqwest::Error: X509 code 20: unable to get local issuer certificate
同じXのAPI、同じコンテナ、同じタイミングなのに片方だけ落ちる。
原因1:XはPKCEが必須になっていた
最初はこれ。X OAuth 2.0は今、confidential clientでもPKCEしか受け付けない。OAuth 2.1の仕様でもPKCEを全クライアントに必須化していて、もともとモバイル用だったPKCEが今は全てのOAuthクライアントに適用されるようになっている。
僕のx_loginはcode_challengeを送っていなかった。そりゃXが拒否する。
修正は1行:
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
let auth_url = client
.authorize_url(|| CsrfToken::new_random())
.set_pkce_challenge(pkce_challenge)
.url();
// verifierはセッションに保存 (tower-sessions の insert は async)
session.insert("pkce_verifier", pkce_verifier).await?;
callback側で:
let token = client
.exchange_code(code)
.set_pkce_verifier(pkce_verifier)
.request_async(async_http_client)
.await?;
原因2:callbackがエラーを握りつぶしていた
PKCEを入れてもまだ500。理由はこっち:
#[derive(Deserialize)]
struct AuthCallbackQuery {
code: String, // ←必須にしてた
state: String,
}
Xが?error=access_denied&error_description=...を返すとserdeが「missing field code」で落ちて、真のエラーがログに出ない。自分で真因を隠してた。
全部Optionにして素直に受ける:
#[derive(Debug, Deserialize)]
struct AuthCallbackQuery {
code: Option<String>,
state: Option<String>,
error: Option<String>,
error_description: Option<String>,
}
if let Some(err) = q.error {
tracing::error!(error = %err, desc = ?q.error_description, "X OAuth error");
return Err(StatusCode::BAD_REQUEST);
}
原因3(本丸):CA証明書欠落とTLS非対称
ここが「片方だけ通る」の正体。
-
oauth2 = 4.4が内部で引く reqwest 0.11 は rustls + webpki-roots を使う。 webpki-roots は「Mozilla's root certificates for use with the webpki or rustls crates」、つまり「compiled-in copy of the root certificates trusted by Mozilla」で、ルート証明書がバイナリに焼き込まれていてOSのCAストアに依存しない。だから slim でも token 交換は通った。 -
一方アプリが直接使う reqwest 0.12 はデフォルトで native-tls、つまり OpenSSL。 OpenSSL はOSのトラストストア
/etc/ssl/certsを見る。 -
debian:bookworm-slimはca-certificatesが入っていない → OpenSSL のトラストストアが空 →/users/meだけ「unable to get local issuer certificate」。ca-certificatesで**ルート証明書(トラストアンカー)**を入れると解決する。Debian系でCAが無いとcurlでも同じになる典型例だ。
依存ツリーで裏が取れる。rustls 用の CA (webpki-roots) と native-tls をそれぞれ逆引きすると、別バージョンの reqwest にぶら下がっているのが見える:
$ cargo tree -i webpki-roots
webpki-roots v0.25.4
└── reqwest v0.11.27 # ← oauth2 が内部で引く古い方 (rustls)
└── oauth2 v4.4.2
└── axum_vue_api v0.1.0
$ cargo tree -i native-tls
native-tls v0.2.14
├── hyper-tls v0.6.0
│ └── reqwest v0.12.22 # ← アプリが直接使う方 (OpenSSL)
│ └── axum_vue_api v0.1.0
└── …(dev-dependencies は略)
つまり同一プロセス内に reqwest が2バージョン同居し、TLS実装も2つ(rustls / OpenSSL)、片方だけルート証明書を内蔵していた。これが「片方だけ通る」の正体。
修正は3つだけ
- DockerfileにCA追加
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
- 可能ならreqwestもrustlsに統一
# Cargo.toml
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
- 上のPKCEとcallback修正
これでtokenも/users/meも同じTLSスタックで通る。
再発防止チェックリスト
- slim/distrolessを使うなら
ca-certificatesは必ず入れる。入れないとビルドは通るのに実行時だけ死ぬ - 「Aは通る、Bは証明書エラー」を見たら
cargo tree | grep -E "rustls|native-tls"でバックエンド混在を疑う - OAuthのcallbackは成功形だけを必須にしない。
error/error_descriptionを必ず受ける - X APIはもうPKCE無しは通らない。confidentialでも
code_challenge必須
まとめ
症状が原因を隠す典型だった。ログのX509 code 20だけ見ると「Xが不安定」に見えるけど、実態は「rustlsは内蔵CAで動く、OpenSSLは空っぽ」。
片方のHTTPSだけ通る時は、証明書じゃなくTLSバックエンドの非対称を最初に疑おう。
同じ轍を踏んだ人、他にも「slimでハマった」ネタあれば教えてください。