Architecture2026.08.10 · 10 min read

動いている実装を作り直すとき、仕様ではなく byte 一致を検証手段にする

NCP認証基盤・クラウドインフラ

本番で動いている OpenID Connect プロバイダを、Go から別の言語・別のランタイムへ丸ごと書き直した。このとき合格条件を「仕様を満たしていること」ではなく「旧実装のレスポンスと byte 単位で一致すること」に置いた。その理由と、実際に何が一致しなかったかを書く。

「仕様どおり」では等価性を示せない

書き直しの怖さは、機能が足りないことではない。すでに誰かが依存している細部が、静かに変わることにある。認証サーバーであれば、エラーコードの選び方、リダイレクトのステータスコード、トークンの文字列形式。どれも仕様書には「こうでなければならない」と書かれていない範囲があり、その範囲を旧実装がどう埋めていたかに、連携先のアプリケーションは知らないうちに依存している。

仕様を満たすことを合格条件にすると、この範囲が全部すり抜ける。仕様は満たしているが挙動は変わっている、という状態が作れてしまう。

合格条件を byte 一致に置く

そこで基準を変えた。同じリクエストを投げたとき、新実装と旧実装のレスポンスが byte 単位で一致すること。ステータスコード、ヘッダー、ボディ、そして末尾のバイトまで。

差分の取り方
# ステージング(新実装)と本番(旧実装)を同じリクエストで叩いて差分を取る
$ diff \
    <(curl -s https://auth-next.example.com/.well-known/jwks.json) \
    <(curl -s https://auth.example.com/.well-known/jwks.json)

# エラーパスも同様に。ここが一番ずれる
$ diff \
    <(curl -s -X POST https://auth-next.example.com/oauth2/token -d '') \
    <(curl -s -X POST https://auth.example.com/oauth2/token -d '')

この基準の良いところは、判定に解釈が入らないことだ。「たぶん互換だと思う」が存在しなくなり、差分が出たかどうかだけになる。

3つの情報源を使い分ける

期待値をどこから取るかで、精度がまったく変わる。最終的に次の3つを使い分けた。

01
run old implementation locally旧実装をローカルで起動し、正常系・異常系のレスポンスを網羅的に採取する。
02
read the library source挙動の一次ソースは依存ライブラリのソース。エラー文言や検証順序はここでしか確定しない。
03
pin as fixtures採取した応答をテストの期待値として固定する。実装ではなく観測結果が仕様になる。
04
deploy and diff against production新実装をステージングへ出し、本番との差分をエラーパス込みで取る。
等価性を確認するまでの流れ
  • 旧実装をローカルで起動して実際に叩く。正常系より異常系のほうが情報量が多い。エラーパスは本番に直接投げても副作用がないものが多く、そちらからも採取できる。
  • 依存ライブラリのソースを直接読む。エラー文言、検証の順序、シリアライズ方法の使い分けは、ドキュメントではなくソースが一次情報になる。バージョンを固定して読むのが前提。
  • 新実装をステージングへ出し、本番と差分を取る。ローカルで一致していても、ランタイムが変われば結果が変わる箇所がある。

実際に一致しなかった細部

エラーパスを中心に十数種類の応答を突き合わせた結果、一致しなかった箇所はどれも「読んでも分からない」類のものだった。

  • 同じ JSON でも、シリアライズの経路によって末尾の改行の有無が変わる。エラー応答には付かず、一部の成功応答には付く。ライブラリ内部でエンコーダの使い分けがあり、外からは推測できない。
  • Content-Type の書式が完全一致しない。charset の有無や区切りのスペースまで一致させないと byte 比較は通らない。
  • エラーの判定順序に意味がある。空ボディ → パラメータ欠落 → 未知の値 → クライアント認証 → 本処理、の順で検査され、途中で分岐が変わると返るエラーコードが変わる。
  • 資格情報が欠けている場合に返るのは invalid_client ではなく invalid_request だった。直感と逆で、仕様書を読んだだけでは絶対にこの順序にならない。
  • 認可レスポンスのリダイレクトは 302 ではなく 303 See Other。ここを間違えても大半のクライアントは動いてしまうため、テストで固定しないと気づけない。
末尾バイトの違い
// エラー応答と token 成功応答: 末尾に改行が付かない
{"error":"invalid_request","error_description":"..."}
// ↑ 最後のバイトは "}"

// introspect / userinfo の成功応答: 末尾に改行が付く
{"active":true,"scope":"openid","client_id":"..."}
// ↑ 最後のバイトは "\n"

末尾の改行1バイトなど、普通はどうでもいい。実際、これが原因で壊れるクライアントは稀だろう。それでも一致させたのは、「ここは許容差」という判断を1つ入れた瞬間に、基準が意見に戻ってしまうからだ。全部一致を条件にしておけば、差分が出たときに考える必要がない。

旧実装のデータを fixture にする

レスポンスだけでなく、永続化されたデータそのものも移行対象になる。ここでも同じ考え方を使い、旧実装が実際に書き込んだレコードを採取して、新実装のテストにシードした。

たとえばリフレッシュトークンの行をそのまま新しいデータベースへ流し込み、それを使ったトークン更新が成立するかをテストにする。これが通れば「移行済みユーザーがログインを継続できる」ことの証明になる。セッションデータについては、旧実装の JSON 表現をそのまま新実装でも採用した。表現を変えなければ、移行スクリプトは無変換コピーで済み、実データがそのまま互換性の証拠になる。

移行スクリプトを賢くするより、データ表現を変えないほうが安全なことが多い。変換が無ければ、変換のバグも無い。

この基準が炙り出したバグ

byte 一致を条件にした結果、通常のテストでは見つからなかったバグが出た。

  • 認可レスポンスのリダイレクトを 302 で返していた。旧実装は 303 を返す。ブラウザはどちらでも動くので、E2E で突き合わせるまで誰も気づかない。
  • 認可コードの消費時に、内部レコードを削除するキーを間違えていた。実際には1件も削除されておらず、処理としては黙って成功し続けていた。削除後の件数を検査する assert を足して確定した。

どちらも「動いているように見える」バグで、後者は放置すれば使用済みコードの残留につながる。基準を厳しくしたこと自体が検出器になった。

この方法が向かないところ

  • 旧実装のバグまで正確に再現してしまう。今回は「まず等価に作り、直すのはその後」と割り切ったが、明らかな不具合は移植しないという線引きは都度必要になる。
  • 採取できるのは観測できた応答だけ。網羅性はこちらで設計するしかなく、叩き忘れたパスは検証されない。
  • 旧実装を動かせることが前提になる。ソースもバイナリも失われている相手には使えない。

まとめ

書き直しの目的が「同じものを別の場所で動かす」ことなら、正しさの基準は仕様ではなく現行実装に置いたほうがいい。仕様は複数の実装を許すが、移行で求められているのはそのうちの1つとの一致だからだ。

最終的に、切り替え後も連携先のアプリケーションは設定変更なしで動き続け、発行済みのトークンも無効化されなかった。判断が正しかったことの証拠は、何も起きなかったことだった。

MigrationTestingOAuth 2.0Architecture