🐱

AWS環境でのBFFパターンによるセキュアな認証認可の実装例

2024/12/10に公開

導入

背景・目的

  • シングルページアプリケーション(SPA)に認証・認可を実装する際、OIDC・OAuth準拠のパブリッククライアントを採用することも一般的です。
  • しかし、パブリッククライアントには、クライアント認証とトークン保管に関する課題があります。
  • この課題に対し、OAuth 2.0 for Browser-Based Applicationsに記載されているBFFパターンを採用することで、コンフィデンシャルクライアントがOIDC・OAuth系のエンドポイントと直接通信し、ブラウザでのトークン管理を不要にできます。
  • 本ブログでは、パブリッククライアントの課題とBFFパターンの基本概念を説明した上で、AWS環境での具体的な実装例をサンプルコードとともに解説します。

対象読者

  • AWS Certified Solutions Architect - Professionalレベルの知識を想定し、細かなAWSサービスに対する解説は割愛します。

環境構成

  • 可能な限りAWSサービスを活用して、BFFパターンを構成しています。

パブリッククライアントの課題

パブリッククライアントとは、OIDC・OAuthにおいて、クライアントシークレットを安全に保管できない環境で動作するアプリケーションです。代表的な例としてSPAやネイティブアプリが挙げられます。

このパブリッククライアントには、主にクライアント認証やトークン保管の2つの観点で課題があります。[1]

  • クライアント認証: クライアントシークレットを安全に保持することができないため、不正なクライアントによる成りすましリスク
  • トークン保管: アクセストークンやリフレッシュトークンがブラウザやデバイス上で盗まれ、不正アクセスにつながるリスク

BFFパターンの概要

BFFパターンはOAuth 2.0 for Browser-Based Applicationsに記載されている方式であり、バックエンドアプリにアクセスするSPAのセキュリティを強化できます。

  • BFFがコンフィデンシャルクライアントとして、ブラウザのJavaScriptアプリに代わってOIDC・OAuth系のエンドポイントと通信を行います。
  • BFFはブラウザからのリクエストをリソースサーバへプロキシします。

This section describes the architecture of a JavaScript application that relies on a backend component to handle all OAuth responsibilities and API interactions. The BFF has three core responsibilities:

The BFF interacts with the authorization server as a confidential OAuth client

The BFF manages OAuth access and refresh tokens in the context of a cookie-based session, avoiding the direct exposure of any tokens to the JavaScript application

The BFF proxies all requests to a resource server, augmenting them with the correct access token before forwarding them to the resource server

本ブログの環境構成に合わせると、以下イメージとなります。


すべてのSPAでBFFパターンを採用する必要はないと思いつつ、高機密性が求められるアプリケーションでは採用選択肢に入るでしょう。

AWS上でのBFFパターン実装例

AWS上でのBFFパターン実装例について、Apache設定ファイル・CDKサンプルコードとともに解説していきます。

[再掲]環境構成

  • ECS上のApacheがBFFとして振る舞い、Cognitoとの対話やAPI Gatewayへのプロキシを担います。
    • Apache向け認証認可モジュールであるmod_auth_openidcを導入することで、コンフィデンシャルクライアントとしての振る舞いを可能とします。
    • VPCエンドポイント経由でPrivate API Gatewayへリクエストをプロキシします。
  • リソースポリシーでアクセス制御されたPrivate API Gatewayへのリクエストに対して、Lambdaオーソライザーによる認可を実施します。
  • SPA資源はCloudFront及びS3を用いて配信します。

Apache設定

まずはApacheの設定ファイルを作成します。

  • OIDC系の設定に加えて、CORSやプロキシ設定を定義しています。[2]
  • 環境依存値はコンテナイメージ外部から環境変数として受け渡しています。
  • Location/privateを設定することで、認証済みユーザが/private にアクセスした場合、API Gatewayへプロキシします。
  • Location/private/loginを設定することで、未認証ユーザが認可コードフローを開始できるようにしています。後ほど登場するSPAでは、ログインボタンの遷移先として/private/loginを指定しています。[3]
    • 未認証ユーザが/private/loginにアクセスした場合、Cognito認証画面にリダイレクトされて認可コードフローが開始します。
    • Cognitoでの認証完了後、SPAへリダイレクトされます。
    • リダイレクト後のSPAから/privateを呼び出すことで、認証済みユーザとしてAPI Gateway背後の資源にアクセスできます。
LoadModule auth_openidc_module /usr/lib/apache2/modules/mod_auth_openidc.so
LoadModule proxy_module modules/mod_proxy.so
LoadModule proxy_http_module modules/mod_proxy_http.so
LoadModule ssl_module modules/mod_ssl.so
LoadModule headers_module modules/mod_headers.so
<VirtualHost *:80>
    OIDCResponseType code
    OIDCCryptoPassphrase hogehoge
    OIDCProviderMetadataURL ${OIDC_PROVIDER_METADATA_URL}
    OIDCClientID ${OIDC_CLIENT_ID}
    OIDCClientSecret ${OIDC_CLIENT_SECRET}
    OIDCRedirectURI ${OIDC_REDIRECT_URI}
    OIDCPKCEMethod S256
    OIDCXForwardedHeaders X-Forwarded-Host X-Forwarded-Proto X-Forwarded-Port

    # CORS Settings
    Header always set Access-Control-Allow-Origin ${ACCESS_CONTROL_ALLOW_ORIGIN}
    Header always set Access-Control-Allow-Methods "GET, POST, OPTIONS"
    Header always set Access-Control-Allow-Headers "Authorization, Content-Type"
    Header always set Access-Control-Allow-Credentials "true"

    SSLProxyEngine on
    <Location /private>
        AuthType openid-connect
        Require valid-user
        ProxyPass ${PROXY_PASS}
        ProxyPassReverse ${PROXY_PASS_REVERSE}
        LogLevel debug
    </Location>
    <Location /private/login>
        AuthType openid-connect
        Require valid-user
        Redirect ${FRONT_URL}
        LogLevel debug
    </Location>
</VirtualHost>

BFF用Dockerfileを作成します。Dockerイメージをビルド・ECRへプッシュします。

FROM httpd:2.4.62

ENV TZ=Asia/Tokyo
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone

RUN apt update && apt upgrade -y
RUN apt-get install -y libapache2-mod-auth-openidc

RUN mkdir /usr/local/apache2/conf/include/ &&  echo 'Include conf/include/rp-reverse-proxy.conf ' >> /usr/local/apache2/conf/httpd.conf
COPY ./rp-reverse-proxy.conf /usr/local/apache2/conf/include/

Cognito設定

Cognitoを設定していきます。

  • コンフィデンシャルクライアント向けシークレットを生成します。
  • 今回は検証用のため細かな設定はしていませんが、システム要件を踏まえて適宜設定追加を検討ください。
    // ------------ OIDC IdP ---------------
    // ---- Cognito
    // Create User Pool
    const userPool = new cognito.UserPool(this, "UserPool", {
      selfSignUpEnabled: false,
    })
    const domain = userPool.addDomain("Domain", {
      cognitoDomain: {
        domainPrefix: props.domainPrefix
      }
    })
    const client = userPool.addClient("Client", {
      oAuth: {
        flows: {
          authorizationCodeGrant: true,
        },
        scopes: [cognito.OAuthScope.OPENID],
        callbackUrls: props.callbackUrls,
      },
      generateSecret: true,
    })

DNS・ネットワーク設定

Route53やVPC、VPCエンドポイント資源を設定していきます。

  • VPC関連資源構築には、AWS Japanのソリューションアーキテクトさんが公開しているBleaのコンストラクトを利用します。

  • プライベートAPI Gatewayにアクセスするための、VPCエンドポイントを作成します。

    // ------------ DNS ---------------
    // ---- Route53
    // Define Hosted Zone
    const myHostedZone = route53.HostedZone.fromHostedZoneAttributes(this, "HostedZone", {
      hostedZoneId: props.hostedZoneId,
      zoneName: props.zoneName
    })

    // ------------ Network ---------------
    // Create VPC Resources by using Blea
    // https://github.com/aws-samples/baseline-environment-on-aws/blob/main/usecases/blea-guest-ecs-app-sample/lib/construct/networking.ts
    const networking = new Networking(this, 'Networking', {
      vpcCidr: props.vpcCidr,
    });

    // Create VPC Endpoint
    const apiGwVpcEndpoint = new ec2.InterfaceVpcEndpoint(this, "ApiGwVpcEndpoint", {
      vpc: networking.vpc,
      service: ec2.InterfaceVpcEndpointAwsService.APIGATEWAY,
      subnets: { subnets: networking.vpc.privateSubnets },
    });

