[AWS CDK] ALBとCognitoを使ってOktaをIdPとするSAML認証をしてみた
サクッとSAML認証を実装したい
こんにちは、のんピ(@non____97)です。
皆さんサクッとSAML認証を実装したいなと思ったことはありますか? 私はあります。
自分でSAML認証のService Provider(SP)側の処理を実装するのは大変です。そのような場合はALBとCognitoを使うと簡単に行えます。
ということで実際にやってみました。今回はIdPとしてOktaを使用します。
「SAML認証ってなんやねん」や「OktaのSAMLアプリってどうやって作成すればいいんだ」、「CognitoでSAML認証ってどうやって行えばいいんだ」という方は以下ドキュメントをご覧ください。
また、せっかくなので以下アップデートで可能になった署名付きSAMLリクエストと、暗号化されたSAMLレスポンスも行います。
AWS CDKのコードの説明
検証環境
作成する環境の構成図は以下のとおりです。
ALBでCognitoを使ったSAML認証をするようにします。
また、AWS CDKのコードは以下リポジトリに保存しています。
SAML認証
SAML認証周りでConstructを分けています。
やっていることは以下のとおりです。
- ユーザープールの作成
- SAML認証なのでセルフサインアップは無効
- 作成するカスタムドメインのゾーンのルートドメインのAレコードを作成
- 以下re:Postに記載のとおり、
- Amazon Cognito のカスタムドメインエラーをトラブルシューティングする | AWS re:Post
- ユーザープールドメインの作成
- 今回はCognitoドメインではなく、カスタムドメイン
- SAML IdPのメタデータ等が指定されている場合は以下の処理を実施
- SAMLアイデンティティプロバイダーの作成
- アプリケーションクライアントの作成
実際のコードは以下のとおりです。
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のコードのパラメーターは以下のとおりです。
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
をクリックします。
SAML 2.0
を選択します。
アプリ名を入力します。
事前に確認した内容をもとに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.email
をemail
として渡すように設定
Name ID format
をPersistent
に設定しない場合は、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エラーになります。
最後にアプリの種類を選択します。
設定が完了するとメタデータURLが発行されます。後ほど使用するので控えておきましょう。
今のままでは認証に使用するユーザーが紐づいていないので、ユーザーを割り当てます。
Assignments
タブからPeopleを確認します。
適当にユーザーを割り当てます。
Cognitoの設定変更
IdPで使用するメタデータURLを確認できたので、Cognito側でSAMLのアイデンティティプロバイダーを設定します。
AWS CDKのコードのパラメーターは以下のとおりです。
- metadataURL : Oktaで確認したメタデータURL
- callbackUrls : `https://ALBのFQDN/oauth2/idpresponse`
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のトップページが表示されました。
再度`https://www.saml-app.non-97.net/`にアクセスします。
すると、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 Encryption
がUnencrypted
で、SAML Signed Request
がDisabled
であることを確認します。
SAMLアサーションの暗号化とSAMLリクエストの署名の検証を行うように設定します。その際に事前にダウンロードしておいた各証明書を指定します。
それでは、再度`https://www.saml-app.non-97.net/`にアクセスします。すると、EC2インスタンス上で動作しているWebサーバーから正常にリクエストが返ってきました。
Cookieを確認すると、ALBのリスナーで指定したセッションCookieが作成されていました。
ユーザープールのユーザー一覧を確認すると、認証に使用したユーザーが登録されていました。
`https://www.saml-app.non-97.net/phpinfo.php`にアクセスして、`phpinfo()`の結果も確認してみます。
以下記事を参考にSAML認証によってALBで追加されたヘッダーを確認します。
x-amzn-oidc-data
などのヘッダー情報を確認できました。
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)でした!