🪩

Next.jsのMiddlewareで認証が必要なCloud Runと通信できるようにする

2023/04/26に公開

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はオリジンサーバーで処理を行うため、バックエンドと通信をする際にはサービス間認証に必要な認証情報を付与してあげる必要があります。

https://cloud.google.com/run/docs/authenticating/service-to-service?hl=ja

注意点

ここで気をつけなければいけないのはNext.jsのサーバーランタイムはNode.js RuntimeEdge Runtimeの2種類あり、Middlewareはエッジでコードを実行できるように設計されているためデフォルトでEdge Runtimeを使用して実行される点です。
そのため使用できるAPIに制約があります。

https://nextjs.org/docs/api-reference/edge-runtime

Next.js 13ではページごとにランタイムを切り替えることが可能になりましたが、おそらくMiddlewareには適用できないと思います。(間違っていたらコメントいただけますと幸いです)

https://github.com/vercel/next.js/discussions/34179

実装

上記の注意点を踏まえて実装です。

前提

フロントエンド用のCloud Runを実行しているサービスアカウントにはバックエンド用のCloud Runのroles/run.invokerロールが付与されているものとします。
コンソールで行う場合は以下のドキュメントが参考になると思います。

https://cloud.google.com/run/docs/authenticating/service-to-service?hl=ja#set-up-sa

middleware.ts

process.env.BACKEND_CLOUD_RUN_URLにはCloud Runが自動発行するrun.appで終わるURLが入っています。(https://<サービス名>-<hash>.run.app

middleware.ts
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を指定します。

get-token.ts
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;
  }
};

https://twitter.com/ykzts/status/1651213847997616129?s=20

memory-cache.ts

取得したトークンをインメモリにキャッシュします。
取得の際に有効期限のチェックをします。

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