バックエンドAPI設定

バックエンドAPI資源であるAPI GatewayやLambdaを設定していきます。

  • エンドポイントタイプにはPrivateを選択します。
  • 上記で作成したVPCエンドポイント経由しないアクセスをDenyするよう、リソースポリシーを設定します。
  • APIリクエストに対する認可コンポーネントとして、Lambdaオーソライザーを設定します。
  • mod_auth_openidcからoidc_access_tokenヘッダでアクセストークンがバックエンドへ渡されることを踏まえて、identitySourceを設定します。
    // ------------ Backend API ---------------
    // ---- API Gateway
    // Create LogGroup
    const apiGatewayLogGroup = new logs.LogGroup(this, 'ApiGatewayLogGroup', {
      retention: logs.RetentionDays.ONE_MONTH,
    })

    // Create REST API
    const restApi = new apigateway.RestApi(this, "RestApi", {
      description: "Backend REST API",
      endpointTypes: [apigateway.EndpointType.PRIVATE],
      deployOptions: {
        accessLogDestination: new apigateway.LogGroupLogDestination(apiGatewayLogGroup),
        accessLogFormat: apigateway.AccessLogFormat.jsonWithStandardFields(),
        loggingLevel: props.logLevel ?? apigateway.MethodLoggingLevel.INFO,
        tracingEnabled: true, // Enable X-ray tracing
        metricsEnabled: true, // Enable Metrics for this method
      },
      policy: new iam.PolicyDocument({
        statements: [
          new iam.PolicyStatement({
            effect: iam.Effect.DENY,
            actions: ["execute-api:Invoke"],
            principals: [new iam.AnyPrincipal()],
            resources: ["execute-api:/*"],
            conditions: {
              StringNotEquals: {
                "aws:SourceVpce": apiGwVpcEndpoint.vpcEndpointId
              }
            },
          }),
        ],
      })
    })

    // ---- Authorizer
    // Create Authorizer Lambda Function
    const authorizerLambda = new lambda.Function(this, "AuthorizerLambda", {
      runtime: lambda.Runtime.PYTHON_3_12,
      code: lambda.Code.fromAsset('lambda/python/authorizer'),
      handler: 'index.lambda_handler',
      memorySize: 256,
      timeout: cdk.Duration.seconds(25),
      tracing: lambda.Tracing.ACTIVE,
      insightsVersion: lambda.LambdaInsightsVersion.VERSION_1_0_98_0,
    })

    // Create Authorizer
    const authorizer = new apigateway.TokenAuthorizer(
      this, "Authorizer", {
      handler: authorizerLambda,
      identitySource: apigateway.IdentitySource.header('oidc_access_token')
    }
    )

    // ---- Lambda
    // Create Lambda Function
    const itemLambda = new lambda.Function(this, "ItemLambda", {
      runtime: lambda.Runtime.PYTHON_3_12,
      code: lambda.Code.fromAsset('lambda/python/backend'),
      handler: 'item.lambda_handler',
      memorySize: 256,
      timeout: cdk.Duration.seconds(25),
      tracing: lambda.Tracing.ACTIVE,
      insightsVersion: lambda.LambdaInsightsVersion.VERSION_1_0_98_0,
    })

    // Create Lambda Integration
    const itemResource = restApi.root.addResource("item");
    const itemIntegration = new apigateway.LambdaIntegration(itemLambda)
    itemResource.addMethod('GET', itemIntegration, {
      authorizationType: apigateway.AuthorizationType.CUSTOM,
      authorizer: authorizer,
    })

Lambdaオーソライザーのアプリケーションコードは、AWS公式サイトを参考に作成してます。今回は検証用のため細かな認可ロジックは組み込んでいませんが、必要に応じてトークン検証や追加の認可ロジックを記載ください。

