1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ステージングの Basic 認証が、アプリ自身の内部呼び出しまで 401 で弾いていた(Next.js)

1
Posted at

結論

検索エンジンに拾われないよう、ステージング環境だけ Basic 認証をかけていました。ところが、アプリが自分自身の API を fetch してバックグラウンド処理を起動する作りだったため、その内部呼び出しまで Basic 認証で 401 になっていました。

結果、ステージングでは2か月以上、主要機能が1回も動いていませんでした。気づかなかった理由は、内部呼び出しの失敗が画面にもログにも出ない作りだったからです。

対策は次の3つです。

  1. Basic 認証の除外リストに、内部呼び出しのパスを入れる(そのパスは別の秘密で守る)
  2. 投げっぱなしの fetch でも、レスポンスのステータスを見て失敗を確定させる
  3. 認証の層を足すときは、「外から来るリクエスト」だけでなく「自分から自分へのリクエスト」も洗い出す

構成

題材は、Next.js(App Router)で作った、LLM で文書を生成する Web アプリです。生成に数十秒かかるため、非同期ジョブにしています。

ブラウザ ──POST /api/generate──▶ アプリ
                                  │ 1. 利用回数を予約
                                  │ 2. ジョブの行を作る
                                  │ 3. 自分の公開 URL の /api/generate/process を fetch(待たない)
                                  │ 4. 202 を返す
                                  ▼
                       POST https://<自分のドメイン>/api/generate/process
                                  │ LLM を呼び、結果を保存し、ジョブを completed にする
ブラウザ ──GET /api/generate/jobs/:id(1.5秒ごと)──▶ 状態を見る

/api/generate/process は、ブラウザから直接呼ばれては困るので、専用の秘密値をヘッダで確認しています。

// /api/generate の中(抜粋)
void fetch(`${baseUrl}/api/generate/process`, {
  method: 'POST',
  headers: {
    'content-type': 'application/json',
    'x-job-secret': secret, // /process 側で timingSafeEqual で照合する
  },
  body: JSON.stringify({ jobId, userId, input }),
});

ホスティングは、本番が Azure App Service、ステージングが Azure Container Apps です。


ステージングの Basic 認証

公開前の画面や、本番と同じ文言のページが検索エンジンに拾われないよう、ステージングだけ Basic 認証をかけていました。Next.js の middleware(Next.js 16 では proxy.ts)の最初で判定しています。

// proxy.ts(修正前・抜粋)
function shouldBypassBasicAuth(pathname: string): boolean {
  return (
    pathname === '/api/stripe/webhook' ||
    pathname.startsWith('/api/stripe/webhook/')
  );
}

function checkStagingBasicAuth(request: NextRequest): NextResponse | null {
  if (isProductionEnv()) return null;

  const expectedUser = process.env.STAGING_BASIC_AUTH_USER;
  const expectedPass = process.env.STAGING_BASIC_AUTH_PASSWORD;
  if (!expectedUser || !expectedPass) return null;

  if (shouldBypassBasicAuth(request.nextUrl.pathname)) return null;

  // Authorization ヘッダを確認し、一致しなければ 401
  // ...
  return new NextResponse('Authentication required', {
    status: 401,
    headers: { 'WWW-Authenticate': 'Basic realm="Staging", charset="UTF-8"' },
  });
}

除外リストには、外部(Stripe)から叩かれる Webhook だけを入れていました。「外から来るけど、人じゃないもの」は考えていたわけです。

抜けていたのは、自分から自分へのリクエストでした。


何が起きていたか

/api/generate が投げる内部の fetch には、Basic 認証の資格情報が付いていません。しかも宛先は自分の公開 URL なので、普通のリクエストとして middleware を通ります。

/api/generate ──fetch──▶ https://<staging>/api/generate/process
                              │
                              ▼ middleware(proxy.ts)
                         Basic 認証のヘッダが無い → 401
                              ✕ ルートまで届かない

つまり、生成ボタンを押すと、

  1. 利用回数が予約され、ジョブの行ができる
  2. 内部呼び出しが 401 で弾かれる
  3. ジョブは processing のまま残る

