[AWS CDK] ALBとCognitoを使ってOktaをIdPとするSAML認証をしてみた

SAML認証を簡単に実装したい場合に
2024.04.26

サクッとSAML認証を実装したい

こんにちは、のんピ(@non____97)です。

皆さんサクッとSAML認証を実装したいなと思ったことはありますか? 私はあります。

自分でSAML認証のService Provider(SP)側の処理を実装するのは大変です。そのような場合はALBとCognitoを使うと簡単に行えます。

ということで実際にやってみました。今回はIdPとしてOktaを使用します。

「SAML認証ってなんやねん」や「OktaのSAMLアプリってどうやって作成すればいいんだ」、「CognitoでSAML認証ってどうやって行えばいいんだ」という方は以下ドキュメントをご覧ください。

また、せっかくなので以下アップデートで可能になった署名付きSAMLリクエストと、暗号化されたSAMLレスポンスも行います。

AWS CDKのコードの説明

検証環境

作成する環境の構成図は以下のとおりです。

[AWS CDK] ALBとCognitoを使ってOktaをIdPとするSAML認証をしてみた

ALBでCognitoを使ったSAML認証をするようにします。

また、AWS CDKのコードは以下リポジトリに保存しています。

SAML認証

SAML認証周りでConstructを分けています。

やっていることは以下のとおりです。

  • ユーザープールの作成
    • SAML認証なのでセルフサインアップは無効
  • 作成するカスタムドメインのゾーンのルートドメインのAレコードを作成
  • ユーザープールドメインの作成
    • 今回はCognitoドメインではなく、カスタムドメイン
  • SAML IdPのメタデータ等が指定されている場合は以下の処理を実施
    • SAMLアイデンティティプロバイダーの作成
    • アプリケーションクライアントの作成

実際のコードは以下のとおりです。

./lib/construct/saml-auth-construct.ts

import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import { SamlAuthProperty } from "../../parameter/index";
import { HostedZoneConstruct } from "./hosted-zone-construct";
import { CertificateConstruct } from "./certificate-construct";

export interface SamlAuthConstructProps extends SamlAuthProperty {
  hostedZoneConstruct: HostedZoneConstruct;
  certificateConstruct: CertificateConstruct;
}

export class SamlAuthConstruct extends Construct {
  public readonly userPool: cdk.aws_cognito.IUserPool;
  public readonly userPoolDomain: cdk.aws_cognito.IUserPoolDomain;
  public readonly userPoolClient: cdk.aws_cognito.IUserPoolClient;

  constructor(scope: Construct, id: string, props: SamlAuthConstructProps) {
    super(scope, id);

    // User pool
    const userPool = new cdk.aws_cognito.UserPool(this, "Default", {
      selfSignUpEnabled: false,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
    });
    this.userPool = userPool;

    // Custom Domain
    const rootDomainRecord = new cdk.aws_route53.ARecord(
      this,
      "RootDomainRecord",
      {
        zone: props.hostedZoneConstruct.hostedZone,
        target: cdk.aws_route53.RecordTarget.fromIpAddresses("127.0.0.1"),
      }
    );

    const userPoolDomain = userPool.addDomain("CustomDomain", {
      customDomain: {
        domainName: `${props.domainName}.${props.hostedZoneConstruct.hostedZone.zoneName}`,
        certificate: props.certificateConstruct.certificate,
      },
    });
    this.userPoolDomain = userPoolDomain;
    userPoolDomain.node.defaultChild?.node.addDependency(rootDomainRecord);

    new cdk.aws_route53.ARecord(this, "CustomDomainRecord", {
      zone: props.hostedZoneConstruct.hostedZone,
      recordName: props.domainName,
      target: cdk.aws_route53.RecordTarget.fromAlias(
        new cdk.aws_route53_targets.UserPoolDomainTarget(userPoolDomain)
      ),
    });

    if (!props.saml) {
      return;
    }

    // User Pool Identity Provider
    const userPoolProvider = new cdk.aws_cognito.UserPoolIdentityProviderSaml(
      this,
      "UserPoolProvider",
      {
        userPool,
        metadata: cdk.aws_cognito.UserPoolIdentityProviderSamlMetadata.url(
          props.saml.metadataURL
        ),
        encryptedResponses: true,
        requestSigningAlgorithm: cdk.aws_cognito.SigningAlgorithm.RSA_SHA256,
        attributeMapping: {
          email: cdk.aws_cognito.ProviderAttribute.AMAZON_EMAIL,
        },
      }
    );

    // User Pool Client
    const userPoolClient = userPool.addClient("UserPoolClient", {
      generateSecret: true,
      oAuth: {
        callbackUrls: props.saml.callbackUrls,
        logoutUrls: props.saml.logoutUrls,
        flows: {
          implicitCodeGrant: false,
          authorizationCodeGrant: true,
        },
        scopes: [
          cdk.aws_cognito.OAuthScope.OPENID,
          cdk.aws_cognito.OAuthScope.EMAIL,
          cdk.aws_cognito.OAuthScope.PROFILE,
        ],
      },
      authFlows: {
        userSrp: true,
      },
      preventUserExistenceErrors: true,
      supportedIdentityProviders: [
        cdk.aws_cognito.UserPoolClientIdentityProvider.custom(
          userPoolProvider.providerName
        ),
      ],
    });
    this.userPoolClient = userPoolClient;
  }
}