import json

def lambda_handler(event, context):
    token = event['authorizationToken']
    try:
        if isAuthorized(token):
            print('authorized')
            response = generatePolicy('user', 'Allow', event['methodArn'])
        else:
            print('unauthorized')
            # Return a 401 Unauthorized response
            raise Exception('Unauthorized')
            return 'unauthorized'
        return json.loads(response)
    except BaseException:
        print('unauthorized')
        return 'unauthorized'  # Return a 500 error

def isAuthorized(token):
    print(token)
    # 必要に応じてトークン検証や追加の認可ロジックを記載ください。
    return True

def generatePolicy(principalId, effect, resource):
    authResponse = {}
    authResponse['principalId'] = principalId
    if (effect and resource):
        policyDocument = {}
        policyDocument['Version'] = '2012-10-17'
        policyDocument['Statement'] = []
        statementOne = {}
        statementOne['Action'] = 'execute-api:Invoke'
        statementOne['Effect'] = effect
        statementOne['Resource'] = resource
        policyDocument['Statement'] = [statementOne]
        authResponse['policyDocument'] = policyDocument
    authResponse['context'] = {
        "stringKey": "stringval",
        "numberKey": 123,
        "booleanKey": True
    }
    authResponse_JSON = json.dumps(authResponse)
    return authResponse_JSON

バックエンドLambdaのアプリケーションコードは、固定値を返却する簡単なロジックのみ実装します。

import json

def lambda_handler(event, context):
    response = get_items()
    return {
        "statusCode": 200,
        "body": json.dumps(response["Items"])
    }

def get_items():
    response = {
        "Items": [
            {"test": "hoge"},
        ]}
    return response

BFF資源設定

BFF資源であるECSアプリケーションを設定していきます。

  • imageには上述のBFF用Dockerイメージが格納されたECRを指定します。
  • OIDC_CLIENT_SECRETをはじめとしたCognito関連設定値について、Secrets Manager経由でコンテナに受け渡します。
  • その他環境依存情報について、ECS環境変数経由でコンテナに受け渡します。
  • 今回は検証用のためdesiredCountを1にしていますが、単一障害点となることを避けるために、プロダクション環境では必ず冗長化してください。[4]
    // ------------ BFF ---------------
    // ---- ECS Application
    // Create Certificate
    const bffCertificate = new acm.Certificate(this, 'BffCertificate', {
      domainName: props.bffHostname,
      validation: acm.CertificateValidation.fromDns(myHostedZone),
    });

    // Define ECR
    const ecrRepo = ecr.Repository.fromRepositoryArn(this, "EcrRepo", props.ecrRepoArn)

    // Create ECS Cluster
    const cluster = new ecs.Cluster(this, "Cluster", {
      vpc: networking.vpc,
      containerInsights: true,
    })

    // Create ALB & ECS
    const loadBalancedFargateService = new ecsPatterns.ApplicationLoadBalancedFargateService(this, 'Service', {
      cluster,
      certificate: bffCertificate,
      domainName: props.bffHostname,
      domainZone: myHostedZone,
      memoryLimitMiB: 1024,
      cpu: 512,
      desiredCount: props.desiredCount ?? 1,
      taskImageOptions: {
        image: ecs.ContainerImage.fromEcrRepository(ecrRepo, props.imageTag),
        secrets: {
          "OIDC_PROVIDER_METADATA_URL": ecs.Secret.fromSecretsManager(cognitoSecret, "userPoolProviderUrl"),
          "OIDC_CLIENT_ID": ecs.Secret.fromSecretsManager(cognitoSecret, "clientId"),
          "OIDC_CLIENT_SECRET": ecs.Secret.fromSecretsManager(cognitoSecret, "secret"),
        },
        environment: {
          "OIDC_REDIRECT_URI": props.redirectURI,
          "ACCESS_CONTROL_ALLOW_ORIGIN": props.accessControlAllowOrigin,
          "PROXY_PASS": restApi.url,
          "PROXY_PASS_REVERSE": restApi.url,
          "FRONT_URL": props.frontURL
        }
      },
    });

フロント資源を配信するためのCloudFront や S3を設定していきます。

  • SPA資源を配信できるよう、CloudFrontのオリジンにS3を指定します。
  • L2コンストラクトでOAC設定が可能になっていたので、L2コンストラクトで設定していきます。
    // ------------ DNS ---------------
    // ---- Route53
    const myHostedZone = route53.HostedZone.fromHostedZoneAttributes(this, "HostedZone", {
      hostedZoneId: props.hostedZoneId,
      zoneName: props.zoneName
    })

    // ------------ FrontEnd ---------------
    // ---- S3
    const bucket = new s3.Bucket(this, 'Bucket', {
      removalPolicy: cdk.RemovalPolicy.DESTROY,
      autoDeleteObjects: true,
    });

    // ---- CloudFront
    // Create Certificate
    const frontCertificate = new acm.Certificate(this, 'FrontCertificate', {
      domainName: props.frontHostname,
      validation: acm.CertificateValidation.fromDns(myHostedZone),
    });
    // Create Distribution
    const myDist = new cloudfront.Distribution(this, 'MyDist', {
      defaultBehavior: {
        origin: origins.S3BucketOrigin.withOriginAccessControl(bucket)
      },
      domainNames: [props.frontHostname],
      certificate: frontCertificate,
    });
    // Create A Record
    new route53.ARecord(this, "frontARecord", {
      recordName: props.frontHostname,
      zone: myHostedZone,
      target: route53.RecordTarget.fromAlias(new targets.CloudFrontTarget(myDist))
    })

Nextアプリケーション設定

動作確認用にNext.jsで簡単なSPAを作成していきます。(あくまで検証用に構築したアプリであること、ご理解ください。)

  • ボタン「Get Data」を押下すると、BFF経由でバックエンドAPIへリクエストします。
    • 未認証の場合は、認証エラーメッセージを表示します。
    • 認証済みの場合は、BFF経由でバックエンドAPIから取得したJSONを表示します。
  • ボタン「Login」を押下すると、BFFのログイン専用URLに画面遷移して、認可コードフローが走ります。
    • 認可コードフローが完了すると、Apache側の設定によって再度SPAへリダイレクトされます。
"use client";

import React, { useState } from 'react';
import { fetchData, Data } from '../lib/api';
import '../app/globals.css';

const HomePage: React.FC = () => {
  const [data, setData] = useState<Data[] | null>(null);
  const [error, setError] = useState<string | null>(null);

  const handleFetchData = async (): Promise<void> => {
    try {
      const result = await fetchData();
      setData(result);
      setError(null);
    } catch (error) {
      if (error instanceof Error) {
        setError(error.message);
      } else {
        setError('An unknown error occurred');
      }
      setData(null);
    }
  };

  const handleLogin = (): void => {
    window.location.href = "https://website.example.com/private/login";
  };

  return (
    <div className="min-h-screen bg-gradient-to-br from-blue-500 via-purple-500 to-pink-500 text-white flex flex-col">
      <main className="flex flex-1 flex-col justify-center items-center p-8">
        <h1 className="mb-6 text-4xl font-extrabold drop-shadow-lg">Data Fetcher</h1>
        <div className="flex space-x-4">
          <button
            onClick={handleLogin}
            className="bg-white bg-opacity-20 hover:bg-opacity-30 text-white py-3 px-6 rounded-full font-medium shadow-lg transition duration-200"
          >
            Login
          </button>
          <button
            onClick={handleFetchData}
            className="bg-white bg-opacity-20 hover:bg-opacity-30 text-white py-3 px-6 rounded-full font-medium shadow-lg transition duration-200"
          >
            Get Data
          </button>
        </div>
        {error && (
          <div className="bg-red-600 bg-opacity-80 text-white p-4 rounded-md mt-4 shadow-lg">
            <h2 className="font-bold">Error</h2>
            <p>{error}</p>
          </div>
        )}
        {data && (
          <div className="bg-white bg-opacity-20 p-4 rounded-lg text-white w-full max-w-3xl shadow-lg mt-6">
            <h2 className="text-xl font-bold mb-2">Fetched Data</h2>
            <pre className="whitespace-pre-wrap break-words">{JSON.stringify(data, null, 2)}</pre>
          </div>
        )}
      </main>
      <footer className="bg-opacity-90 backdrop-blur-lg p-4 text-center text-sm">
        &copy; 2024 My App. All rights reserved.
      </footer>
    </div>
  );
};