という状態になっていました。


なぜ2か月以上気づかなかったか

内部呼び出しのコードが、こうなっていたからです。

// 修正前
void fetch(runnerUrl, { method: 'POST', headers, body }).catch((error) => {
  console.error('[generate] failed to dispatch background job:', error);
});

fetch は、HTTP 401 が返ってきても reject しません。 reject するのはネットワークエラーのときだけです。なので 401 は .catch に入らず、ログにも出ませんでした。

画面のほうは、ジョブが processing のままなので「分析中」の表示が続きます。15分経つとタイムアウトとして失敗扱いになる仕組みはありましたが、その前に画面を閉じれば、ただ「遅い」で終わります。

さらに、本番には Basic 認証が無いので、本番では普通に動いていました。 ステージングで生成を試したのは Basic 認証を入れる前が最後で、その後は LP や記事ページの確認にしか使っていませんでした。


気づいたきっかけ

別件で、この内部呼び出しのまわりを直していました。投げっぱなしの fetch でもレスポンスを見て、2xx でなければジョブを失敗として確定し、予約した回数を返すようにしたのです。

// 修正後(抜粋)
void fetch(runnerUrl, { method: 'POST', headers, body })
  .then(async (res) => {
    // 受け付けられなかった(認証・入力・ジョブ不在)なら、処理は始まっていない
    if (!res.ok) {
      console.error(`[generate] background job rejected: HTTP ${res.status}`);
      await failJobAndRefund('分析を開始できませんでした。');
    }
  })
  .catch(async (error) => {
    console.error('[generate] failed to dispatch background job:', error);
    await failJobAndRefund('分析を開始できませんでした。');
  });

これをステージングに出して生成ボタンを押したら、すぐにエラーが表示されるようになりました。それまでは「分析中」がずっと続いていたので、ここで初めて「動いていない」ことがはっきりしました。

ログにはこう出ていました。

[generate] Received analysis request
[supabase] checkAndReserveQuota result: true
[generate] Dispatching background job to: https://<staging>/api/generate/process
[generate] background job rejected: HTTP 401

401 を返しているのは誰か

401 を返しうる場所は2つありました。

  • ルート側: 内部呼び出し用の秘密値が一致しない(/process が JSON で unauthorized_job_runner を返す)
  • middleware 側: Basic 認証の資格情報が無い

どちらかは、外から同じリクエストを送れば分かります。

curl -s -i -X POST https://<staging>/api/generate/process \
  -H "content-type: application/json" -d '{}'
HTTP/1.1 401 Unauthorized
www-authenticate: Basic realm="Staging", charset="UTF-8"

www-authenticate: Basic が返ってきたので、middleware の Basic 認証で止まっていると確定しました。ルートまで届いていれば、JSON のエラーが返るはずです。

401 の原因を切り分けるときは、レスポンスヘッダを見るのが早いです。Basic 認証なら WWW-Authenticate: Basic ... が付き、アプリのルートが返す 401 には付かない(付けていない)ことが多いので、どの層で止まったかが一目で分かります。


直し方

除外リストに入れ、純粋な関数にしてテストする

除外の判定を middleware から切り出し、next/server に依存しない関数にしました。こうすると、Node のテストランナーで直接テストできます。

// lib/staging-basic-auth.ts
// - Stripe webhook: 外部(Stripe)から叩かれる。署名で守られている
// - 生成ジョブの内部呼び出し: 自分の公開 URL へ fetch するため Basic 認証の資格情報を持たない。
//   ルート側で専用の秘密値を確認している
export function shouldBypassStagingBasicAuth(pathname: string): boolean {
  return (
    pathname === '/api/stripe/webhook' ||
    pathname.startsWith('/api/stripe/webhook/') ||
    pathname === '/api/generate/process'
  );
}
// tests/staging-basic-auth.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { shouldBypassStagingBasicAuth } from '../lib/staging-basic-auth.ts';

test('生成ジョブの内部呼び出しは Basic 認証を通さない', () => {
  assert.equal(shouldBypassStagingBasicAuth('/api/generate/process'), true);
});

