Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Migration to 2.0 Problem -- RedirectUrisSignOut #82

Closed
rpattcorner opened this issue Sep 5, 2020 · 26 comments
Closed

Migration to 2.0 Problem -- RedirectUrisSignOut #82

rpattcorner opened this issue Sep 5, 2020 · 26 comments

Comments

@rpattcorner
Copy link
Contributor

rpattcorner commented Sep 5, 2020

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
Copy link
Collaborator

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

  • set parameter CreateCloudFrontDistribution to false
  • AND
  • do not provide a value for parameter AlternateDomainNames

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
Copy link
Contributor Author

rpattcorner commented Sep 7, 2020

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
Copy link
Collaborator

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
Copy link
Contributor Author

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:

  • CloudFront and UserPool related resources can live in main stack
  • a@e will tolerate an absent AlternateDomainName and a false CreateCloudFrontDistribution, and if nothing more is done, manual intervention on the user pool will be required, similar to 1.2. That's the part of the discussion that restores the scenario present in 1.2 but not in 2.0

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

  • In the top level stack a@e is invoked before CloudFront, and yields the outputs CloudFront needs for its behaviours, same as always
  • The a@e lambdas that need to know the URL of the CloudFront application refactor that value into JSON for ease of modification if it isn't there already
  • The existing LambdaCodeUpdateHandler is enhanced to allow modifying the CloudFront URL wherever it appears
  • LambdaCodeUpdateHandler's Arn is made available as an a@e stack output so it can be invoked from the top level stack
  • The top level stack contains a new custom resource dependent on CloudFront that invokes the enhanced LambdaCodeUpdateHandler with the CloudFront URL

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

R.

@ottokruse
Copy link
Collaborator

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
Copy link
Contributor Author

rpattcorner commented Sep 7, 2020

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
Copy link
Collaborator

This should be it: #83

@ottokruse
Copy link
Collaborator

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
Copy link
Collaborator

v2.0.1 in the SAR

@rpattcorner
Copy link
Contributor Author

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

@rpattcorner
Copy link
Contributor Author

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

  • My two test scenarios build and deploy. The first use case has no custom resource and I adjust the Cognito URIs by hand, the second uses the callback.
  • Both build and complete without error after a little fiddling on my side on the top level CFN
  • Both give an error from the lambdas that I don't understand at this point:
    Error: [Cognito] invalid_request: invalid_scope [log region: us-east-1]
    image

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
Copy link
Contributor Author

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
Copy link
Collaborator

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
Copy link
Contributor Author

rpattcorner commented Sep 8, 2020

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
Copy link
Contributor Author

rpattcorner commented Sep 8, 2020

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
Copy link
Collaborator

Mmm :|

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

@rpattcorner
Copy link
Contributor Author

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

@rpattcorner
Copy link
Contributor Author

rpattcorner commented Sep 8, 2020

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
Copy link
Contributor Author

Only stack parms needed are the bucket name and stack name

@ottokruse
Copy link
Collaborator

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
Copy link
Contributor Author

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

  • 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
  • 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?

Testing away, exciting!

@ottokruse
Copy link
Collaborator

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:

LogLevel:
Type: String
Description: "Use for development: setting to a value other than none turns on logging at that level. Warning! This will log sensitive data, use for development only"
Default: "none"
AllowedValues:
- "none"
- "info"
- "warn"
- "error"
- "debug"

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
Copy link
Contributor Author

rpattcorner commented Sep 9, 2020

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

  • Initial indications on template above with fix are positive
  • Secondary test on our actual app with the custom resource are positive

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
Copy link
Contributor Author

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
Copy link
Collaborator

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?

  • Check your browser's address bar––Cognito puts error message (like "redirect mismatch") there sometimes.
  • Check that your user pool has a value (either false or true) for "AllowAdminCreateUserOnly" (even though it is not a required field according to CloudFormation docs, I've seen it be an issue if not specified)
# 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
Copy link
Contributor Author

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants