amazon-web-servicesjwtauthorizationamazon-cognitoaws-http-api

Why does my login from the cognito hosted ui fail (using jwt auhtorizer)?


What I'm trying to achieve:

So far so good, the login itself works and directly redirects with a code to the desired target: https://private.mydomain.com/showMovies?code=e0c9d56c-b55b-4e24-b868-6e74e2c5fad2

But, the only thing i get is: {"message":"Unauthorized"}

I thought enabling access logging might enlighten me, but it didn't:

{
    "requestId": "BbPBXgtNliAEM9Q=",
    "ip": "1234568",
    "requestTime": "07/Mar/2023:19:35:36 +0000",
    "httpMethod": "GET",
    "routeKey": "GET /showMovies",
    "status": "401",
    "protocol": "HTTP/1.1",
    "responseLength": "26"
}

What did I do, to come at least that far:

I was quite lucky to find a step by step instruction for what I wanted to do: https://www.workfall.com/learning/blog/control-access-to-an-http-api-using-jwt-authorizers-via-amazon-cognito/

But I wasn't that lucky because I didn't want to do it like that, I wanted to use the cdk ... which took me a while ;)

Here is my code. Normaly I wouldn't put all this stuff into one object, but for asking about it, its quite good. Normaly I would try only to provide meaningfull snippets but I fear to skip the important clue.

I have no idea what is wrong :/ I double checked the options/settings created by my stack with screenshots from the link above multiple times, but I'm not able to find a meaningfull difference.

I have no idea how to do further debugging, as activating access logging didn't provide further information.

I hope it is just one stupid flag or wrong exchanged information between the userpool and the authorizer and someone sees it. I have allready spent hours with trial and error playing arround with different flags, options and informations :(

import {
  Stack,
  StackProps,
  CfnOutput,
  RemovalPolicy,
  Duration,
} from "aws-cdk-lib";
import { Function, Runtime, Code } from "aws-cdk-lib/aws-lambda";
import { Bucket } from "aws-cdk-lib/aws-s3";
import { BucketDeployment, Source } from "aws-cdk-lib/aws-s3-deployment";
import { Construct } from "constructs";
import { ARecord, HostedZone, RecordTarget } from "aws-cdk-lib/aws-route53";
import {
  Certificate,
  CertificateValidation,
} from "aws-cdk-lib/aws-certificatemanager";
import { GlobalConfig } from "../config/config";
import {
  DomainName,
  HttpApi,
  HttpMethod,
} from "@aws-cdk/aws-apigatewayv2-alpha";
import { ApiGatewayv2DomainProperties } from "aws-cdk-lib/aws-route53-targets";
import { HttpLambdaIntegration } from "@aws-cdk/aws-apigatewayv2-integrations-alpha";
import { AccountRecovery, OAuthScope, UserPool } from "aws-cdk-lib/aws-cognito";
import { HttpJwtAuthorizer } from "@aws-cdk/aws-apigatewayv2-authorizers-alpha";

export class WebPagesStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    // offer public accessible static content like css and jquery
    const publicStaticContentBucket = new Bucket(
      this,
      "static-content-bucket",
      {
        publicReadAccess: true,
        removalPolicy: RemovalPolicy.DESTROY,
        autoDeleteObjects: true,
        versioned: false,
        enforceSSL: true,
      }
    );

    new BucketDeployment(this, "static-content-deployment", {
      sources: [Source.asset("static")],
      destinationBucket: publicStaticContentBucket,
    });

    // get the bucket url with the static content to use it within the lambda
    let publicStaticContentBucketUrl = "https://";
    publicStaticContentBucketUrl = publicStaticContentBucketUrl.concat(
      publicStaticContentBucket.bucketRegionalDomainName
    );

    // lambda returning the html-body
    const showMoviesLambda = new Function(this, "show-movies-lambda", {
      runtime: Runtime.PYTHON_3_9,
      code: Code.fromAsset(GlobalConfig.lambdaDir),
      handler: "movies_show.handler",
      environment: {
        STATIC_CONTENT_BASE_URL: publicStaticContentBucketUrl,
      },
    });

    // creating a subdomain from an existing domain for this purpose
    const subDomainName = "private." + GlobalConfig.domainName;

    const hostedZone = HostedZone.fromLookup(this, "private-hosted-zone", {
      domainName: GlobalConfig.domainName,
    });

    const subPageCertificate = new Certificate(
      this,
      "private-subdomain-certificate",
      {
        domainName: subDomainName,
        validation: CertificateValidation.fromDns(hostedZone),
      }
    );

    const subDomain = new DomainName(this, "private-domain", {
      domainName: subDomainName,
      certificate: subPageCertificate,
    });

    new ARecord(this, "private-subdomain-a-record", {
      recordName: subDomainName,
      zone: hostedZone,
      target: RecordTarget.fromAlias(
        new ApiGatewayv2DomainProperties(
          subDomain.regionalDomainName,
          subDomain.regionalHostedZoneId
        )
      ),
    });

    // map the new subdomain to a new http-api
    const privateHttpApi = new HttpApi(this, "private-http-api", {
      description: "only for me",
      defaultDomainMapping: {
        domainName: subDomain,
      },
      apiName: "private-http-api",
    });

    privateHttpApi.applyRemovalPolicy(RemovalPolicy.DESTROY);

    // creating a user-pool, users are to be created using aws console
    const user_pool = new UserPool(this, "private-user-pool", {
      userPoolName: "private-user-pool",
      selfSignUpEnabled: false,
      accountRecovery: AccountRecovery.NONE,
      autoVerify: { email: true },
      signInAliases: { email: true },
      signInCaseSensitive: false,
      standardAttributes: {
        email: {
          required: true,
          mutable: true,
        },
        preferredUsername: {
          required: false,
          mutable: true,
        },
      },
      passwordPolicy: {
        minLength: 8,
        requireLowercase: true,
        requireUppercase: true,
        requireDigits: true,
        requireSymbols: false,
        tempPasswordValidity: Duration.days(7),
      },
      removalPolicy: RemovalPolicy.DESTROY,
    });

    /* already define the route path here to:
       - be redirected to, after log in
       - to be the target for the lambda
       - to attach the authorizer to
    */
    const routePath = "/showMovies";

    /*
     * Now: nearly everything "on"
     * Todo: Remove unnneeded, when it finaly works
     * especially callbackUrl isn't used (or is it)?
     */
    const user_pool_app_client = user_pool.addClient(
      "private-user-pool-app-client",
      {
        accessTokenValidity: Duration.days(1),
        authFlows: {
          adminUserPassword: true,
          custom: true,
          userPassword: true,
          userSrp: true,
        },
        authSessionValidity: Duration.minutes(15),
        disableOAuth: false,
        oAuth: {
          flows: {
            authorizationCodeGrant: true,
            implicitCodeGrant: true,
          },
          callbackUrls: ["https://" + subDomainName + routePath],
          scopes: [
            OAuthScope.COGNITO_ADMIN,
            OAuthScope.EMAIL,
            OAuthScope.OPENID,
            OAuthScope.PHONE,
            OAuthScope.PROFILE,
          ],
        },
        preventUserExistenceErrors: true,
      }
    );

    /*
     * - just used to set the redirect uri after login?
     * - domainPrefix not needed, but doesn't hurt neither?
     */
    const user_pool_domain = user_pool.addDomain("private-user-pool-domain", {
      cognitoDomain: {
        domainPrefix: "private",
      },
    });

    user_pool_domain.signInUrl(user_pool_app_client, {
      redirectUri: "https://" + subDomainName + routePath,
    });

    user_pool_domain.applyRemovalPolicy(RemovalPolicy.DESTROY);

    new CfnOutput(this, "private-user-pool-domain", {
      value: user_pool.userPoolProviderUrl,
    });

    // the authorizer
    const authorizer = new HttpJwtAuthorizer(
      "private-show_movies-auhtorizer",
      user_pool.userPoolProviderUrl,
      {
        jwtAudience: [user_pool_app_client.userPoolClientId],
      }
    );

    // finally glueing: path, method, lambda & authorizer
    privateHttpApi.addRoutes({
      path: routePath,
      methods: [HttpMethod.GET],
      integration: new HttpLambdaIntegration(
        "show-movies-lambda-integration",
        showMoviesLambda
      ),
      authorizer: authorizer,
    });

    new CfnOutput(this, "static-content-bucket-url-output", {
      value: publicStaticContentBucketUrl,
      description: "Oeffentliche URL fuer statischen Content",
      exportName: "staticContentBucketUrl",
    });
  }
}


