Search code examples
terraformamazon-cognitoopenidgithub-oauth

How can I add GitHub as an identity provider for AWS Cognito with Terraform?


I am using AWS Cognito to build out the authentication layer for my React app, and I'm trying to go for the quickest win possible. I'm using Terraform to build my backend, and have successfully got Google working as an identity provider. Now I want to add Github, but I'm unable to find any sample Terraform resources that I can use for this. I am using the hosted UI to test the configuration, but will copy the links directly into my react app once it's all working.

I have created an OAuth application in GitHub and used the credentials from that.

Here is my resource for the GitHub identity provider (which I came up with largely with the help from copilot):

resource "aws_cognito_identity_provider" "github_provider" {
  user_pool_id = aws_cognito_user_pool.user_pool.id
  provider_name = "GitHub"
  provider_type = "OIDC"

  provider_details = {
    authorize_scopes          = "openid"
    client_id                 = "XXXXXXXXXXXXXXXXX"
    client_secret             = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
    oidc_issuer               = "https://token.actions.githubusercontent.com"
    attributes_request_method = "GET"
  }

  attribute_mapping = {
    username = "sub"
  }
}

In the hosted UI I can then see my GitHub authentication button:

Scrreenshot of Hosted UI with GitHub login

However, if I click the link, it immediately goes to my callback with the error without going to GitHub:

http://localhost:3000/callback?error_description=Unsupported+configuration+for+OIDC+Identity+Provider.+Please+review+the+documentation+for+specification.&error=server_error

"Unsupported configuration for OIDC Identity Provider. Please review the documentation for specification."

I can't find any description of this error message on Google and it doesn't help explain what the problem is. Can anyone help?


Solution

  • I've spent a day of trial and error on this, but mostly thanks to this article I was able to get it working: https://sst.dev/examples/how-to-add-github-login-to-your-cognito-user-pool.html

    GitHub doesn't implement OCID the way AWS expects it to, so I had to add two proxies to my API to properly format the request and response between AWS and GitHub.

    Here is the final working aws_cognito_identity_provider:

    resource "aws_cognito_identity_provider" "github_provider" {
      user_pool_id = aws_cognito_user_pool.user_pool.id
      provider_name = "GitHub"
      provider_type = "OIDC"
    
      provider_details = {
        authorize_scopes          = "openid user:email"
        client_id                 = "XXXXXXXXXXXXXXXXXX"
        client_secret             = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
    
        attributes_request_method = "GET"
    
        oidc_issuer               = "https://token.actions.githubusercontent.com"
    
        authorize_url             = "https://github.com/login/oauth/authorize"
        token_url                 = "https://${var.api_domain}/auth/github-token-proxy"
        attributes_url            = "https://${var.api_domain}/auth/github-userinfo-proxy"
        jwks_uri                  = "https://token.actions.githubusercontent.com/.well-known/jwks.json"
      }
    
      attribute_mapping = {
        email = "email"
        username = "sub"
      }
    }
    

    for the token_url proxy, the main change is attaching the accept: "application/json" which Cognito doesn't seem to do on it's own:

    import { corsMiddleware } from "../middleware/corsMiddleware";
    import fetch from "node-fetch";
    import parser from "lambda-multipart-parser";
    import { Logger } from "@aws-lambda-powertools/logger";
    
    export const handler = corsMiddleware(async (event) => {
      const result = await parser.parse(event);
    
      const logger = new Logger({ serviceName: "postAuthGithubTokenProxyHandler" });
    
      logger.info(event);
    
      const token = await (
        await fetch(
          `https://github.com/login/oauth/access_token?client_id=${result.client_id}&client_secret=${result.client_secret}&code=${result.code}`,
          {
            method: "POST",
            headers: {
              accept: "application/json",
            },
          }
        )
      ).json();
    
      logger.info(JSON.stringify(token));
    
      return {
        statusCode: 200,
        body: JSON.stringify(token),
      };
    });
    
    

    For the second proxy, I had to change the authorization header from "Bearer" to "token". I also had to add in a call to /user/emails as the default /user endpoint doesn't return private emails on its own:

    import { corsMiddleware } from "../middleware/corsMiddleware";
    import fetch from "node-fetch";
    
    import { Logger } from "@aws-lambda-powertools/logger";
    
    export const handler = corsMiddleware(async (event) => {
      const logger = new Logger({
        serviceName: "getAuthGithubUserInfoProxyHandler",
      });
    
      logger.info(event);
    
      const bearerToken = event.headers["Authorization"].split("Bearer ")[1];
      if (!bearerToken) {
        return {
          statusCode: 401,
          body: JSON.stringify({
            error: "Missing bearer token",
          }),
        };
      }
    
      const token = (await (
        await fetch("https://api.github.com/user", {
          method: "GET",
          headers: {
            authorization: `token ${bearerToken}`,
            accept: "application/json",
          },
        })
      ).json()) as { id: string; email: string };
    
      logger.info(JSON.stringify(token));
    
      let email = token.email;
    
      if (email === null) {
        const emails = (await (
          await fetch("https://api.github.com/user/emails", {
            method: "GET",
            headers: {
              authorization: `token ${bearerToken}`,
              accept: "application/json",
            },
          })
        ).json()) as { email: string; primary: boolean; verified: boolean }[];
    
        logger.info(JSON.stringify(emails));
    
        const primaryEmailObj = emails.find((email) => email.primary);
        if (primaryEmailObj) {
          email = primaryEmailObj.email;
        }
      }
    
      return {
        statusCode: 200,
        body: JSON.stringify({
          email: email,
          sub: token.id,
        }),
      };
    });