Search code examples
aws-api-gatewayamazon-cognitoaws-cdk

How to authenticate React website with API using AWS Cognito with OAuth2 authentication


I want to control access to a public AWS API, meaning only my website and mobile app can consume this API, otherwise the API will reject the requests.

My approach is to secure the API using AWS Cognito with OAuth2 scopes, see more details here. The website won't need user registration, so I use cognito just to secure the API.

So far, I'm able to go to Hosted UI under App client settings in AWS Cognito. Click the link there to login, and get a "code": https://example.com/?code=f27b2de0-1111-1111-1111-11111111. And then follow How to use the code returned from Cognito to get AWS credentials? to get id_token. And finally send a HTTP request with header key=Authorization and value=<id_token>. This works for me. I see that the API rejects requests when no valid token, and returns expected results when valid token is present.

However, I have some questions:

  1. The token expires in 30 days(I configured it to be so), and my website is a reactJS app, how can the website refresh the token? My website should not care any login here, it just need to send a valid ID Token as the Authorization header to access the API. Users should not see any login page. Should I use Cognito SDK amazon-cognito-identity-js in my react app to fetch the ID token?
  2. Do I need setup callback url here? All I need is ensure that the API will reject the request if token is missing or invalid, I don't know what's the purpose of having callback url in this case.

Feel free to point out any other mistakes I made here.

Sample code

Here are my CDK code to setup API + Cognito.

import * as CDK from "aws-cdk-lib";

import * as CertificateManager from "aws-cdk-lib/aws-certificatemanager";
import * as Route53 from "aws-cdk-lib/aws-route53";
import * as Route53Targets from "aws-cdk-lib/aws-route53-targets";
import * as ApiGateway from "aws-cdk-lib/aws-apigateway";

import * as ELBv2 from "aws-cdk-lib/aws-elasticloadbalancingv2";
import { Construct } from "constructs";
import { StageInfo } from "../config/stage-config";
import * as Cognito from "aws-cdk-lib/aws-cognito";

export interface ApigatewayStackProps extends CDK.StackProps {
  readonly packageName: string;
  readonly stageInfo: StageInfo;
}

export class ApigatewayStack extends CDK.Stack {
  // Prefix for CDK constrcut ID
  private readonly constructIdPrefix: string;
  private readonly pandaApiCognitoUserPool: Cognito.UserPool;
  private readonly domainCertificate: CertificateManager.Certificate;
  private readonly apiAuthorizer: ApiGateway.CfnAuthorizer;
  private readonly pandaApi: ApiGateway.RestApi;
  constructor(scope: Construct, id: string, props: ApigatewayStackProps) {
    super(scope, id, props);

    this.constructIdPrefix = `${props.packageName}-${props.stageInfo.stageName}`;

    const hostedZone: Route53.IHostedZone = Route53.HostedZone.fromLookup(
      this,
      `${this.constructIdPrefix}-HostedZoneLookup`,
      {
        domainName: props.stageInfo.domainName,
      }
    );
    this.domainCertificate = new CertificateManager.Certificate(
      this,
      `${this.constructIdPrefix}-pandaApiCertificate`,
      {
        domainName: props.stageInfo.domainName,
        validation:
          CertificateManager.CertificateValidation.fromDns(hostedZone),
      }
    );

    this.pandaApi = new ApiGateway.RestApi(
      this,
      `${this.constructIdPrefix}-pandaApi`,
      {
        description: "The centralized API for panda.com",
        domainName: {
          domainName: props.stageInfo.domainName,
          certificate: this.domainCertificate,
          //mappingKey: props.pipelineStageInfo.stageName
        },

        defaultCorsPreflightOptions: {
          allowOrigins: ApiGateway.Cors.ALL_ORIGINS,
          allowMethods: [...ApiGateway.Cors.DEFAULT_HEADERS],
        },
      }
    );

    new Route53.ARecord(this, "AliasRecord", {
      zone: hostedZone,
      target: Route53.RecordTarget.fromAlias(
        new Route53Targets.ApiGateway(this.pandaApi)
      ),
      // or - route53.RecordTarget.fromAlias(new alias.ApiGatewayDomain(domainName)),
    });

    this.pandaApiCognitoUserPool = new Cognito.UserPool(this, "UserPool", {
      userPoolName: `pandaApiUserPool`,
      selfSignUpEnabled: false,
    });

    this.apiAuthorizer = new ApiGateway.CfnAuthorizer(
      this,
      `${this.constructIdPrefix}-pandaApiAuthorizer`,
      {
        name: "pandaApiAuthorizer",
        type: ApiGateway.AuthorizationType.COGNITO,
        identitySource: "method.request.header.Authorization",
        restApiId: this.pandaApi.restApiId,
        providerArns: [this.pandaApiCognitoUserPool.userPoolArn],
      }
    );

    this.addCognitoAuthentication(props);
  }

  private addCognitoAuthentication(props: ApigatewayStackProps) {
    this.pandaApiCognitoUserPool.addDomain("DomainName", {
      cognitoDomain: {
        domainPrefix: `panda-api-user-pool-${props.stageInfo.stageName.toLocaleLowerCase()}`,
      },
      
    });

    this.pandaApiCognitoUserPool.addClient(
      `${this.constructIdPrefix}-pandaApiUserPoolClient`,
      {
        userPoolClientName: `pandaApiUserPoolClient`,
        generateSecret: true,
        oAuth: {
          flows: {
            // It's highly recommend to use only the Authorization code grant flow.
            // https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-app-idp-settings.html
            authorizationCodeGrant: true,
          },
          scopes: [Cognito.OAuthScope.OPENID],
          //callbackUrls: [props.stageInfo.domainName + '/callback']
        },
        authFlows: {
          userPassword: true,
        },

        refreshTokenValidity: CDK.Duration.days(30),
      }
    );
  }

}


Solution

  • Cognito is for logging your users in and providing you an access token and id token as a result. Your users must log in. If they don't log in then you can't use cognito to secure you API. Further if you don't require log in, then anyone in the world can call your API.

    There is no way to ensure only your website and your mobile app can talk to your API, this isn't how APIs work. APIs are available to the whole world, they are all public, you can't block requests to them, unless your users individually authenticate.

    The reason for this is that any one can spoof your website or mobile application, and because of that no security mechanisms exist that would enable restricting access.

    So either your API is world-writable/readable or you require your users to log in. The good news is there are tons of ways to easily do this, now that logs of AuthN providers support webauthn/fido2/passkeys out of the box.

    you can probably google around for a list of providers that offer exactly what you need, but a starter list is something like this one on how to pick the best auth provider.