Cloudflare Pages で Next.js に Basic 認証をかける
結論
コマンド実行
第 1 に下記のコマンドを実行します。
npm install --save-dev @cloudflare/workers-types
mkdir functions
touch functions/tsconfig.json functions/_middleware.ts
コーディング
第 2 に下記のファイルに内容を入力します。
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"lib": ["esnext"],
"types": ["@cloudflare/workers-types"]
}
}
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 認証を設定することはできませんでした。
このスクラップについて
Next.js で静的エクスポートして Cloudflare Pages にデプロイした Web ページに Basic 認証をかけることになった。
「Cloudflare Pages Basic 認証」などで検索すると Cloudflare Workers を使う方法や Cloudflare Pages Function を使う方法の記事が見つかる。
Next.js にはサーバー側で実行されるランタイムとして Node.js か Edge のいずれかを選択できる。
Edge の方は Cloudflare Pages でも対応しているらしく、これを使うことで Basic 認証をかけられるのではないかと考えた次第。
このスクラップでは Next.js で Edge ランタイムを使って Basic 認証を行うミドルウェアを作成し、作成した Next.js アプリを Cloudflare で動かせるかどうかを検証したい。
検証の順番
- ローカルの Next.js で Edge ランタイムを使用する
- ローカルの Next.js で Basic 認証を行うミドルウェアを作成する
- Cloudflare で Edge ランタイムを使用する
- Cloudflare で Edge ランタイムを使用して Basic 認証を行う
ワークスペースの作成
npx create-next-app --typescript --eslint --src-dir --import-alias "@/*" --use-npm edge-basic-auth
cd edge-basic-auth
npm run dev
気になる料金体系
Cloudflare Pages で Edge ランタイムを使った場合の料金体系ってどうなるのかな?
Workers とか Function と同じなのだろうか。
余裕があったら調べたい。
Basic 認証をかける
Next.js のミドルウェア機能を使用する。
下記の記事がわかりやすい。
上記の記事で参照しているコードは下記の通り。
まずはこれを写経してみよう。
ファイル作成
touch src/middleware.ts src/pages/api/auth.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);
}
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.");
}
実行結果
ユーザー名とパスワードの入力部が表示されて正しい内容を入力するとページが表示された。
ミドルウェアでレスポンスを返せる?
Next.js v13.1.0 からはできそうな感じがするので実際にやってみよう。
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
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: ["/"],
};
ミドルウェアで Edge ランタイムを有効化する
API Route では下記のように Edge ランタイムを有効化する。
export const config = {
runtime: 'edge',
}
edge ではなく experimental-edge という記事もあるが現時点では edge で良いのかな?
ミドルウェアについては特に何も説明はされていないがデフォルトで Edge になっているのかな?
むしろ Node.js ランタイムを使えない?
確認するには eval() 関数など Edge で使えない関数を使えるかどうか試してみると良いかもしれない。
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 ランタイムになっている。
getServerSideProps() でも Edge ランタイムが使える
Edge API Routes とあるから API でしか使えないと思っていたら SSR でも使える。
ただし色々と制限はあるようだ。
環境変数の使用
touch .env.local
BASIC_AUTH_IS_ENABLED="1"
BASIC_AUTH_USERNAME="admin"
BASIC_AUTH_PASSWORD="password"
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"',
},
});
}
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
作成したリポジトリはこちらでございます。
念のためローカルでビルド
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 ..
再プッシュ
git add .
git commit -m "Remove apis"
git push origin main
Cloudflare Pages へのデプロイ
新しいプロジェクトを作成する。
Framework Preset では Next.js を選ぶ。
3 つの環境変数を設定するのを忘れない。
新しくプロジェクトを作成するときは .env の内容をコピペできないのかな?
Node.js のバージョン
環境変数に下記を追加する。
- NODE_VERSION = 16
毎回忘れてしまう。
デプロイはできたけど
真っ白いページが表示される。
ビルドコマンドを試してみる
Cloudflare Pages のドキュメントによると下記のコマンドが実行される。
npx @cloudflare/next-on-pages --experimental-minify
--experimental-minify
を外して実行してみる。
npx @cloudflare/next-on-pages
ビルド結果は .vercel/output/static ディレクトリに出力される。
_worker.js というファイルが含まれており、内部的には Cloudflare Workers が実行されていることが推測される。
auth.ts を復活させてみた
mkdir src/pages/api
touch src/pages/api/auth.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);
}
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 を使うようにした。
結果はダメだった
ただし /api/auth にアクセスすると Basic 認証ダイアログが表示されることからステータスコードやヘッダーの設定は行えているようだ。
ミドルウェアで色々と検証してみよう。
ミドルウェアの検証方法
Cloudflare はビルドが早いとはいえトライ&エラーを繰り返すのは遅いので別の方法を考える必要がある。
ローカルでビルドしたものを Wrangler を使ってアップした方が早いかもしれない。
Wrangler の使い方については下記に書いたスクラップが参考になりそう。
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 を書き換える → アップロードを繰り返して色々と試していこうと思う。
@cloudflare/next-on-pages
サポートされている機能については https://github.com/cloudflare/next-on-pages/blob/main/docs/supported.md に記載がある。
そもそも何もしないミドルウェアは動くのか
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
やはり白いページが表示される。
他の方も同じ現象に悩んでいる
わるあがき
index.tsx に getServerSideProps を追加したらいけるかなと思ったけどダメだった。
export const getServerSideProps: GetServerSideProps = async () => {
return {
props: {},
};
};
export const config = {
runtime: "experimental-edge",
};
方針を変える
Static エクスポートしたものに _middleware.js なり _worker.js なりを足す方向で検討してみようと思う。
再スタート
ミドルウェアや API Route や getServerSideProps を全て外した状態でビルドする。
npx @cloudflare/next-on-pages
そうすると .vercel/output/static に _worker.js が含まれなくなる。
Middleware
mkdir .vercel/output/static/functions/
touch .vercel/output/static/functions/_middleware.js
Functions が動かない
Direct Uploads では _worker.js にしか対応してないようだ。
前言撤回
ディレクトリを作成する所を間違えていた。
mkdir functions/
touch functions/_middleware.js
TypeScript を使いたい
npm install --save-dev @cloudflare/workers-types
touch functions/tsconfig.json
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"lib": ["esnext"],
"types": ["@cloudflare/workers-types"]
}
}
Wrangler でデプロイしてみる
npx wrangler pages publish .vercel/output/static
できた
結局 functions/_middleware.ts を追加すれば良かっただけなのか。
せっかくなので環境変数を使ってみる
.env ではなくて .dev.vars というファイルを作らなくては行けないのは初見だとわかるはずがない。
touch .dev.vars
BASIC_AUTH_IS_ENABLED="1"
BASIC_AUTH_USERNAME="admin"
BASIC_AUTH_PASSWORD="password"
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"',
},
});
};
なんかとっ散らかってしまった
後日改めてまとめよう。
Git デプロイも大丈夫だった
環境変数が BASIC_AUTH_IS_ENABLED='"1"'
みたいな状態になっていてできなかったけど余計なダブルクオートを除去したら問題なくできた。