やってみた

Cognitoユーザープールの作成

実際にデプロイしてみます。

まず、Cognitoユーザープールを作成します。その他VPCやRoute 53 Public Hosted Zoneなど諸々必要なリソースも作成します。

AWS CDKのコードのパラメーターは以下のとおりです。

./parameter/index.ts

export const samlAppStackProperty: SamlAppStackProperty = {
  env: {
    account: process.env.CDK_DEFAULT_ACCOUNT,
    region: process.env.CDK_DEFAULT_REGION,
  },
  props: {
    hostedZone: {
      zoneName: "saml-app.non-97.net",
    },
    certificate: {
      certificateDomainName: "*.saml-app.non-97.net",
    },
    samlAuth: {
      domainName: "auth",
    },
    network: {
      vpcCidr: "10.10.0.0/20",
      subnetConfigurations: [
        {
          name: "public",
          subnetType: cdk.aws_ec2.SubnetType.PUBLIC,
          cidrMask: 27,
        },
      ],
      maxAzs: 2,
      natGateways: 0,
    },
    asg: {
      machineImage: cdk.aws_ec2.MachineImage.latestAmazonLinux2023({
        cachedInContext: true,
      }),
      instanceType: new cdk.aws_ec2.InstanceType("t3.micro"),
      subnetSelection: {
        subnetType: cdk.aws_ec2.SubnetType.PUBLIC,
      },
    },
    alb: {
      internetFacing: true,
      allowIpAddresses: ["0.0.0.0/0"],
      subnetSelection: {
        subnetType: cdk.aws_ec2.SubnetType.PUBLIC,
      },
      recordName: "www",
    },
  },
};

デプロイ後、Cognitoユーザープールを確認します。ユーザープールIDはIdP側で使用するので控えておきます。

作成したユーザープールの確認

Cognitoが使用するドメインも控えておきます。後ほど使用します。

カスタムドメインの確認

Oktaの設定

次にIdPであるOktaの設定をします。

各パラメーターの詳細は以下Okta公式ドキュメントをご覧ください。

Oktaにログイン後、Applications-Applications-Create App Integrationsをクリックします。

Create App Integration

SAML 2.0を選択します。

SAML 2.0

アプリ名を入力します。

General Settings

事前に確認した内容をもとにSAMLの設定をします。

  • Single Sign On URL : https://Cognitoドメイン or カスタムドメイン/saml2/idpresponse
  • Audience Restriction : urn:amazon:cognito:sp:CognitoユーザープールID
  • Name ID format : Persistent
  • Attribute Statements : Oktaのuser.emailemailとして渡すように設定

SAML Settings

Name ID formatPersistentに設定しない場合は、ALBにアクセスをして認証をする際にNameIDPolicy 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent' is not the configured Name ID Format 'urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified' for the appと400エラーになります。

不正な​リクエスト

最後にアプリの種類を選択します。

Help Okta Support understand how you configured this application

設定が完了するとメタデータURLが発行されます。後ほど使用するので控えておきましょう。

Sign On

今のままでは認証に使用するユーザーが紐づいていないので、ユーザーを割り当てます。

AssignmentsタブからPeopleを確認します。

Assignments

適当にユーザーを割り当てます。

ユーザーをアサイン

Cognitoの設定変更

IdPで使用するメタデータURLを確認できたので、Cognito側でSAMLのアイデンティティプロバイダーを設定します。

AWS CDKのコードのパラメーターは以下のとおりです。

  • metadataURL : Oktaで確認したメタデータURL
  • callbackUrls : `https://ALBのFQDN/oauth2/idpresponse`

./parameter/index.ts

