Authorizing API Gateway against a Cognito federated identity pool


#1

I am trying to get an API Gateway/Lambda web application (python flask with serverless-wsgi) to use a Cognito federated identity pool to authenticate/authorize web clients. The underlying requirement is to get a number of apps to permit access only to authorized Salesforce or Office365 users; and I don’t want to write OpenID Connect code in each application, but want to outsource this pain to Cognito.

So I created a Salesforce identity provider in IAM, and a Cognito identity pool linked to this, following this guide. (Aside: unclear as to what the callback URL should be, but I just used my app URL with /callback appended for now)

And I added to serverless.yml:

  web:
    handler: wsgi.handler
    events:
      - http:
          path: /
          method: ANY
          authorizer:
            arn: arn:aws:cognito-identity:eu-west-1:XXXX:identitypool/eu-west-1:YYYY
      - http:
          path: "{proxy+}"
          method: ANY
          authorizer:
            arn: arn:aws:cognito-identity:eu-west-1:XXXX:identitypool/eu-west-1:YYYY

This is based on the example here for an existing Cognito User Pool, although I realise that a Cognito Identity Pool is different (nice explanation here)

Now when I do sls deploy I get this error:

Serverless: Checking Stack update progress...
...........
Serverless: Operation failed!

  Serverless Error ---------------------------------------

  An error occurred: A3e1b0cdac43LambdaPermissionApiGateway - Unable to parse HTTP response content.

If I do SLS_DEBUG="*" sls deploy then I get a slightly different error:

Serverless: Checking Stack update progress...
.........
Serverless: Operation failed!

  Serverless Error ---------------------------------------

  An error occurred: A3e1b0cdac43ApiGatewayAuthorizer - Invalid lambda function.

  Stack Trace --------------------------------------------

ServerlessError: An error occurred: A3e1b0cdac43ApiGatewayAuthorizer - Invalid lambda function.
    at provider.request.then (/usr/lib/node_modules/serverless/lib/plugins/aws/lib/monitorStack.js:112:33)
From previous event:
    at AwsDeploy.monitorStack (/usr/lib/node_modules/serverless/lib/plugins/aws/lib/monitorStack.js:26:12)
    at provider.request.then (/usr/lib/node_modules/serverless/lib/plugins/aws/lib/updateStack.js:84:30)
From previous event:
    at AwsDeploy.update (/usr/lib/node_modules/serverless/lib/plugins/aws/lib/updateStack.js:84:8)
From previous event:
    at AwsDeploy.BbPromise.bind.then (/usr/lib/node_modules/serverless/lib/plugins/aws/lib/updateStack.js:101:12)
From previous event:
    at AwsDeploy.updateStack (/usr/lib/node_modules/serverless/lib/plugins/aws/lib/updateStack.js:95:8)
From previous event:
    at AwsDeploy.BbPromise.bind.then (/usr/lib/node_modules/serverless/lib/plugins/aws/deploy/index.js:135:39)
From previous event:
    at Object.aws:deploy:deploy:updateStack [as hook] (/usr/lib/node_modules/serverless/lib/plugins/aws/deploy/index.js:131:10)
    at BbPromise.reduce (/usr/lib/node_modules/serverless/lib/classes/PluginManager.js:372:55)
From previous event:
    at PluginManager.invoke (/usr/lib/node_modules/serverless/lib/classes/PluginManager.js:372:22)
    at PluginManager.spawn (/usr/lib/node_modules/serverless/lib/classes/PluginManager.js:390:17)
    at AwsDeploy.BbPromise.bind.then (/usr/lib/node_modules/serverless/lib/plugins/aws/deploy/index.js:101:48)
From previous event:
    at Object.deploy:deploy [as hook] (/usr/lib/node_modules/serverless/lib/plugins/aws/deploy/index.js:97:10)
    at BbPromise.reduce (/usr/lib/node_modules/serverless/lib/classes/PluginManager.js:372:55)
From previous event:
    at PluginManager.invoke (/usr/lib/node_modules/serverless/lib/classes/PluginManager.js:372:22)
    at PluginManager.run (/usr/lib/node_modules/serverless/lib/classes/PluginManager.js:403:17)
    at variables.populateService.then (/usr/lib/node_modules/serverless/lib/Serverless.js:102:33)
    at runCallback (timers.js:672:20)
    at tryOnImmediate (timers.js:645:5)
    at processImmediate [as _immediateCallback] (timers.js:617:5)
From previous event:
    at Serverless.run (/usr/lib/node_modules/serverless/lib/Serverless.js:89:74)
    at serverless.init.then (/usr/lib/node_modules/serverless/bin/serverless:42:50)

If I look in .serverless/cloudformation-template-update-stack.json then it looks like it’s trying to use a custom lambda authorizer (although I really don’t grok cloudformation)

"A3e1b0cdac43ApiGatewayAuthorizer": {
  "Type": "AWS::ApiGateway::Authorizer",
  "Properties": {
    "AuthorizerResultTtlInSeconds": 300,
    "IdentitySource": "method.request.header.Authorization",
    "Name": "a3e1b0cdac43",
    "RestApiId": {
      "Ref": "ApiGatewayRestApi"
    },
    "AuthorizerUri": {
      "Fn::Join": [
        "",
        [
          "arn:aws:apigateway:",
          {
            "Ref": "AWS::Region"
          },
          ":lambda:path/2015-03-31/functions/",
          "arn:aws:cognito-identity:eu-west-1:XXXX:identitypool/eu-west-1:YYYY",
          "/invocations"
        ]
      ]
    },
    "Type": "TOKEN"
  }
},

So my problems are:

  1. Is it even possible to use API Gateway with Cognito federated Identity Pool as an authorizer?

    The API gateway documentation mentions user pools, but I can see no mention of identity pools.

    However this AWS post suggests it’s possible. It looks like the OpenID token is somehow exchanged for an IAM token, in which case maybe I can use aws_iam as the authorizer.

    This post shows an OpenID identity pool. The app itself still needs to have its own login and callback pages which understand OpenID exchanges. I was hoping to make it as transparent to the app itself as possible, but I can give them a special login page if required.

  2. If it is possible, does serverless support this, and if so how do I configure it?

Thanks… Brian.


#2

Update: this page is the closest I can find; it describes an angular js app which authenticates three different resources. The /google and /cip resources are authenticated against a federated identity pool, and the /google resource is authorized only to people who authenticated via the Google identity provider within that pool.

The code for this application is published, but I’m lost in the cloudformation template.

Note: I don’t necessarily want to create the identity pool from serverless; having a single pre-existing identity pool which is shared by many apps would in fact be better.

But I will need, somehow, to create permissions such that only authenticated users can access each app. Preferably this will be further restricted for authorization, e.g. only to users in a particular group, although as long as the claims are passed through to the app I can check it there.

My head is starting to explode :slight_smile:


#3

Oh, and just a bit of background. Right now the way I solve this is to have a couple of EC2 instances running Apache, mod_proxy and mod_auth_openidc.

Users hit the proxy URL, and mod_auth_openidc transparently redirects them to the OpenIDC provider if necessary; only when they’ve authenticated does it proxy the request through to API Gateway. It can also enforce authorisation, e.g. specific organisation claim.

The great thing about this is that it’s completely transparent to the Lambda/Flask application. The only code needed in Lambda is to check the source IP address of the connection - to check that the request is coming from the proxy, so that people can’t bypass the authentication by hitting the API gateway URL directly.

With AWS able to attach custom domains to API gateway instances, and ACS issuing the certificates for those custom domains, I thought it would be much cleaner if I could get rid of the proxy VMs and have people connect directly to API gateway. But that means doing the full OpenID authentication/authorisation in API gateway too.

Right now it looks like the web app would need to serve a bunch of Javascript which handles the entire flow:

  1. Direct the user to the identity provider(s) to pick up credentials (IDP-specific?)
  2. Pass these credentials to Cognito to get an IAM token (this? and/or this?)
  3. Include the IAM token for each subsequent web access
  4. Deal with expired tokens

… plus configure IAM roles to perform the authorisation to specific API gateway URLs.


#4

Yes it is possible to use Federated identities. This is a simple way to do it:

  • User requests a token directly to the provider (i.e. Google)

  • User sends the token to my public APIG endpoint:

    - http:
        path: auth/signin/google
        method: GET
        integration: lambda-proxy
        cors:
          origin: '*'
          allowCredentials: false
    
  • On Lambda:

    • Verify the token received and extract the user details (email, name…)
    • Get the user identityId
    • Get user credentials with getCredentialsForIdentity
    • Return credentials to the user
  • From now, user has to sign requests from the client using those credentials to endpoints like this:

    - http:
        path: /myendpoint
        method: GET
        authorizer: aws_iam
        cors:
          origin: '*'
          allowCredentials: false
          headers:
            - Content-Type
            - Authorization
            - X-Amz-Date
            - X-Api-Key
            - X-Amz-Security-Token
            - X-Amz-User-Agent
    

