aws-samples / cloudfront-authorization-at-edge

Protect downloads of your content hosted on CloudFront with Cognito authentication using cookies and Lambda@Edge
https://aws.amazon.com/blogs/networking-and-content-delivery/authorizationedge-using-cookies-protect-your-amazon-cloudfront-content-from-being-downloaded-by-unauthenticated-users/
MIT No Attribution
474 stars 157 forks source link

Migration to 2.0 Problem -- RedirectUrisSignOut #82

Closed rpattcorner closed 4 years ago

rpattcorner commented 4 years ago

Working on migrating our application to a@e 2.0 to look further at #81 and other features, and run into a roadblock. The demonstration stack for 2.0 that illustrates how to define Cognito UserPool etc. in the parent stack works fine but ...

I've pulled the Cognito User Pool and other related artifacts into the main stack, and successfully created all artifacts until we get to the calls to the nested a@e stack. In creating the framework I see:

Embedded stack arn:aws:cloudformation:us-east-1:REDACTED:stack/ae200d-LambdaEdgeProtection-L97QEYQVDZLR/4cb7b300-ef9e-11ea-ace7-126c97cb5bc1 was not successfully created: Cannot export output RedirectUrisSignOut. Exported values must not be empty or whitespace-only.

My call to the nested stack which worked fine in 1.2 has changed only in minor ways ... I've added parameters for the UserPoolArn and UserPoolClientId I've created in the parent stack, just as the a@e example for 2.0 does. The failing call looks like this:

  LambdaEdgeProtection:
    Type: AWS::Serverless::Application
    DependsOn: UserPoolDomain
    Properties:
      Location:
        ApplicationId: arn:aws:serverlessrepo:us-east-1:520945424137:applications/cloudfront-authorization-at-edge
        SemanticVersion: 2.0.0
      Parameters:
        CreateCloudFrontDistribution: "false"
        HttpHeaders: !Ref HttpHeaders
        UserPoolArn: !GetAtt UserPool.Arn
        UserPoolClientId: !Ref UserPoolClient

A visit through the code shows that RedirectUrisSignOut is mainly referenced in src/cfn-custom-resources/user-pool-client/index.ts and in the main template, suggesting that maybe I've got the dependencies wrong, but they seem obvious ... an explicit depends on the UserPoolDomain, and implicit ones for the UserPoolClient and UserPool.Arn. All we're really doing here is adding the HttpHeaders parameter from the main template.

Another possibility -- because this example does not use custom domains, but just relies on the CloudFront URL, as does the example, is some problem with the sentinel values -- but I left them alone, as they are in the example.

Any idea what might be going on?

ottokruse commented 4 years ago

Looking closely at the auth@edge 2.0 code, I think it can go wrong if you:

Which is indeed the scenario you're in, looking at that piece of CFN you pasted.

You can unblock yourself by providing AlternateDomainNames or by setting CreateCloudFrontDistribution to true.

Meanwhile, I will fix the code, to make this scenario work again too.

Question: is this just a test or do you actually want to deploy like this? Because in this scenario you always have to manually update the redirect URI's in the User Pool Domain, after doing the deployment.

rpattcorner commented 4 years ago

This isn't our most common deploy scenario -- the common usage is to use an AlternateDomainName -- but it's a useful testing scenario for a quick setup that doesn't require a certificate and domain, and for us a required scenario for an agency or company that does not have control over their domains or certificates..

So we would like to be able to both create the cloudfront in the top level stack, setting CreateCloudFrontDistribution to false and providing no value for AlternateDomainName. One original hope in testing a@e 2.0 was that in bringing both the UserPool and the Cloudfront inside the top level CFN stack we could avoid a circular dependency by creating the CloudFront (thus making its entry point available to CFN) then using the Cloudfront entry point as the redirect URI in the User Pool.

Unfortunately it looks like a@e needs the Cloudfront default URL, and CloudFront needs to know the URLs of the a@e lambdas so there is still a circularity.

Failing a true solution it would be very welcome to restore the 1.2 capability to allow no AlternateDomainName and CreateCloudFrontDistribution to False, then manually update the user pool.

Eventually we may rewrite to avoid the top level CloudFormation stack altogether in favor of our internal cloud API tool called mu -- but that's a long term prospect.

ottokruse commented 4 years ago

Unfortunately it looks like a@e needs the Cloudfront default URL, and CloudFront needs to know the URLs of the a@e lambdas so there is still a circularity.

Yes that can only be solved by a custom resource in the top level stack. But the custom resource implementation can be borrowed from a@e, so it will be simple actually. But the a@e custom resource handler arn needs to be made a stack output, so you can refer to it (a 1 min change to a@e)

rpattcorner commented 4 years ago

Funny, I was out walking the dog and the same idea occurred to me. There's already a LambdaCodeUpdateHandler lambda in a@e that fills the architectural function that would be required in a custom resource, since the LambdaCodeUpdateHandler adjusts the JSON in the other lambdas for changes in headers and the like!

