Closed40

Cloudflare Pages で Next.js に Basic 認証をかける

ピン留めされたアイテム
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

結論

コマンド実行

第 1 に下記のコマンドを実行します。

コマンド
npm install --save-dev @cloudflare/workers-types
mkdir functions
touch functions/tsconfig.json functions/_middleware.ts

コーディング

第 2 に下記のファイルに内容を入力します。

functions/tsconfig.json
{
  "compilerOptions": {
    "target": "esnext",
    "module": "esnext",
    "lib": ["esnext"],
    "types": ["@cloudflare/workers-types"]
  }
}
functions/_middleware.ts
interface ENV {
  BASIC_AUTH_IS_ENABLED: string;
  BASIC_AUTH_USERNAME: string;
  BASIC_AUTH_PASSWORD: string;
}

export const onRequest: PagesFunction<ENV> = async (context) => {
  if (context.env.BASIC_AUTH_IS_ENABLED !== "1") {
    return await context.next();
  }

  const authorizationHeader = context.request.headers.get("Authorization");

  if (authorizationHeader) {
    const authValue = authorizationHeader.split(" ")[1];
    const [user, password] = atob(authValue).split(":");

    if (
      user === context.env.BASIC_AUTH_USERNAME &&
      password === context.env.BASIC_AUTH_PASSWORD
    ) {
      return await context.next();
    }
  }

  return new Response("Auth Required.", {
    status: 401,
    headers: {
      "WWW-authenticate": 'Basic realm="Secure Area"',
    },
  });
};

環境変数

第 3 に下記の環境変数を Cloudflare Pages の Web コンソールで設定します。

  • BASIC_AUTH_IS_ENABLED
  • BASIC_AUTH_USERNAME
  • BASIC_AUTH_PASSWORD

環境変数を設定したら再ビルドすると反映されます。

Framework preset

Framework preset は Next.js でも Next.js (Static HTML Export) でもどちらでも大丈夫です。

Create a project

デプロイには Connect to Git を使って Cloudflare にビルドしてもらっても、ローカルでビルドしてから Wrangler CLI を使ってパブリッシュしてもどちらでも大丈夫です。

Next.js の Middleware を使う方法

2023 年 3 月 31 日時点では Cloudflare Pages で Next.js の Middleware を使用すると真っ白なページが表示される現象を確認しており Basic 認証を設定することはできませんでした。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

このスクラップについて

Next.js で静的エクスポートして Cloudflare Pages にデプロイした Web ページに Basic 認証をかけることになった。

「Cloudflare Pages Basic 認証」などで検索すると Cloudflare Workers を使う方法や Cloudflare Pages Function を使う方法の記事が見つかる。

https://www.to-r.net/media/cloudflare-pages-basic/

Next.js にはサーバー側で実行されるランタイムとして Node.js か Edge のいずれかを選択できる。

Edge の方は Cloudflare Pages でも対応しているらしく、これを使うことで Basic 認証をかけられるのではないかと考えた次第。

https://nextjs.org/docs/advanced-features/react-18/switchable-runtime

https://developers.cloudflare.com/pages/framework-guides/deploy-a-nextjs-site/

このスクラップでは Next.js で Edge ランタイムを使って Basic 認証を行うミドルウェアを作成し、作成した Next.js アプリを Cloudflare で動かせるかどうかを検証したい。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

検証の順番

  1. ローカルの Next.js で Edge ランタイムを使用する
  2. ローカルの Next.js で Basic 認証を行うミドルウェアを作成する
  3. Cloudflare で Edge ランタイムを使用する
  4. Cloudflare で Edge ランタイムを使用して Basic 認証を行う
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

気になる料金体系

Cloudflare Pages で Edge ランタイムを使った場合の料金体系ってどうなるのかな?

Workers とか Function と同じなのだろうか。

余裕があったら調べたい。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Basic 認証をかける

Next.js のミドルウェア機能を使用する。

https://nextjs.org/docs/advanced-features/middleware

下記の記事がわかりやすい。

https://zenn.dev/canesro/articles/41ca9c6c49b9fb

上記の記事で参照しているコードは下記の通り。