Solution

  • Probably the main problem here is that the article that you are following is a bit confusing, specially the last part, where they "test" the solution. The article "as is" doesn't work. Why? Because they are protecting the API through a JWT Authorizer with identity source $request.header.Authorization. So, in order to call that protected API, you need to pass a bearer token in the Authorization header of the HTTP request. The problem is that later in the article they never show how to do that (they mention it, but they don't show it). They show how to invoke the Cognito Hosted UI and after the (Oauth) authentication code flow, you are redirected to the app with a code in the query string - that is actually a code that (the application) must use to later exchange it for a token (that then you can use in the Authorization header to call your API).

    Assuming you are building a single page application (SPA) and hosting your code in S3, and your APIs in API Gateway > Lambda, one way to approach that scenario is to use the Amplify SDK to integrate your SPA with Cognito (you don't necessarily need to host your app in Amplify, you can just use the SDK to simplify the authentication flow, and host your SPA in S3). Taken from https://aws.amazon.com/blogs/security/how-to-set-up-amazon-cognito-for-federated-authentication-using-azure-ad/ :

    One way to add secure authentication using Amazon Cognito into a single page application (SPA) is to use the Auth.federatedSignIn() method of Auth class from AWS Amplify. AWS Amplify provides SDKs to integrate your web or mobile app with a growing list of AWS services, including integration with Amazon Cognito user pool. The federatedSign() method will render the hosted UI that gives users the option to sign in with the identity providers that you enabled on the app client (in Step 4), as shown in Figure 8. One advantage of hosted UI is that you don’t have to write any code for rendering it. Additionally, it will transparently implement the Authorization code grant with PKCE and securely provide your client-side application with the tokens (ID, Access and Refresh) that are required to access the backend APIs.

    For a sample web application and instructions to connect it with Amazon Cognito authentication, see the aws-amplify-oidc-federation GitHub repository.