You can also take a look at this serverless auth boilerplate


#5

Thank you. Can I just clarify a few points: my scenario is a Flask web app running in Lambda, and user has a regular web browser accessing this app, just following links in HTML.

User requests a token directly to the provider (i.e. Google)

If this takes place in the browser, presumably this means my Flask app has to return a page containing Javascript for the user to request the token - or else the Flask app returns a HTTP redirect pointing the user directly to Google.

I’m guessing that the callback URL I should use when requesting the token is the auth/signin/google service you mentioned (which I need to provide as part of my Flask app)

Verify the token

Does this mean the Flask app needs to get involved in JWT decoding and signature verification? This is similar to what I’d have to do to use OpenID Connect without Cognito.

Return credentials to the user

In a cookie? Otherwise how else will the browser retain them for the next request?

From now, user has to sign requests from the client using those credentials to endpoints like this:

This part I don’t understand. How can I make the client’s web browser send headers like X-Api-Key and X-Amz-* with every request? Does every page request have to come from Javascript XmlHttpRequest, rather than just letting the browser follow links? If so this would be a complete rewrite of the app, using a client-side framework like sammy or angular.

The way I handle it at the moment is:

  1. Flask app runs in API Gateway (using serverless-wsgi)
  2. I have an Apache reverse proxy running in EC2
  3. Reverse proxy runs mod_auth_openidc
  4. Flask app has a small wrapper that blocks all requests unless the source IP is the proxy’s public IP:
def lambda_app(environ, start_response):
    if environ["REMOTE_ADDR"] not in TRUSTED_SOURCES:
        start_response("403 Forbidden", [("Content-Type", "text/html")])
        return ["Access denied"]
    return app(environ, start_response)

The net effect is that the Flask app is unmodified - it just receives HTTP requests and returns HTML pages. If an unauthenticated / unauthorized user connects to the proxy, then mod_auth_openidc intercepts this, redirects them to Google/O365 or whatever, handles the /callback response, gives them a suitable cookie, and performs the necessary authorization. e.g.

        <Location "/admin">
                AuthType openid-connect
                Require claim organization_id:XXXXXXXX
        </Location>

I was hoping to achieve something similar with API Gateway and Cognito, but looks like I was hoping for too much.

Aside: the proxy is going to remain for some other applications anyway, because those applications require HTTPS client certificate authentication, which API Gateway definitely can’t do.

You can also take a look at this serverless auth boilerplate

Ah, thank you.

As a non-Javascript person, I’m trying to decode the source to serverless-authentication-gh-pages. As far as I can see:

  • It’s client-side HTML and Javascript
  • When the user clicks on the Google login button, it triggers a redirect to page authenticationEndpoint + '/authentication/signin/google (via window.location.href)
  • authenticationEndpoint is an API Gateway URL front-end to lambda, in this case it’s handler.signin from serverless-boilerplate
  • this in turn calls the signin code from serverless-authentication-google: this part I get lost in
  • by the time this is complete, the user somehow lands back at the original page. However if I start from a local checkout of index.html I end up at http://laardee.github.io/serverless-authentication-gh-pages: so either this is the Google callback URL, or the authentication lambda function knows this URL.
  • somehow an authorization_token ends up in local storage
  • when the user tries to access the protected URL (/test-token), this is done via XHR with an added Authorization: header containing this token
  • back in serverless-authentication-boilerplate, somehow the Authorization header is checked:
          authorizer:
            arn: replace-with-arn-of-the-authorizer-function
            resultTtlInSeconds: 0
            identitySource: method.request.header.Authorization
            identityValidationExpression: .*

(so presumably this is using a Lambda custom authorizer, not IAM authorization. In fact I don’t think it’s using Cognito at all)

But even given that I don’t understand this fully, my main take-away is that the client browser is unable to access any protected page except via XHR (to add the Authorization: header)


#6

Run into same issue after you and your post probably saved me several hours of hitting my head to the wall… Thanks!