Next.jsのMiddlewareで認証が必要なCloud Runと通信できるようにする
Cloud Run でプロダクト開発する際にバックエンドの Cloud Run をロードバランサーからルーティングさせたくないことはよくあるかと思います。
その場合以下のような構成になるでしょうか。
フロントエンドからGraphQLを利用する場合には、Next.jsのMiddlewareを用いてバックエンド(GraphQL Server)との通信をプロキシさせます。
バックエンドはフロントエンドとは別のCloud Runで動作していますが、
Cloud Runのデプロイ時に --no-allow-unauthenticated
フラグを付与し、認証を必要にしているといった構成になります。
このフラグを付与することでバックエンド用のCloud Runに対するroles/run.invoker
ロールを付与したサービスアカウントで認証することでしかバックエンド用のCloud Runと通信できないようにすることが可能となり、セキュリティを向上させることができます。
今回Middlewareはオリジンサーバーで処理を行うため、バックエンドと通信をする際にはサービス間認証に必要な認証情報を付与してあげる必要があります。
注意点
ここで気をつけなければいけないのはNext.jsのサーバーランタイムはNode.js Runtime
とEdge Runtime
の2種類あり、Middlewareはエッジでコードを実行できるように設計されているためデフォルトでEdge Runtime
を使用して実行される点です。
そのため使用できるAPIに制約があります。
Next.js 13ではページごとにランタイムを切り替えることが可能になりましたが、おそらくMiddlewareには適用できないと思います。(間違っていたらコメントいただけますと幸いです)
実装
上記の注意点を踏まえて実装です。
前提
フロントエンド用のCloud Runを実行しているサービスアカウントにはバックエンド用のCloud Runのroles/run.invoker
ロールが付与されているものとします。
コンソールで行う場合は以下のドキュメントが参考になると思います。
middleware.ts
process.env.BACKEND_CLOUD_RUN_URL
にはCloud Runが自動発行するrun.app
で終わるURLが入っています。(https://<サービス名>-<hash>.run.app
)
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { getToken } from './get-token';
const backendURL =
process.env.NODE_ENV === 'production'
? process.env.BACKEND_CLOUD_RUN_URL // Cloud Runが自動発行するURL
: 'http://localhost:4000';
export async function middleware(request: NextRequest) {
const requestHeaders = new Headers(request.headers);
const token = await getToken();
if (token) {
// `Authorization` を使用している場合は `X-Serverless-Authorization` を使えます
requestHeaders.set('Authorization', `Bearer ${token}`);
}
return NextResponse.rewrite(`${backendURL}/graphql`, {
request: {
headers: requestHeaders,
},
});
}
export const config = {
matcher: '/graphql',
};
get-token.ts
トークンを取得する処理です。
認証ライブラリを使用できないのでメタデータサーバーから取得するようにします。
/graphql
がリクエストされる度にメタデータサーバーからトークンが取得されてしまうので
- キャッシュがあって、有効期限が切れていない場合はキャッシュされているトークンを返す
- キャッシュがなければメタデータサーバーから取得して返す(その際、キャッシュに保存する)
のような処理にしています。
audience
はCloud Runが自動発行するrun.app
で終わるURLを指定します。
import { MemoryCache } from './memory-cache';
export const memoryCache = new MemoryCache();
const decodeJwt = (token: string) => {
const [, payload] = token.split('.');
const decoded = JSON.parse(atob(payload));
return decoded;
};
export const getToken = async () => {
if (process.env.NODE_ENV !== 'production') return undefined;
const audience = process.env.BACKEND_CLOUD_RUN_URL;
if (!audience) return undefined;
try {
const cachedToken = memoryCache.get('token');
if (cachedToken) {
return cachedToken;
}
const res = await fetch(
`http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience=${audience}`,
{
headers: {
'Metadata-Flavor': 'Google',
},
},
);
if (!res.ok) return undefined;
const text = await res.text();
const decoded = decodeJwt(text);
const exp = decoded.exp;
memoryCache.add('token', text, exp);
return text;
} catch (error) {
console.error(error);
return undefined;
}
};
memory-cache.ts
取得したトークンをインメモリにキャッシュします。
取得の際に有効期限のチェックをします。
type CacheTarget = {
value: any;
expire: number | undefined;
};
export class MemoryCache {
memory = new Map<string, CacheTarget>();
add(key: string, value: any, expire?: number) {
this.memory.set(key, { value, expire });
}
get(key: string) {
const cache = this.memory.get(key);
if (!cache) return undefined;
if (cache.expire && this.isExpired(cache.expire)) {
this.memory.delete(key);
return undefined;
}
return cache.value;
}
isExpired(expire: number) {
return expire * 1000 <= Date.now();
}
}
おわりに
Next.jsのMiddlewareでCloud Runのサービス間認証を行う方法を紹介しました。
Cloud Runをお使いの場合はセキュリティを向上させるためにサービス間認証の導入を検討されてみてはいかがでしょうか?
Discussion