export const samlAppStackProperty: SamlAppStackProperty = {
  env: {
    account: process.env.CDK_DEFAULT_ACCOUNT,
    region: process.env.CDK_DEFAULT_REGION,
  },
  props: {
.
.
(中略)
.
.
    samlAuth: {
      domainName: "auth",
      saml: {
        metadataURL:
          "<Oktaで確認したメタデータURL>",
        callbackUrls: ["https://www.saml-app.non-97.net/oauth2/idpresponse"],
      },
    },
.
.
(中略)
.
.
  },
};

npx cdk diffを叩いて差分を確認します。アイデンティティプロバイダーやアプリケーションクライアント、ALBの作成が行われそうですね。

$ npx cdk diff
[WARNING] aws-cdk-lib.aws_ec2.LaunchTemplateProps#keyName is deprecated.
  - Use `keyPair` instead - https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_ec2-readme.html#using-an-existing-ec2-key-pair
  This API will be removed in the next major release.
Stack SamlAppStack
Hold on while we create a read-only change set to get a diff with accurate replacement information (use --no-change-set to use a less accurate but faster template-only diff)
Security Group Changes
┌───┬───────────────────────────────────────────────────────┬─────┬──────────┬───────────────────────────────────────────────────────┐
│   │ Group                                                 │ Dir │ Protocol │ Peer                                                  │
├───┼───────────────────────────────────────────────────────┼─────┼──────────┼───────────────────────────────────────────────────────┤
│ + │ ${AlbConstruct/Default/SecurityGroup.GroupId}         │ In  │ TCP 443  │ Everyone (IPv4)                                       │
│ + │ ${AlbConstruct/Default/SecurityGroup.GroupId}         │ Out │ TCP 443  │ Everyone (IPv4)                                       │
│ + │ ${AlbConstruct/Default/SecurityGroup.GroupId}         │ Out │ TCP 80   │ ${AsgConstruct/Default/InstanceSecurityGroup.GroupId} │
├───┼───────────────────────────────────────────────────────┼─────┼──────────┼───────────────────────────────────────────────────────┤
│ + │ ${AsgConstruct/Default/InstanceSecurityGroup.GroupId} │ In  │ TCP 80   │ ${AlbConstruct/Default/SecurityGroup.GroupId}         │
└───┴───────────────────────────────────────────────────────┴─────┴──────────┴───────────────────────────────────────────────────────┘
(NOTE: There may be security-related changes not in this list. See https://github.com/aws/aws-cdk/issues/1299)

Resources
[+] AWS::Cognito::UserPoolClient SamlAuthConstruct/Default/UserPoolClient SamlAuthConstructUserPoolClient38E678A0 
[+] AWS::Cognito::UserPoolIdentityProvider SamlAuthConstruct/UserPoolProvider SamlAuthConstructUserPoolProvider32C7AAF5 
[+] AWS::EC2::SecurityGroupIngress AsgConstruct/Default/InstanceSecurityGroup/from SamlAppStackAlbConstructSecurityGroupF181F2FC:80 AsgConstructInstanceSecurityGroupfromSamlAppStackAlbConstructSecurityGroupF181F2FC80F2DEAFB2 
[+] AWS::ElasticLoadBalancingV2::LoadBalancer AlbConstruct/Default AlbConstructF9C2F654 
[+] AWS::EC2::SecurityGroup AlbConstruct/Default/SecurityGroup AlbConstructSecurityGroup6F1A15B1 
[+] AWS::EC2::SecurityGroupEgress AlbConstruct/Default/SecurityGroup/to SamlAppStackAsgConstructInstanceSecurityGroup25C8618D:80 AlbConstructSecurityGrouptoSamlAppStackAsgConstructInstanceSecurityGroup25C8618D80B02521F2 
[+] AWS::ElasticLoadBalancingV2::Listener AlbConstruct/Default/ListenerHttps AlbConstructListenerHttpsA10A42FE 
[+] AWS::ElasticLoadBalancingV2::TargetGroup AlbConstruct/TargetGroup AlbConstructTargetGroup0AE1146B 
[+] AWS::Route53::RecordSet AlbConstruct/AliasRecord AlbConstructAliasRecord09C3EC5D 
[~] AWS::AutoScaling::AutoScalingGroup AsgConstruct/Default/ASG AsgConstructASG899212B0 
 └─ [+] TargetGroupARNs
     └─ [{"Ref":"AlbConstructTargetGroup0AE1146B"}]

✨  Number of stacks with differences: 1

npx cdk deployでデプロイをします。

デプロイ完了後、ALBのリスナーを確認すると、作成したCognitoユーザープールやアプリケーションクライアントを使って認証をすることが分かります。

リスナーの確認

作成されたアプリケーションクライアントアプリケーションクライアントは以下のとおりです。

動作確認

動作確認をします。

Oktaにサインインしていない状態で`https://www.saml-app.non-97.net/`にアクセスします。

すると、Oktaのサインイン画面に遷移しました。

アクセスするとリダイレクトされる

認証情報を入力してサインインすると、Oktaのトップページが表示されました。

Oktaのトップページ

再度`https://www.saml-app.non-97.net/`にアクセスします。

すると、401 Authorization Requiredとなってしまいました。

401 Authorization Required

ALBのアクセスログを確認すると、以下のようなログが出力されていました。AuthInvalidStateParamとしてエラーになっていそうです。

h2 2024-04-23T01:22:12.765680Z app/SamlAp-AlbCo-FnNoQ8SmD7n9/fc6f03c0e4c7a9bb 110.66.137.110:62065 - -1 -1 -1 401 - 626 617 "GET https://www.saml-app.non-97.net:443/oauth2/idpresponse?error_description=Invalid+SAML+response+received%3A+Responses+must+contain+exactly+one+Encrypted+Assertion&error=server_error HTTP/2.0" "<ユーザーエージェント> " TLS_AES_128_GCM_SHA256 TLSv1.3 - "Root=1-66270d44-09196e8b3dc780c35f1d88f6" "www.saml-app.non-97.net" "session-reused" -1 2024-04-23T01:22:12.765000Z "authenticate" "-" "AuthMissingStateParam" "-" "-" "-" "-"
h2 2024-04-23T01:22:13.118116Z app/SamlAp-AlbCo-FnNoQ8SmD7n9/fc6f03c0e4c7a9bb 110.66.137.110:62065 - -1 -1 -1 302 - 75 628 "GET https://www.saml-app.non-97.net:443/ HTTP/2.0" "<ユーザーエージェント> " TLS_AES_128_GCM_SHA256 TLSv1.3 arn:aws:elasticloadbalancing:us-east-1:<AWSアカウントID> :targetgroup/SamlAp-AlbCo-YM7SRSNA1MO0/9d93a3233a61d833 "Root=1-66270d45-7e8f39e75b7cb4d341059d3e" "www.saml-app.non-97.net" "session-reused" 0 2024-04-23T01:22:13.117000Z "authenticate" "-" "-" "-" "-" "-" "-"
h2 2024-04-23T01:22:14.118030Z app/SamlAp-AlbCo-FnNoQ8SmD7n9/fc6f03c0e4c7a9bb 110.66.137.110:62065 - -1 -1 -1 302 - 44 631 "GET https://www.saml-app.non-97.net:443/ HTTP/2.0" "<ユーザーエージェント> " TLS_AES_128_GCM_SHA256 TLSv1.3 arn:aws:elasticloadbalancing:us-east-1:<AWSアカウントID> :targetgroup/SamlAp-AlbCo-YM7SRSNA1MO0/9d93a3233a61d833 "Root=1-66270d46-15de8dc3467061df4b6c3189" "www.saml-app.non-97.net" "session-reused" 0 2024-04-23T01:22:14.117000Z "authenticate" "-" "-" "-" "-" "-" "-"
h2 2024-04-23T01:22:16.672090Z app/SamlAp-AlbCo-FnNoQ8SmD7n9/fc6f03c0e4c7a9bb 110.66.137.110:62065 - -1 -1 -1 401 - 374 616 "GET https://www.saml-app.non-97.net:443/oauth2/idpresponse?error_description=Invalid+SAML+response+received%3A+Responses+must+contain+exactly+one+Encrypted+Assertion&state=t1vREOOgsJWr72GsFMdhdZ0b%2B3RIXdkeysQ9EOclCQIGA9lLhSfaQF3AKwMxgGl2tdVfKbiOxZnpS%5C%2FXEMakkAKyXGyl57LU6u0C4K5BKoXar6ZmgseCA4Uq%2BniThQEsDcq2gidiaQkf8BG9b2nsTPvsKaKrFAGaD5qNjSEZlVbU0nBOc5zWNGihoSKjjtgBqiFGDnGXvJqf4DQfEIvHh6UyJaHSWDy9YdP%2BMQIJRcXJqjGOG7hEKCg%3D%3D&error=server_error HTTP/2.0" "<ユーザーエージェント> " TLS_AES_128_GCM_SHA256 TLSv1.3 - "Root=1-66270d48-56daab6f511acd314eb31546" "www.saml-app.non-97.net" "session-reused" -1 2024-04-23T01:22:16.671000Z "authenticate" "-" "AuthInvalidStateParam" "-" "-" "-" "-"
h2 2024-04-23T01:24:17.128488Z app/SamlAp-AlbCo-FnNoQ8SmD7n9/fc6f03c0e4c7a9bb 110.66.137.110:61004 - -1 -1 -1 401 - 860 617 "GET https://www.saml-app.non-97.net:443/oauth2/idpresponse?error_description=Invalid+SAML+response+received%3A+Responses+must+contain+exactly+one+Encrypted+Assertion&state=t1vREOOgsJWr72GsFMdhdZ0b%2B3RIXdkeysQ9EOclCQIGA9lLhSfaQF3AKwMxgGl2tdVfKbiOxZnpS%5C%2FXEMakkAKyXGyl57LU6u0C4K5BKoXar6ZmgseCA4Uq%2BniThQEsDcq2gidiaQkf8BG9b2nsTPvsKaKrFAGaD5qNjSEZlVbU0nBOc5zWNGihoSKjjtgBqiFGDnGXvJqf4DQfEIvHh6UyJaHSWDy9YdP%2BMQIJRcXJqjGOG7hEKCg%3D%3D&error=server_error HTTP/2.0" "<ユーザーエージェント> " TLS_AES_128_GCM_SHA256 TLSv1.3 - "Root=1-66270dc1-7fead44f03e95b9b7c3c9104" "www.saml-app.non-97.net" "session-reused" -1 2024-04-23T01:24:17.128000Z "authenticate" "-" "AuthInvalidStateParam" "-" "-" "-" "-"

error_descriptionクエリの文字列を確認すると、Invalid SAML response received: Responses must contain exactly one Encrypted Assertionと記載されています。

この原因はSAMLのアイデンティティプロバイダーでIdPから暗号化されたSAMLアサーションを要求するように設定をしているにも関わらず、IdP側でその設定をしていないためです。加えて、SAMLリクエストの署名に使用する証明書をIdPに渡していません。

署名と暗号化それぞれで使用する証明書を確認して、ローカルにダウンロードします。

署名証明書と暗号化証明書の表示

暗号化証明書

Oktaの設定変更をします。変更前はAssertion EncryptionUnencryptedで、SAML Signed RequestDisabledであることを確認します。

変更前のApp Settings

SAMLアサーションの暗号化とSAMLリクエストの署名の検証を行うように設定します。その際に事前にダウンロードしておいた各証明書を指定します。

Oktaの設定変更

それでは、再度`https://www.saml-app.non-97.net/`にアクセスします。すると、EC2インスタンス上で動作しているWebサーバーから正常にリクエストが返ってきました。

表示できたことを確認

Cookieを確認すると、ALBのリスナーで指定したセッションCookieが作成されていました。

Cookieの確認

ユーザープールのユーザー一覧を確認すると、認証に使用したユーザーが登録されていました。

ユーザーの確認

`https://www.saml-app.non-97.net/phpinfo.php`にアクセスして、`phpinfo()`の結果も確認してみます。

phpinfo

以下記事を参考にSAML認証によってALBで追加されたヘッダーを確認します。

x-amzn-oidc-dataなどのヘッダー情報を確認できました。

HTTP_X_AMZN_OIDC

x-amzn-oidc-dataをデコードしてみましょう。

$ oidc_data='<x-amzn-oidc-data>'
$ echo $oidc_data \
	| cut -d'.' -f 2 \
	| base64 -D \
	| jq

{
  "sub": "f4587468-6061-703a-ddd7-2facbd76285f",
  "email_verified": "false",
  "identities": "[{\"dateCreated\":\"1713847527334\",\"userId\":\"<メールアドレス> \",\"providerName\":\"SamlAppStackPoolProvider96B21C88\",\"providerType\":\"SAML\",\"issuer\":null,\"primary\":\"true\"}]",
  "email": "<メールアドレス> ",
  "username": "SamlAppStackPoolProvider96B21C88_<メールアドレス> ",
  "exp": 1714084995,
  "iss": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_yy2XiquVR"
}

ユーザープールに登録されたユーザー情報とを照らし合わせると、各種値が一致していることが確認できます。

ユーザープールに登録されたユーザー情報詳細

以下記事で紹介しているとおり、アプリケーション側ではこちらの値を元に色々と処理できそうです。

SAML認証を簡単に実装したい場合に

ALBとCognitoを使ってOktaをIdPとするSAML認証をしてみました。

SAML認証を簡単に実装したい場合に役立ちそうですね。

この記事が誰かの助けになれば幸いです。

以上、AWS事業本部 コンサルティング部の のんピ(@non____97)でした!