https://github.com/vercel/examples/blob/main/edge-middleware/basic-auth-password/middleware.ts

https://github.com/vercel/examples/blob/main/edge-middleware/basic-auth-password/pages/api/auth.ts

まずはこれを写経してみよう。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

コーディング

src/middleware.ts
import { NextRequest, NextResponse } from "next/server";

export const config = {
  matchers: ["/", "/index"],
};

export function middleware(req: NextRequest) {
  const basicAuth = req.headers.get("authorization");
  const url = req.nextUrl;

  if (basicAuth) {
    const authValue = basicAuth.split(" ")[1];
    const [user, pwd] = atob(authValue).split(":");

    if (user === "4dmin" && pwd === "testpwd123") {
      return NextResponse.next();
    }
  }

  url.pathname = "/api/auth";
  return NextResponse.rewrite(url);
}
src/pages/api/auth.ts
import { NextApiRequest, NextApiResponse } from "next";

export default function handler(_: NextApiRequest, res: NextApiResponse) {
  res.setHeader("WWW-authenticate", 'Basic realm="Secure Area"');
  res.statusCode = 401;
  res.end("Auth Required.");
}


実行結果

ユーザー名とパスワードの入力部が表示されて正しい内容を入力するとページが表示された。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

ミドルウェアでレスポンスを返せる?

Next.js v13.1.0 からはできそうな感じがするので実際にやってみよう。

https://nextjs.org/docs/advanced-features/middleware#producing-a-response

src/middleware.ts
import { NextRequest, NextResponse } from "next/server";

export const config = {
  matchers: ["/", "/index"],
};

export function middleware(req: NextRequest) {
  const basicAuth = req.headers.get("authorization");
  const url = req.nextUrl;

  if (basicAuth) {
    const authValue = basicAuth.split(" ")[1];
    const [user, pwd] = atob(authValue).split(":");

    if (user === "4dmin" && pwd === "testpwd123") {
      return NextResponse.next();
    }
  }

  return new NextResponse("Auth Required.", {
    status: 401,
    headers: {
      "WWW-authenticate": 'Basic realm="Secure Area"',
    },
  });
}

普通にできたので auth.ts は削除しても良さそう。

コマンド
rm -f src/pages/api/auth.ts
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Matcher について調べる

https://nextjs.org/docs/advanced-features/middleware#matcher

現状だと特に何も設定していないのに http://localhost:3000/vercel.svg などにも Basic 認証がかかってしまう。

と思ったら Matcher の設定を間違えているだけだった。

誤り
export const config = {
  matchers: ["/", "/index"],
};
正しい
export const config = {
  matcher: ["/", "/index"],
};

Matcher を指定しない場合(または今回のように指定が間違っていた場合)すべてのパスに対してミドルウェアが実行されるのかな?

でもそうすると先ほど /api/auth が実行されたことが説明できないのでもやもやする。

あと Matcher で "/index" を指定する必要は特に無さそうなので外しておく。

正しい
export const config = {
  matcher: ["/"],
};
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

ミドルウェアで Edge ランタイムを有効化する

API Route では下記のように Edge ランタイムを有効化する。

export const config = {
  runtime: 'edge',
}

edge ではなく experimental-edge という記事もあるが現時点では edge で良いのかな?

ミドルウェアについては特に何も説明はされていないがデフォルトで Edge になっているのかな?

むしろ Node.js ランタイムを使えない?

確認するには eval() 関数など Edge で使えない関数を使えるかどうか試してみると良いかもしれない。

src/middleware.ts
import { NextRequest, NextResponse } from "next/server";

export const config = {
  matcher: ["/"],
};

export function middleware(req: NextRequest) {
  eval("");

  const basicAuth = req.headers.get("authorization");
  const url = req.nextUrl;

  if (basicAuth) {
    const authValue = basicAuth.split(" ")[1];
    const [user, pwd] = atob(authValue).split(":");

    if (user === "4dmin" && pwd === "testpwd123") {
      return NextResponse.next();
    }
  }

  return new NextResponse("Auth Required.", {
    status: 401,
    headers: {
      "WWW-authenticate": 'Basic realm="Secure Area"',
    },
  });
}