That would be a great solution ... and I hope it's something you might do. I'm seeing it like this -- just to get it on paper:

In addition, we can add functionality to significantly make the stacks more independent as you suggest:

Is that what you had in mind? Is that possible? If so I'd be happy to test it out.

R.

ottokruse commented 4 years ago

Funny, I was out walking the dog and the same idea occurred to me.

LOL!

Yeah that's it in principle––but I mean the UserPoolClientUpdate custom resource to be exact. I need to have a step back to see how this resource is used now and if the plan really makes sense, but I think it does. I'll keep you posted.

If you wanna do it by the way, that's fine too. I can provide guidance while you code the PR (if you need it)

rpattcorner commented 4 years ago

I'd rather you did if you're willing ... I'm pretty snowed on the app itself, and don't have any experience compiling a SAM module.

ottokruse commented 4 years ago

This should be it: #83

ottokruse commented 4 years ago

OK you should now be able to add a resource like this to the top-level stack:

  UserPoolClientUpdate:
    Type: Custom::UserPoolClientUpdate
    Condition: UpdateUserPoolClient
    Properties:
      ServiceToken: !GetAtt LambdaEdgeProtection.Outputs.UserPoolClientUpdateHandler
      UserPoolArn: <your arn, or grab from Auth@Edge stack>
      UserPoolClientId: <your client id, or grab from Auth@Edge stack>
      CloudFrontDistributionDomainName: <your CloudFront domain>
      RedirectPathSignIn: "/parseauth"
      RedirectPathSignOut: "/"
      AlternateDomainNames: []
      OAuthScopes: <your scopes>
ottokruse commented 4 years ago

v2.0.1 in the SAR

rpattcorner commented 4 years ago

It must be about a million o'clock in NE -- thanks! I'll test it right away

rpattcorner commented 4 years ago

Brilliant ... as we're in different time zones (I think), I'll give you preliminary results which are:

The only things in cloudtrail I immediately see seem to relate to parseAuth creating logs ...

  "eventTime": "2020-09-07T21:52:50Z",
    "eventSource": "logs.amazonaws.com",
    "eventName": "CreateLogStream",
    "awsRegion": "us-east-1",
    "sourceIPAddress": "3.235.155.143",
    "userAgent": "awslambda-worker/1.0 rusoto/0.42.0 rust/1.45.2 linux",
    "errorCode": "ResourceNotFoundException",
    "errorMessage": "The specified log group does not exist.",
    "requestParameters": {
        "logGroupName": "/aws/lambda/us-east-1.ae201nocustom-LambdaEdgeProtectio-ParseAuthHandler-S6CI0U5A5ZVZ",
        "logStreamName": "2020/09/07/[1]35bbd71f99a34f9a8f1c4b2537020c06"
    },

for scenario 1 (no custom) and

 "eventTime": "2020-09-07T21:55:19Z",
    "eventSource": "logs.amazonaws.com",
    "eventName": "CreateLogStream",
    "awsRegion": "us-east-1",
    "sourceIPAddress": "3.237.173.225",
    "userAgent": "awslambda-worker/1.0 rusoto/0.42.0 rust/1.45.2 linux",
    "errorCode": "ResourceNotFoundException",
    "errorMessage": "The specified log group does not exist.",
    "requestParameters": {
        "logStreamName": "2020/09/07/[1]1b33630fb1d14708be678dc689047831",
        "logGroupName": "/aws/lambda/us-east-1.ae201customb-LambdaEdgeProtection-ParseAuthHandler-OT6ZOK55QK6B"
    }

for scenario 2 (custom resource)

These may be unrelated. I'll dig in more in the AM, but wanted to give you early feedback as this looks very promising

rpattcorner commented 4 years ago

Oh yeah, I think it's something about the log group creation. the URL of the fail says clearly the problematic lambda is parseAuth: https://d8whov6mdjadw.cloudfront.net/parseauth?error_description=invalid_scope&state=eyJub25jZSI6IjE1OTk1MTU1NjRUaHAuQlM5Nm5wRUtuT0RYeiIsInJlcXVlc3RlZFVyaSI6Ii8ifQ&error=invalid_request . When I navigate to CLoudwatch Events I see: image

So maybe something funky with the group creation ... is there somewhere I should have specified a region now that we are no longer tied to us-east-1?

ottokruse commented 4 years ago

The log group error might be a red herring––the real error looks to be "invalid_scope"

How are you passing the scopes to the custom resource? It expects them here as a list of strings (not as a CommaDelimitedList):

UserPoolClientUpdate:
    Type: Custom::UserPoolClientUpdate
    Condition: UpdateUserPoolClient
    Properties:
      ServiceToken: !GetAtt LambdaEdgeProtection.Outputs.UserPoolClientUpdateHandler
      UserPoolArn: <your arn, or grab from Auth@Edge stack>
      UserPoolClientId: <your client id, or grab from Auth@Edge stack>
      CloudFrontDistributionDomainName: <your CloudFront domain>
      RedirectPathSignIn: "/parseauth"
      RedirectPathSignOut: "/"
      AlternateDomainNames: []
      OAuthScopes: ["profile", "openid", "email", "phone", "aws.cognito.signin.user.admin"]