test('似たパスまで素通しにしない', () => {
  for (const path of ['/', '/api/generate', '/api/generate/process/x', '/api/generate/processing']) {
    assert.equal(shouldBypassStagingBasicAuth(path), false, path);
  }
});

2つ目のテストは地味ですが大事です。startsWith('/api/generate/process') のように書いてしまうと、/api/generate/processing のような別のパスまで素通しになります。完全一致にしておき、テストで固定しました。

修正後に同じ curl を送ると、今度はルートまで届き、秘密値の確認で止まります。

HTTP/1.1 401 Unauthorized
{"error":"unauthorized_job_runner"}

これが期待どおりの形です。Basic 認証は外れたが、ルート自身の守りは効いていることが確認できます。

なぜ除外して大丈夫か

  • /api/generate/process は、ルートの中で専用の秘密値を timingSafeEqual で照合している。Basic 認証が無くても、秘密値を知らなければ何もできない
  • Basic 認証の目的は「検索エンジンに拾わせない」こと。POST 専用で、401 の JSON しか返さない API は、拾われても困らない
  • 本番には Basic 認証自体が無いので、本番の挙動は変わらない

ほかの直し方と、選ばなかった理由

方法 選ばなかった理由
内部の fetch に Basic 認証のヘッダを付ける ステージング専用の資格情報を、アプリの処理側でも扱うことになる。環境によって付けたり付けなかったりする分岐も増える
公開 URL ではなく http://127.0.0.1:<PORT> に投げる 宛先を変えても middleware は通る。 Basic 認証の判定はパスで行っているので、結局 401 になる。ホスト名で分岐させると、別の抜け道を作りかねない
ジョブを同じプロセスの中で起動する(fetch しない) 一番すっきりするが、処理の起動の仕組みそのものを変えることになる。今回は止血を優先した

2つ目は、最初に「ローカルに投げれば外を通らないから大丈夫では」と考えかけたところです。middleware はホスト名に関係なく、すべてのリクエストで動くので、宛先を変えても解決しません。


学んだこと

認証の層を足すときは「誰がこの URL を叩くか」を全部挙げる

Basic 認証を入れたとき、考えていたのは「人」と「外部サービス(Stripe)」でした。アプリ自身が叩く URL は、頭から抜けていました。

認証の層を足すときに洗い出すべきもの:

  • ブラウザ(人)
  • 外部サービスからの Webhook
  • 自分自身からの内部呼び出し(バックグラウンド処理の起動、ISR の再生成など)
  • 監視・ヘルスチェック
  • クローラー(止めたいもの)

投げっぱなしの fetch でも、ステータスは見る

fetch は 4xx・5xx では reject しません。void fetch(...).catch(...) だけだと、サーバーが拒否した場合は何も起きません。 投げっぱなしにするときほど、.then で res.ok を見て、失敗を確定させる処理を書いておくべきでした。

ステージングでも主要な操作を通す

ステージングを「見た目の確認場所」として使っていると、主要な操作が壊れていても気づけません。今回の件のあと、ステージングでは生成・停止・コピーまで実際に操作してから本番に出すようにしました。


まとめ

  • ステージングだけの Basic 認証が、アプリ自身の内部呼び出し(自分の公開 URL への fetch)まで 401 で弾いていた
  • fetch は 401 で reject しないので、投げっぱなしの .catch だけでは失敗が見えず、2か月以上気づかなかった
  • 401 の出どころは、レスポンスヘッダの WWW-Authenticate で切り分けられる
  • 内部呼び出しのパスを除外リストに入れ(完全一致・テストで固定)、そのパスはルート側の秘密値で守る
  • 宛先を 127.0.0.1 に変えても、middleware は通るので解決しない
  • 認証の層を足すときは、「自分から自分へのリクエスト」も必ず挙げる

(題材にしたのは、曖昧な開発依頼から見積前に聞くべきことを洗い出す個人開発の Web サービスです: https://reqlite.indexaxial.com/?utm_source=qiita&utm_campaign=qiita-04 )

1
1
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
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?