コンソールの方に警告メッセージが表示されたので Edge になっているようだ。

警告メッセージ
warn  - src/middleware.ts (8:2) @ eval
warn  - Dynamic Code Evaluation (e. g. 'eval', 'new Function') not allowed in Edge Runtime
Learn More: https://nextjs.org/docs/messages/edge-dynamic-code-evaluation
   6 | 
   7 | export function middleware(req: NextRequest) {
>  8 |   eval("");
     |  ^
   9 | 
  10 |   const basicAuth = req.headers.get("authorization");
  11 |   const url = req.nextUrl;

結論としてはミドルウェアは特に設定しなくてもデフォルトで Edge ランタイムになっている。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

環境変数の使用

コマンド
touch .env.local
.env.local
BASIC_AUTH_IS_ENABLED="1"
BASIC_AUTH_USERNAME="admin"
BASIC_AUTH_PASSWORD="password"
src/middleware.ts
import { NextRequest, NextResponse } from "next/server";

export const config = {
  matcher: ["/"],
};

export function middleware(req: NextRequest) {
  if (process.env.BASIC_AUTH_IS_ENABLED !== "1") {
    return NextResponse.next();
  }

  const authorizationHeader = req.headers.get("authorization");
  const url = req.nextUrl;

  if (authorizationHeader) {
    const authValue = authorizationHeader.split(" ")[1];
    const [user, password] = atob(authValue).split(":");

    if (
      user === process.env.BASIC_AUTH_USERNAME &&
      password === process.env.BASIC_AUTH_PASSWORD
    ) {
      return NextResponse.next();
    }
  }

  return new NextResponse("Auth Required.", {
    status: 401,
    headers: {
      "WWW-authenticate": 'Basic realm="Secure Area"',
    },
  });
}
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Cloudflare Pages へのデプロイ準備

まずは GitHub リポジトリを作成する。

コマンド
## 変更内容のコミット
git add .
git commit -m "Actual initial commit"

## リポジトリの作成
gh repo create --public cloudflare-nextjs-basic-auth

## リポジトリへのプッシュ
git remote add origin git@github.com:tatsuyasusukida/cloudflare-nextjs-basic-auth
git push --set-upstream origin main

作成したリポジトリはこちらでございます。

https://github.com/tatsuyasusukida/cloudflare-nextjs-basic-auth

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

念のためローカルでビルド

コマンド
npm run build
出力
> edge-basic-auth@0.1.0 build
> next build

info  - Loaded env from /Users/susukida/workspace/js/edge-basic-auth/.env.local
info  - Linting and checking validity of types  
info  - Compiled successfully
info  - Collecting page data  
info  - Generating static pages (3/3)
info  - Creating an optimized production build .info  - Finalizing page optimizainfo  - Finalizing page optimization  

Route (pages)                              Size     First Load JS
┌ ○ /                                      4.6 kB           78 kB
├   └ css/299b4710ca2a9192.css             1.87 kB
├   /_app                                  0 B            73.4 kB
├ ○ /404                                   182 B          73.5 kB
└ λ /api/hello                             0 B            73.4 kB
+ First Load JS shared by all              74.1 kB
  ├ chunks/framework-2c79e2a64abdb08b.js   45.2 kB
  ├ chunks/main-7b9ea2e3ff9fc42d.js        27.1 kB
  ├ chunks/pages/_app-5fbdfbcdfb555d2f.js  296 B
  ├ chunks/webpack-8fa1640cc84ba8fe.js     750 B
  └ css/876d048b5dab7c28.css               706 B

ƒ Middleware                               28.9 kB

λ  (Server)  server-side renders at runtime (uses getInitialProps or getServerSideProps)
○  (Static)  automatically rendered as static HTML (uses no initial props)

info  - Creating an optimized production build ..

/api/hello が残っていたので削除してから再ビルドする。

コマンド
rm -rf src/pages/api
npm run build
出力
> edge-basic-auth@0.1.0 build
> next build

info  - Loaded env from /Users/susukida/workspace/js/edge-basic-auth/.env.local
info  - Linting and checking validity of types  
info  - Compiled successfully
info  - Collecting page data  
info  - Generating static pages (3/3)
info  - Creating an optimized production build .info  - Finalizing page optimizainfo  - Finalizing page optimization  

Route (pages)                              Size     First Load JS
┌ ○ /                                      4.6 kB           78 kB
├   └ css/299b4710ca2a9192.css             1.87 kB
├   /_app                                  0 B            73.4 kB
└ ○ /404                                   182 B          73.5 kB
+ First Load JS shared by all              74.1 kB
  ├ chunks/framework-2c79e2a64abdb08b.js   45.2 kB
  ├ chunks/main-7b9ea2e3ff9fc42d.js        27.1 kB
  ├ chunks/pages/_app-5fbdfbcdfb555d2f.js  296 B
  ├ chunks/webpack-8fa1640cc84ba8fe.js     750 B
  └ css/876d048b5dab7c28.css               706 B

ƒ Middleware                               28.9 kB

○  (Static)  automatically rendered as static HTML (uses no initial props)

info  - Creating an optimized production build ..
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Cloudflare Pages へのデプロイ

新しいプロジェクトを作成する。

Framework Preset では Next.js を選ぶ。

3 つの環境変数を設定するのを忘れない。

新しくプロジェクトを作成するときは .env の内容をコピペできないのかな?

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

ビルドコマンドを試してみる

Cloudflare Pages のドキュメントによると下記のコマンドが実行される。

コマンド
npx @cloudflare/next-on-pages --experimental-minify

https://developers.cloudflare.com/pages/framework-guides/deploy-a-nextjs-site/#deploy-with-cloudflare-pages

--experimental-minify を外して実行してみる。

コマンド
npx @cloudflare/next-on-pages

ビルド結果は .vercel/output/static ディレクトリに出力される。

_worker.js というファイルが含まれており、内部的には Cloudflare Workers が実行されていることが推測される。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

auth.ts を復活させてみた

コマンド
mkdir src/pages/api
touch src/pages/api/auth.ts
src/middleware.ts
import { NextRequest, NextResponse } from "next/server";

export const config = {
  matcher: ["/"],
};

export function middleware(req: NextRequest) {
  if (process.env.BASIC_AUTH_IS_ENABLED !== "1") {
    return NextResponse.next();
  }

  const authorizationHeader = req.headers.get("authorization");
  const url = req.nextUrl;

  if (authorizationHeader) {
    const authValue = authorizationHeader.split(" ")[1];
    const [user, password] = atob(authValue).split(":");

    if (
      user === process.env.BASIC_AUTH_USERNAME &&
      password === process.env.BASIC_AUTH_PASSWORD
    ) {
      return NextResponse.next();
    }
  }

  url.pathname = "/api/auth";
  return NextResponse.rewrite(url);
}
src/pages/api/auth.ts
import { NextApiRequest, NextApiResponse } from "next";
import { NextResponse } from "next/server";

export const config = {
  runtime: "edge",
};

export default function handler(_: NextApiRequest) {
  return new Response("Auth Required.", {
    status: 401,
    headers: {
      "WWW-authenticate": 'Basic realm="Secure Area"',
    },
  });
}

ついでに NextResponse ではなく Response を使うようにした。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

結果はダメだった

ただし /api/auth にアクセスすると Basic 認証ダイアログが表示されることからステータスコードやヘッダーの設定は行えているようだ。

ミドルウェアで色々と検証してみよう。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

ミドルウェアの検証方法

Cloudflare はビルドが早いとはいえトライ&エラーを繰り返すのは遅いので別の方法を考える必要がある。

ローカルでビルドしたものを Wrangler を使ってアップした方が早いかもしれない。

Wrangler の使い方については下記に書いたスクラップが参考になりそう。

https://zenn.dev/tatsuyasusukida/scraps/69115b132c47c6

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

CLI からアップロード

コマンド
npx wrangler pages publish .vercel/output/static

認可ページが表示されるので認可する。

新しくプロジェクトを作るか既存のプロジェクトを選ぶ。

今回は新しく作成しようと思う。

名前は cloudflare-middleware とした。

アップロードはすぐ終わった。

コンソール出力も下記のように短かった。

コンソール出力
No project selected. Would you like to create one or use an existing project?
❯ Create a new project
  Use an existing project
Enter the name of your new project: cloudflare-middleware 
Enter the production branch name: main 
✨ Successfully created the 'cloudflare-middleware' project.
🌍  Uploading... (27/27)

✨ Success! Uploaded 27 files (2.25 sec)

✨ Uploading _headers
✨ Uploading _worker.js
✨ Uploading _routes.json
▲ [WARNING] 🚨 _routes.json is an experimental feature and is subject to change. Don't use unless you really must!


Retrieving cached values for account_id and project_name from node_modules/.cache/wrangler
✨ Deployment complete! Take a peek over at https://04a51273.cloudflare-middleware.pages.dev

これで _worker.js を書き換える → アップロードを繰り返して色々と試していこうと思う。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

そもそも何もしないミドルウェアは動くのか

src/middleware.ts
import { NextRequest, NextResponse } from "next/server";

export const config = {
  matcher: ["/"],
};

export function middleware(req: NextRequest) {
  return NextResponse.next();

  // if (process.env.BASIC_AUTH_IS_ENABLED !== "1") {
  //   return NextResponse.next();
  // }

  // const authorizationHeader = req.headers.get("authorization");
  // const url = req.nextUrl;

  // if (authorizationHeader) {
  //   const authValue = authorizationHeader.split(" ")[1];
  //   const [user, password] = atob(authValue).split(":");

  //   if (
  //     user === process.env.BASIC_AUTH_USERNAME &&
  //     password === process.env.BASIC_AUTH_PASSWORD
  //   ) {
  //     return NextResponse.next();
  //   }
  // }

  // url.pathname = "/api/auth";
  // return NextResponse.rewrite(url);
}
コマンド
npx @cloudflare/next-on-pages
npx wrangler pages publish .vercel/output/static

やはり白いページが表示される。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

わるあがき

index.tsx に getServerSideProps を追加したらいけるかなと思ったけどダメだった。

src/pages/index.tsx(末尾に追加)
export const getServerSideProps: GetServerSideProps = async () => {
  return {
    props: {},
  };
};

export const config = {
  runtime: "experimental-edge",
};
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

再スタート

ミドルウェアや API Route や getServerSideProps を全て外した状態でビルドする。

コマンド
npx @cloudflare/next-on-pages

そうすると .vercel/output/static に _worker.js が含まれなくなる。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

せっかくなので環境変数を使ってみる

https://developers.cloudflare.com/workers/platform/environment-variables/

.env ではなくて .dev.vars というファイルを作らなくては行けないのは初見だとわかるはずがない。

コマンド
touch .dev.vars
.dev.vars
BASIC_AUTH_IS_ENABLED="1"
BASIC_AUTH_USERNAME="admin"
BASIC_AUTH_PASSWORD="password"
functions/_middleware.ts
interface ENV {
  BASIC_AUTH_IS_ENABLED: string;
  BASIC_AUTH_USERNAME: string;
  BASIC_AUTH_PASSWORD: string;
}

export const onRequest: PagesFunction<ENV> = async (context) => {
  if (context.env.BASIC_AUTH_IS_ENABLED !== "1") {
    return await context.next();
  }

  const authorizationHeader = context.request.headers.get("Authorization");

  if (authorizationHeader) {
    const authValue = authorizationHeader.split(" ")[1];
    const [user, password] = atob(authValue).split(":");

    if (
      user === context.env.BASIC_AUTH_USERNAME &&
      password === context.env.BASIC_AUTH_PASSWORD
    ) {
      return await context.next();
    }
  }

  return new Response("Auth Required.", {
    status: 401,
    headers: {
      "WWW-authenticate": 'Basic realm="Secure Area"',
    },
  });
};
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Git デプロイも大丈夫だった

環境変数が BASIC_AUTH_IS_ENABLED='"1"' みたいな状態になっていてできなかったけど余計なダブルクオートを除去したら問題なくできた。

このスクラップは2023/03/31にクローズされました