🪩

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

2023/04/26に公開

弊社のとあるプロジェクトでは以下の図ような構成でプロダクトを開発しています。(とても簡略化しています)

アーキテクチャ

フロントエンドから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