export default HomePage;

import axios, { AxiosRequestConfig } from 'axios';

export interface Data {
  id: string;
  name: string;
}

// Axios instance for API requests
const apiClient = axios.create({
  baseURL: 'https://website.example.com/private',
  withCredentials: true, // cookie送信設定
});

// Function to handle API requests with error handling
export const fetchData = async (): Promise<Data[]> => {
  const requestConfig: AxiosRequestConfig = {
    url: '/item',
    method: 'GET',
  };

  try {
    const response = await apiClient.request<Data[]>(requestConfig);
    return response.data;
  } catch (error) {
    if (axios.isAxiosError(error) && error.response?.status === 401) {
      // 401エラーが発生した場合にログインを促すメッセージを表示
      alert('Unauthorized access. Please log in to continue.');
    } else {
      // その他のエラーはコンソールにログを出力
      console.error('Error fetching data:', error);
    }
    throw error; // エラーを再スローして呼び出し元で処理できるようにする
  }
};

SSGで生成したSPA資源は、S3にアップロードしておきます。

動作確認

それでは、CloudFront上のSPAにアクセスしてみましょう。
未認証の状態でボタン「Get Data」を押下すると、エラーメッセージが表示されます。

ボタン「Login」を押下すると、Cognitoのログイン画面に遷移します。[5]


ログイン完了後、SPA画面に再度リダイレクトされます。開発者ツールを確認すると、mod_auth_openidcのセッション管理用Cookieが発行されていることが確認できました。

この状態でボタン「Get Data」を押下すると、無事JSONが表示されました。

参考

https://datatracker.ietf.org/doc/html/draft-ietf-oauth-browser-based-apps#name-backend-for-frontend-bff
https://aws.amazon.com/jp/blogs/news/announcing-baseline-environment-on-aws/
https://github.com/OpenIDC/mod_auth_openidc
https://auth0.com/blog/the-backend-for-frontend-pattern-bff/
https://aws.amazon.com/jp/blogs/news/implementing-custom-domain-names-for-amazon-api-gateway-private-endpoints-using-a-reverse-proxy/
https://aws.amazon.com/jp/blogs/devops/a-new-aws-cdk-l2-construct-for-amazon-cloudfront-origin-access-control-oac/
https://github.com/OpenIDC/mod_auth_openidc/wiki/Caching
https://github.com/aws-samples/baseline-environment-on-aws/blob/main/usecases/blea-guest-ecs-app-sample/lib/construct/networking.ts
https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/apigateway-use-lambda-authorizer.html#api-gateway-lambda-authorizer-token-lambda-function-create
https://aws.amazon.com/jp/about-aws/whats-new/2024/11/amazon-cognito-managed-login/

注意事項

  • 本記事は万全を期して作成していますが、お気づきの点がありましたら、ご連絡よろしくお願いします。
  • なお、本記事の内容を利用した結果及び影響について、筆者は一切の責任を負いませんので、予めご了承ください。
脚注
  1. SPAをはじめとしたパブリッククライアントのセキュリティ向上策として、PKCEやDPoP等の技術要素も挙げられます。 ↩︎

  2. プロダクション環境等ではApache冗長化することになると思います。公式Wikiを参考に、セッション共有するためのキャッシュストレージとしてredis採用を検討ください。 ↩︎

  3. 未認証ユーザの認可コードフロー開始について、様々な実装方式が取りうるでしょう。システム要件やアプリケーションライブラリ等を考慮したうえで、OAuth 2.0 for Browser-Based Applicationsに記載されている"check session" API endpointを用いたセッション確認ロジック実装や、未認証状態検出時にログインボタンを介さない認可コードフロー開始等も検討ください。 ↩︎

  4. 冗長化時には、キャッシュストレージとなるAmazon ElastiCacheの構築も検討ください。 ↩︎

  5. ブログ執筆タイミングの都合上、2024年11月アップデートのマネージドログイン機能を反映できておりません。 ↩︎

Accenture Japan (有志)

Discussion