That list needs to be the same as what you passed to Auth@Edge (if you did not pass anything it needs to be the same as the Auth@Edge default). But then it is probably easier to not provide the scopes when invoking the custom resource––and keep the ones from auth@edge. So, this should also work:

UserPoolClientUpdate:
    Type: Custom::UserPoolClientUpdate
    Condition: UpdateUserPoolClient
    Properties:
      ServiceToken: !GetAtt LambdaEdgeProtection.Outputs.UserPoolClientUpdateHandler
      UserPoolArn: <your arn, or grab from Auth@Edge stack>
      UserPoolClientId: <your client id, or grab from Auth@Edge stack>
      CloudFrontDistributionDomainName: <your CloudFront domain>
      RedirectPathSignIn: "/parseauth"
      RedirectPathSignOut: "/"
      AlternateDomainNames: []
rpattcorner commented 4 years ago

Well, not having a lot of luck here.
Originally, I was passing scopes like this to both the UserPoolClient and the UserPoolClientUpdate:

      AllowedOAuthScopes:
        - phone
        - email
        - openid
        - profile

and the stack deployed, but the run failed, as noted. I believe the two formats should be the same ... but apparently not.

Substituting the corrected string format in both the UserPoolClient and the UserPoolClientUpdate allowed for a deploy, but I get the identical error on scope.

Adding the additional scope parameter aws.cognito.signin.user.admin fixed that problem. Seems to allow a user to edit their own profile from this note. Was never needed before..

So, once I got past the scope error I was able to log into Cognito and change my password, but then encountered a new error in parseAuth:

Sign-in issue
We can't sign you in because of a technical problem

 Error: Failed to exchange authorization code for tokens: Error: Request failed with status code 400 [log region: us-east-1]

Here are the two stanzas as they currently are ... note that the parameter names are different which seems to be required ... AllowedOAuthScopes for the UserPoolClient and OAuthScopes for UserPoolClientUpdate. Making them the same fails at deploy.

UserPoolClient:

 UserPoolClient:
    Type: AWS::Cognito::UserPoolClient
    Properties:
      UserPoolId: !Ref UserPool
      PreventUserExistenceErrors: ENABLED
      GenerateSecret: true
      AllowedOAuthScopes: ["profile", "openid", "email", "phone", "aws.cognito.signin.user.admin"]
      AllowedOAuthFlowsUserPoolClient: true
      AllowedOAuthFlows:
        - code
      SupportedIdentityProviders:
        - COGNITO
      CallbackURLs:
        - https://example.com/will-be-replaced
      LogoutURLs:
        - https://example.com/will-be-replaced

UserPoolClientUpdate:

  UserPoolClientUpdate:
    Type: Custom::UserPoolClientUpdate
    Condition: UpdateUserPoolClient
    Properties:
      ServiceToken: !GetAtt LambdaEdgeProtection.Outputs.UserPoolClientUpdateHandler
      UserPoolArn: !GetAtt UserPool.Arn
      UserPoolClientId: !Ref UserPoolClient
      CloudFrontDistributionDomainName: !GetAtt CloudFrontDistribution.DomainName
      RedirectPathSignIn: "/parseauth"
      RedirectPathSignOut: "/"
      AlternateDomainNames: []
      OAuthScopes: ["profile", "openid", "email", "phone", "aws.cognito.signin.user.admin"]

By the way, the OAuthScopes param seems to be required in the UserPoolClientUpdate ... commenting it out leads to a deploy error on the custom resource:

Failed to update resource. InvalidParameterException: AllowedOAuthFlows and AllowedOAuthScopes are required if user pool client is allowed to use OAuth flows. at Request.extractError (/var/runtime/node_modules/aws-sdk/lib/protocol/json.js:51:27) at Request.callListeners (/var/runtime/node_modules/aws-sdk/lib/sequential_executor.js:106:20) at Request.emit (/var/runtime/node_modules/aws-sdk/lib/sequential_executor.js:78:10) at Request.emit (/var/runtime/node_modules/aws-sdk/lib/request.js:688:14) at Request.transition (/var/runtime/node_modules/aws-sdk/lib/request.js:22:10) at AcceptorStateMachine.runTo (/var/runtime/node_modules/aws-sdk/lib/state_machine.js:14:12) at /var/runtime/node_modules/aws-sdk/lib/state_machine.js:26:10 at Request.<anonymous> (/var/runtime/node_modules/aws-sdk/lib/request.js:38:9) at Request.<anonymous> (/var/runtime/node_modules/aws-sdk/lib/request.js:690:12) at Request.callListeners (/var/runtime/node_modules/aws-sdk/lib/sequential_executor.js:1
rpattcorner commented 4 years ago

By the way2 , the scenario 1 (no custom resource, just cloudfront=false and no alternate domain) also fails, this time with an unspecified error after login password entered to cognito: https://auth-3f673eb0-f150-11ea-a7c2-0a3fc8cbcb75.auth.us-east-1.amazoncognito.com/error

ottokruse commented 4 years ago

Mmm :|

Can you paste your entire CFN template here, I'll try to reproduce

rpattcorner commented 4 years ago

Preparing a stripped version as the original accesses buckets, etc.

rpattcorner commented 4 years ago

Edited, simplified. Gives the error Failed to exchange authorization code for tokens: Error: Request failed with status code 400 [log region: us-east-1] that we're working on.


AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: >
  Sample stack for custom user pool

Parameters: 
  AlternateDomainNames:
    Type: CommaDelimitedList
    Description: "Optional custom domain name for the CloudFront distribution.  Must manually point the custom name's A record to CloudFront URL as an alias after deploy."

  ACMCertificate:
    Type: String
    Description: "Only if using an alternate domain name. ARN for a us-east-1 ACM certificate in this account.  Leave blank if no alternate domain name"

  Installer: 
    Type: String
    Description: Who is installing this application, used for tags

  PriceClass:
    Type: String
    Description: CloudFront price class, e.g. PriceClass_200 for most regions (default), PriceClass_All for all regions (the default), PriceClass_100 least expensive (US, Canada, Europe), or PriceClass_All
    Default: PriceClass_100

  SemanticVersion:
    Type: String
    Description: Semantic version of the back end
    Default: 2.0.1

  HttpHeaders:
    Type: String
    Description: The HTTP headers to set on all responses from CloudFront. Defaults are illustrations only and contain a report-only Cloud Security Policy -- adjust for your application
    Default: >-
      {
        "Strict-Transport-Security": "max-age=31536000; includeSubdomains; preload",
        "Referrer-Policy": "same-origin",
        "X-XSS-Protection": "1; mode=block",
        "X-Frame-Options": "DENY",
        "X-Content-Type-Options": "nosniff"
      }

  BucketNameParameter: 
    Type: String
    Description: A legal bucket name.  Must not exist.

Conditions: 
    IsCommercial: !Equals [ 'aws', !Ref "AWS::Partition" ]
    HasAlternateDomainName: !Not [!Equals [ '', !Join [ "", !Ref AlternateDomainNames ] ] ]
    UpdateUserPoolClient: !Equals [ "true", "true" ]

Resources:

  ApplicationBucket:
    Type: AWS::S3::Bucket
    DeletionPolicy: Retain
    Properties:
      BucketName: 
        Ref: BucketNameParameter
      VersioningConfiguration:
         Status: Enabled
      BucketEncryption:
        ServerSideEncryptionConfiguration: 
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: AES256
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
      CorsConfiguration:
        CorsRules:
          -
            AllowedOrigins: 
              - !Sub 
                  - "https://${DomainName}"
                  - {DomainName: !Select [0, !Ref AlternateDomainNames ] }
            AllowedMethods: 
              - POST
              - GET
              - PUT
              - DELETE
              - HEAD
            AllowedHeaders: 
              - "*"
      Tags: 
          - 
            Key: "OWNER"
            Value: "egt-labs"
          - 
            Key: "APPLICATION"
            Value: "jmpr"
          -
            Key: "PATH"
            Value: {"Fn::Join": ["", ["/", {"Ref": "AWS::StackName"}, "-installer/"]]}
          -
            Key: "INSTALLER"
            Value: !Ref Installer

  CloudFrontOriginAccessIdentity:
    Type: AWS::CloudFront::CloudFrontOriginAccessIdentity
    Properties:
      CloudFrontOriginAccessIdentityConfig:
        Comment: !Sub '${BucketNameParameter}-OAI'

  S3AccessPolicyForOAI:
    Type: AWS::S3::BucketPolicy
    Properties: 
      Bucket:
        Ref: ApplicationBucket
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              CanonicalUser:
                Fn::GetAtt: [ CloudFrontOriginAccessIdentity , S3CanonicalUserId ]
            Action: "s3:GetObject"
            Resource: !Sub "${ApplicationBucket.Arn}/*"

  CloudFrontDistribution:
    Type: AWS::CloudFront::Distribution
    Properties:
      DistributionConfig:
        Aliases: 
          !If
            - HasAlternateDomainName
            - !Ref AlternateDomainNames
            - !Ref AWS::NoValue
        ViewerCertificate:
          !If
            - HasAlternateDomainName
            - AcmCertificateArn: !Ref ACMCertificate
              SslSupportMethod: sni-only
              MinimumProtocolVersion: TLSv1.2_2018
            - !Ref AWS::NoValue

        CacheBehaviors:
          - PathPattern: /parseauth
            Compress: true
            ForwardedValues:
              QueryString: true
            LambdaFunctionAssociations:
              - EventType: viewer-request
                LambdaFunctionARN: !GetAtt LambdaEdgeProtection.Outputs.ParseAuthHandler
            TargetOriginId: dummy-origin
            ViewerProtocolPolicy: redirect-to-https
          - PathPattern: /refreshauth
            Compress: true
            ForwardedValues:
              QueryString: true
            LambdaFunctionAssociations:
              - EventType: viewer-request
                LambdaFunctionARN: !GetAtt LambdaEdgeProtection.Outputs.RefreshAuthHandler
            TargetOriginId: dummy-origin
            ViewerProtocolPolicy: redirect-to-https
          - PathPattern: /signout
            Compress: true
            ForwardedValues:
              QueryString: true
            LambdaFunctionAssociations:
              - EventType: viewer-request
                LambdaFunctionARN: !GetAtt LambdaEdgeProtection.Outputs.SignOutHandler
            TargetOriginId: dummy-origin
            ViewerProtocolPolicy: redirect-to-https
        DefaultCacheBehavior:
          Compress: true
          ForwardedValues:
            QueryString: true
          LambdaFunctionAssociations:
            - EventType: viewer-request
              LambdaFunctionARN: !GetAtt LambdaEdgeProtection.Outputs.CheckAuthHandler
            - EventType: origin-response
              LambdaFunctionARN: !GetAtt LambdaEdgeProtection.Outputs.HttpHeadersHandler
          TargetOriginId: protected-origin
          ViewerProtocolPolicy: redirect-to-https
        Enabled: true
        Origins:
          - DomainName: example.org # Dummy origin is used for Lambda@Edge functions, keep this as-is
            Id: dummy-origin
            CustomOriginConfig:
              OriginProtocolPolicy: match-viewer
          - DomainName: !Sub "${ApplicationBucket}.s3.amazonaws.com"
            Id: protected-origin
            S3OriginConfig:
              OriginAccessIdentity: !Sub "origin-access-identity/cloudfront/${CloudFrontOriginAccessIdentity}"
        CustomErrorResponses:
            - ErrorCode: 404
              ResponseCode: 200
              ResponsePagePath: /index.html
        PriceClass: !Ref PriceClass
        DefaultRootObject: index.html
      Tags: 
        - 
          Key: "OWNER"
          Value: "egt-labs"
        - 
          Key: "APPLICATION"
          Value: "jmpr"
        -
          Key: "PATH"
          Value: {"Fn::Join": ["", ["/", {"Ref": "AWS::StackName"}, "-installer/"]]}
        -
          Key: "INSTALLER"
          Value: !Ref Installer

  CognitoIdentityPool:
    Type: "AWS::Cognito::IdentityPool"
    Properties:
      IdentityPoolName: !Sub ${BucketNameParameter}-Identity
      AllowUnauthenticatedIdentities: false
      CognitoIdentityProviders: 
        - ClientId: !Ref UserPoolClient
          # ProviderName: !Sub 'cognito-idp.${AWS::Region}.amazonaws.com/${LambdaEdgeProtection.Outputs.UserPoolId}'
          ProviderName: !Sub
            - cognito-idp.${AWS::Region}.amazonaws.com/${userpool}
            - { userpool: !Ref UserPool }         
          # ProviderName: !Sub 
          #   - 'cognito-idp.${AWS::Region}.amazonaws.com/${userpool}'
          #   - { userpool: !Ref UserPoolClient }
          # ProviderName: !Sub 'cognito-idp.${AWS::Region}.amazonaws.com/${!Ref UserPool}'

  # Create a role for unauthorized acces to AWS resources.
  # Present only for illustration or possible future use.  Not assigned to pool 
  CognitoUnAuthorizedRole:
    Type: "AWS::IAM::Role"
    Properties:
      AssumeRolePolicyDocument: 
        Version: "2012-10-17"
        Statement:
          - Effect: "Allow"
            Principal: 
              Federated: "cognito-identity.amazonaws.com"
            Action: 
              - "sts:AssumeRoleWithWebIdentity"
            Condition:
              StringEquals: 
                "cognito-identity.amazonaws.com:aud": !Ref CognitoIdentityPool
              "ForAnyValue:StringLike":
                "cognito-identity.amazonaws.com:amr": unauthenticated
      Policies:
        - PolicyName: "CognitoUnauthorizedPolicy"
          PolicyDocument: 
            Version: "2012-10-17"
            Statement: 
              - Effect: "Allow"
                Action:
                  - "mobileanalytics:PutEvents"
                  - "cognito-sync:*"
                Resource: "*"
      Tags: 
          - 
            Key: "OWNER"
            Value: "egt-labs"
          - 
            Key: "APPLICATION"
            Value: "jmpr"
          -
            Key: "PATH"
            Value: {"Fn::Join": ["", ["/", {"Ref": "AWS::StackName"}, "-installer/"]]}
          -
            Key: "INSTALLER"
            Value: !Ref Installer

  # Create a role for authorized acces to AWS resources. Control what your user can access. This example only allows Lambda invokation
  # Only allows users in the previously created Identity Pool
  CognitoAuthorizedRole:
    Type: "AWS::IAM::Role"
    Properties:
      AssumeRolePolicyDocument: 
        Version: "2012-10-17"
        Statement:
          - Effect: "Allow"
            Principal: 
              Federated: "cognito-identity.amazonaws.com"
            Action: 
              - "sts:AssumeRoleWithWebIdentity"
            Condition:
              StringEquals: 
                "cognito-identity.amazonaws.com:aud": !Ref CognitoIdentityPool
              "ForAnyValue:StringLike":
                "cognito-identity.amazonaws.com:amr": authenticated
      Policies:
        - PolicyName: "BasicCognito"
          PolicyDocument: 
            Version: "2012-10-17"
            Statement: 
              - Effect: "Allow"
                Action:
                  - "mobileanalytics:PutEvents"
                  - "cognito-sync:*"
                  - "cognito-identity:*"
                Resource: "*"
        - PolicyName: "jmprExecution"
          PolicyDocument: 
            Version: "2012-10-17"
            Statement: 
              - Effect: "Allow"
                Action:
                  - "lambda:InvokeFunction"
                Resource: "*"
        - PolicyName: "jmprS3"
          PolicyDocument: 
            Version: "2012-10-17"
            Statement: 
              - Effect: "Allow"
                Action:
                  - "s3:*"
                Resource: "*"
      Tags: 
          - 
            Key: "OWNER"
            Value: "egt-labs"
          - 
            Key: "APPLICATION"
            Value: "jmpr"
          -
            Key: "PATH"
            Value: {"Fn::Join": ["", ["/", {"Ref": "AWS::StackName"}, "-installer/"]]}
          -
            Key: "INSTALLER"
            Value: !Ref Installer

  # Assigns the roles to the Identity Pool
  IdentityPoolRoleMapping:
    Type: "AWS::Cognito::IdentityPoolRoleAttachment"
    Properties:
      IdentityPoolId: !Ref CognitoIdentityPool
      Roles:
        authenticated: !GetAtt CognitoAuthorizedRole.Arn

  UserPool:
    Type: AWS::Cognito::UserPool
    Properties:
      UserPoolName: !Ref AWS::StackName
      AdminCreateUserConfig:
        AllowAdminCreateUserOnly: true
      UsernameAttributes:
        - email

  UserPoolClient:
    Type: AWS::Cognito::UserPoolClient
    Properties:
      UserPoolId: !Ref UserPool
      PreventUserExistenceErrors: ENABLED
      GenerateSecret: true
      AllowedOAuthScopes: ["profile", "openid", "email", "phone", "aws.cognito.signin.user.admin"]
      AllowedOAuthFlowsUserPoolClient: true
      AllowedOAuthFlows:
        - code
      SupportedIdentityProviders:
        - COGNITO
      CallbackURLs:
        # Ideally you would put your real callback URL's here, pointing to your custom domain name––the custom
        # domain name that you would also supply as AlternateDomainNames to the cloudfront-authorization-at-edge stack below.
        # However, if you just want to use the CloudFront domain name, use the sentinel value as below, to avoid a circular dependency.
        # This sentinal value will be replaced automatically by the cloudfront-authorization-at-edge stack, with the CloudFront domain name
        - https://example.com/will-be-replaced
      LogoutURLs:
        # Ideally you would put your real logout URL's here, pointing to your custom domain name––the custom
        # domain name that you would also supply as AlternateDomainNames to the cloudfront-authorization-at-edge stack below.
        # However, if you just want to use the CloudFront domain name, use the sentinel value as below, to avoid a circular dependency.
        # This sentinal value will be replaced automatically by the cloudfront-authorization-at-edge stack, with the CloudFront domain name
        - https://example.com/will-be-replaced

  UserPoolDomain:
    Type: AWS::Cognito::UserPoolDomain
    Properties:
      Domain: !Sub
        - "auth-${StackIdSuffix}"
        - StackIdSuffix: !Select
            - 2
            - !Split
              - "/"
              - !Ref AWS::StackId
      UserPoolId: !Ref UserPool

  LambdaEdgeProtection:
    Type: AWS::Serverless::Application
    DependsOn: 
      - UserPoolDomain
    Properties:
      Location:
        ApplicationId: arn:aws:serverlessrepo:us-east-1:520945424137:applications/cloudfront-authorization-at-edge
        SemanticVersion: !Ref SemanticVersion
      Parameters:
        CreateCloudFrontDistribution: "false"
        HttpHeaders: !Ref HttpHeaders
        UserPoolArn: !GetAtt UserPool.Arn
        UserPoolClientId: !Ref UserPoolClient

  UserPoolClientUpdate:
    Type: Custom::UserPoolClientUpdate
    Condition: UpdateUserPoolClient
    Properties:
      ServiceToken: !GetAtt LambdaEdgeProtection.Outputs.UserPoolClientUpdateHandler
      UserPoolArn: !GetAtt UserPool.Arn
      UserPoolClientId: !Ref UserPoolClient
      CloudFrontDistributionDomainName: !GetAtt CloudFrontDistribution.DomainName
      RedirectPathSignIn: "/parseauth"
      RedirectPathSignOut: "/"
      AlternateDomainNames: []
      OAuthScopes: ["profile", "openid", "email", "phone", "aws.cognito.signin.user.admin"]
rpattcorner commented 4 years ago

Only stack parms needed are the bucket name and stack name

ottokruse commented 4 years ago

I've found the culprit: in your template change GenerateSecret to false and redeploy and 🎉 it works.

Or, leave it to true, but disable SPA mode then for Auth@Edge: EnableSPAMode = false

I'll update the docs to point this out clearly (and fix the reuse example which was wrong).

Note: I found the cause by setting LogLevel to "debug" for Auth@Edge and checking what happened in parseAuth logs.

rpattcorner commented 4 years ago

Thanks! That's great news! Starting testing now. Some interesting questions:

Testing away, exciting!

ottokruse commented 4 years ago

Updated this example: example-serverless-app-reuse/reuse-with-existing-user-pool.yaml

About your Q's:

It looks like generateSecret is optional and allows the deployer to generate a secret on the client that the SPA can check to avoid impersonators. Am I right in guessing that the a@e doesn't use that feature yet, which is why it fails? It sounds useful at some point, but it's unclear how the SPA would know what to check the generated secret against, unless perhaps the UserPoolClient returned it on deploy as a configuration for the SPA. Is that right? What does it mean to run as EnableSPAMode as false when in fact you're a SPA. I worry about that one

Have a read of this: SPA mode or Static Site mode?

For SPAs it's not useful to have a Client Secret, as anyone can do "view source" and see it.

Where do you set the LogLevel on Auth@Edge? How come you get parseAuth logs and I just get the complaint about no log group?

It's a param to the app, just as CreateCloudFrontDistribution and EnableSPAMode: https://github.com/aws-samples/cloudfront-authorization-at-edge/blob/78867bbe30ddc83fae4fc98f7766e2089f3c95d7/template.yaml#L122-L131

To find the logs you have to go through hoops a bit, as Lambda@Edge logs get their own special log groups, in the region where they end up running (the "normal" log group of the Lambda would not show anything). Easiest way to find them is to go to the CloudFront monitoring dashboard, find the function invocations and jump to the log in the right region there.

rpattcorner commented 4 years ago

Thanks, makes sense. I took SPA mode too literally I think. For whatever reason I do not see the updates to example-serverless-app-reuse/reuse-with-existing-user-pool.yaml in the master branch.

Continuing testing

There may be an issue on the 3rd test, which is the app without custom resources and a manual update to the Application Client callback URLs. Will pursue and get back

rpattcorner commented 4 years ago

Still an issue on the final test. Works like this:

This doesn't look like it's coming from the lambdas, rather from Cognito This scenario works in the 1.2 version Not critical for us now that we have the custom resource but might be worth a look

ottokruse commented 4 years ago

I can't reproduce this error. For me deploying the below template, and after deployment changing the redirect URL's, works fine.

So what is different in your case?

# Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: MIT-0

AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: >
  TODO

Parameters:
  EnableSPAMode:
    Type: String
    Description: Set to 'false' to disable SPA-specific features (i.e. when deploying a static site that won't interact with logout/refresh)
    Default: "true"
    AllowedValues:
      - "true"
      - "false"
  OAuthScopes:
    Type: CommaDelimitedList
    Description: The OAuth scopes to request the User Pool to add to the access token JWT
    Default: "phone, email, profile, openid, aws.cognito.signin.user.admin"
  PriceClass:
    Type: String
    Description: The price class of the CloudFront distribution
    Default: PriceClass_100

Conditions:
  GenerateClientSecret: !Equals
    - EnableSPAMode
    - "false"

Resources:
  UserPool:
    Type: AWS::Cognito::UserPool
    Properties:
      UserPoolName: !Ref AWS::StackName
      AdminCreateUserConfig:
        AllowAdminCreateUserOnly: true
      UsernameAttributes:
        - email
  UserPoolClient:
    Type: AWS::Cognito::UserPoolClient
    Properties:
      UserPoolId: !Ref UserPool
      PreventUserExistenceErrors: ENABLED
      GenerateSecret: !If
        - GenerateClientSecret
        - true
        - false
      AllowedOAuthScopes: !Ref OAuthScopes
      AllowedOAuthFlowsUserPoolClient: true
      AllowedOAuthFlows:
        - code
      SupportedIdentityProviders:
        - COGNITO
      CallbackURLs:
        # Replace
        - https://example.com/will-be-replaced
      LogoutURLs:
        # Replace
        - https://example.com/will-be-replaced
  UserPoolDomain:
    Type: AWS::Cognito::UserPoolDomain
    Properties:
      Domain: !Sub
        - "auth-${StackIdSuffix}"
        - StackIdSuffix: !Select
            - 2
            - !Split
              - "/"
              - !Ref AWS::StackId
      UserPoolId: !Ref UserPool
  ApplicationBucket:
    Type: AWS::S3::Bucket
  CloudFrontOriginAccessIdentity:
    Type: AWS::CloudFront::CloudFrontOriginAccessIdentity
    Properties:
      CloudFrontOriginAccessIdentityConfig:
        Comment: !Sub "${ApplicationBucket}-OAI"
  S3AccessPolicyForOAI:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket:
        Ref: ApplicationBucket
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              CanonicalUser:
                Fn::GetAtt: [CloudFrontOriginAccessIdentity, S3CanonicalUserId]
            Action: "s3:GetObject"
            Resource: !Sub "${ApplicationBucket.Arn}/*"
          - Effect: "Allow"
            Principal:
              CanonicalUser:
                Fn::GetAtt: [CloudFrontOriginAccessIdentity, S3CanonicalUserId]
            Action: "s3:ListBucket"
            Resource: !GetAtt ApplicationBucket.Arn
  CloudFrontDistribution:
    Type: AWS::CloudFront::Distribution
    Properties:
      DistributionConfig:
        # Aliases: <your alternate domain names, also pass these to the serverless stack below>
        # ViewerCertificate: <the config for your HTTPS certificate>
        CacheBehaviors:
          - PathPattern: /parseauth
            Compress: true
            ForwardedValues:
              QueryString: true
            LambdaFunctionAssociations:
              - EventType: viewer-request
                LambdaFunctionARN: !GetAtt MyLambdaEdgeProtectedSpaSetup.Outputs.ParseAuthHandler
            TargetOriginId: dummy-origin
            ViewerProtocolPolicy: redirect-to-https
          - PathPattern: /refreshauth
            Compress: true
            ForwardedValues:
              QueryString: true
            LambdaFunctionAssociations:
              - EventType: viewer-request
                LambdaFunctionARN: !GetAtt MyLambdaEdgeProtectedSpaSetup.Outputs.RefreshAuthHandler
            TargetOriginId: dummy-origin
            ViewerProtocolPolicy: redirect-to-https
          - PathPattern: /signout
            Compress: true
            ForwardedValues:
              QueryString: true
            LambdaFunctionAssociations:
              - EventType: viewer-request
                LambdaFunctionARN: !GetAtt MyLambdaEdgeProtectedSpaSetup.Outputs.SignOutHandler
            TargetOriginId: dummy-origin
            ViewerProtocolPolicy: redirect-to-https
        DefaultCacheBehavior:
          Compress: true
          ForwardedValues:
            QueryString: true
          LambdaFunctionAssociations:
            - EventType: viewer-request
              LambdaFunctionARN: !GetAtt MyLambdaEdgeProtectedSpaSetup.Outputs.CheckAuthHandler
            - EventType: origin-response
              LambdaFunctionARN: !GetAtt MyLambdaEdgeProtectedSpaSetup.Outputs.HttpHeadersHandler
          TargetOriginId: protected-origin
          ViewerProtocolPolicy: redirect-to-https
        Enabled: true
        Origins:
          - DomainName: example.org # Dummy origin is used for Lambda@Edge functions, keep this as-is
            Id: dummy-origin
            CustomOriginConfig:
              OriginProtocolPolicy: match-viewer
          - DomainName: !Sub "${ApplicationBucket}.s3.amazonaws.com"
            Id: protected-origin
            S3OriginConfig:
              OriginAccessIdentity: !Sub "origin-access-identity/cloudfront/${CloudFrontOriginAccessIdentity}"
        CustomErrorResponses:
          - ErrorCode: 404
            ResponseCode: 200
            ResponsePagePath: /index.html
        PriceClass: !Ref PriceClass
        DefaultRootObject: index.html
  MyLambdaEdgeProtectedSpaSetup:
    Type: AWS::Serverless::Application
    DependsOn: UserPoolDomain
    Properties:
      Location:
        ApplicationId: arn:aws:serverlessrepo:us-east-1:520945424137:applications/cloudfront-authorization-at-edge
        SemanticVersion: 2.0.1
      Parameters:
        UserPoolArn: !GetAtt UserPool.Arn
        UserPoolClientId: !Ref UserPoolClient
        EnableSPAMode: !Ref EnableSPAMode
        CreateCloudFrontDistribution: false
        OAuthScopes: !Join
          - ","
          - !Ref OAuthScopes
Outputs:
  WebsiteUrl:
    Description: URL of the CloudFront distribution that serves your SPA from S3
    Value: !Sub "https://${CloudFrontDistribution.DomainName}"
rpattcorner commented 4 years ago

Thanks, Otto. I've gone back to that install after doing nothing to it over the weekend, and it is now working! Very peculiar that it stabilized without action. Let's pass over this particular rabbit-hole and I'l raise it again if it becomes an issue. Looks like 2.01 is now a going concern ... will work on some of the cookie injection magic it brings.