From f97c3adb2198fee093908fe528512ef8be886591 Mon Sep 17 00:00:00 2001 From: Andrew Plummer Date: Thu, 23 Jan 2020 13:14:10 +0000 Subject: [PATCH 1/8] Starting point --- .eslintrc.json | 47 +++ .gitignore | 12 + .prettierrc | 3 + .snyk | 13 + .travis.yml | 21 + LICENSE | 29 ++ README.md | 316 ++++++++++++++- babel.config.js | 19 + config/setup-pact.js | 17 + config/setup-test-framework-script.js | 35 ++ docs/attribute-mapping.png | Bin 0 -> 52434 bytes docs/oauth-flow.svg | 7 + docs/oauth-flow.txt | 7 + docs/openid-flow.svg | 8 + docs/openid-flow.txt | 8 + docs/overview.graphml | 180 +++++++++ docs/overview.png | Bin 0 -> 27480 bytes docs/shim.svg | 17 + docs/shim.txt | 16 + example-config.sh | 26 ++ jest.config.js | 180 +++++++++ package.json | 68 ++++ scripts/create-key.sh | 15 + scripts/deploy.sh | 24 ++ scripts/lib-robust-bash.sh | 14 + src/__mocks__/privateKeyMock.js | 53 +++ src/__mocks__/publicKeyMock.js | 16 + src/config.js | 15 + src/connectors/controllers.js | 80 ++++ src/connectors/lambda/authorize.js | 18 + src/connectors/lambda/jwks.js | 6 + .../lambda/open-id-configuration.js | 12 + src/connectors/lambda/token.js | 34 ++ src/connectors/lambda/userinfo.js | 7 + src/connectors/lambda/util/auth.js | 44 +++ src/connectors/lambda/util/responder.js | 35 ++ src/connectors/logger.js | 49 +++ src/connectors/web/app.js | 24 ++ src/connectors/web/auth.js | 24 ++ src/connectors/web/handlers.js | 29 ++ src/connectors/web/responder.js | 21 + src/connectors/web/routes.js | 13 + src/crypto.js | 30 ++ src/github.js | 93 +++++ src/github.pact.test.js | 359 ++++++++++++++++++ src/helpers.js | 3 + src/helpers.test.js | 16 + src/openid.js | 149 ++++++++ src/openid.test.js | 196 ++++++++++ src/validate-config.js | 30 ++ template.yml | 124 ++++++ webpack.config.js | 60 +++ 52 files changed, 2621 insertions(+), 1 deletion(-) create mode 100644 .eslintrc.json create mode 100644 .gitignore create mode 100644 .prettierrc create mode 100644 .snyk create mode 100644 .travis.yml create mode 100644 LICENSE create mode 100644 babel.config.js create mode 100644 config/setup-pact.js create mode 100644 config/setup-test-framework-script.js create mode 100644 docs/attribute-mapping.png create mode 100644 docs/oauth-flow.svg create mode 100644 docs/oauth-flow.txt create mode 100644 docs/openid-flow.svg create mode 100644 docs/openid-flow.txt create mode 100644 docs/overview.graphml create mode 100644 docs/overview.png create mode 100644 docs/shim.svg create mode 100644 docs/shim.txt create mode 100755 example-config.sh create mode 100644 jest.config.js create mode 100644 package.json create mode 100755 scripts/create-key.sh create mode 100755 scripts/deploy.sh create mode 100644 scripts/lib-robust-bash.sh create mode 100644 src/__mocks__/privateKeyMock.js create mode 100644 src/__mocks__/publicKeyMock.js create mode 100644 src/config.js create mode 100644 src/connectors/controllers.js create mode 100644 src/connectors/lambda/authorize.js create mode 100644 src/connectors/lambda/jwks.js create mode 100644 src/connectors/lambda/open-id-configuration.js create mode 100644 src/connectors/lambda/token.js create mode 100644 src/connectors/lambda/userinfo.js create mode 100644 src/connectors/lambda/util/auth.js create mode 100644 src/connectors/lambda/util/responder.js create mode 100644 src/connectors/logger.js create mode 100644 src/connectors/web/app.js create mode 100644 src/connectors/web/auth.js create mode 100644 src/connectors/web/handlers.js create mode 100644 src/connectors/web/responder.js create mode 100644 src/connectors/web/routes.js create mode 100644 src/crypto.js create mode 100644 src/github.js create mode 100644 src/github.pact.test.js create mode 100644 src/helpers.js create mode 100644 src/helpers.test.js create mode 100644 src/openid.js create mode 100644 src/openid.test.js create mode 100644 src/validate-config.js create mode 100644 template.yml create mode 100644 webpack.config.js diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..9c28f47 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,47 @@ +{ + "extends": [ + "airbnb-base", + "prettier" + ], + "rules": { + "no-console": [ + "error", + { + "allow": [ + "warn", + "error", + "info" + ] + } + ], + "camelcase": 0 + }, + "overrides": [ + { + "files": [ + "**/*.test.js" + ], + "env": { + "jest": true, + "jasmine": true + }, + "plugins": [ + "jest", + "chai-expect", + "chai-friendly" + ], + "rules": { + "no-unused-expressions": 0, + "chai-friendly/no-unused-expressions": 2, + "chai-expect/missing-assertion": 2, + "chai-expect/terminating-properties": 2, + "chai-expect/no-inner-compare": 2, + "jest/no-disabled-tests": "warn", + "jest/no-focused-tests": "error", + "jest/no-identical-title": "error", + "jest/prefer-to-have-length": "warn", + "jest/valid-expect": 0 + } + } + ] +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4d8bd51 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +node_modules +coverage +pacts +dist-web +dist-lambda +logs + +config.sh +*.key +*.key.pub +serverless-output.yml +package-lock.json diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..544138b --- /dev/null +++ b/.prettierrc @@ -0,0 +1,3 @@ +{ + "singleQuote": true +} diff --git a/.snyk b/.snyk new file mode 100644 index 0000000..7fc3100 --- /dev/null +++ b/.snyk @@ -0,0 +1,13 @@ +# Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities. +version: v1.13.5 +ignore: {} +# patches apply the minimum changes required to fix a vulnerability +patch: + SNYK-JS-AXIOS-174505: + - axios: + patched: '2019-05-06T06:05:06.784Z' + SNYK-JS-HTTPSPROXYAGENT-469131: + - snyk > proxy-agent > https-proxy-agent: + patched: '2019-10-08T04:45:37.194Z' + - snyk > proxy-agent > pac-proxy-agent > https-proxy-agent: + patched: '2019-10-08T04:45:37.194Z' diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..62e7330 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,21 @@ +language: node_js +cache: + directories: + - "node_modules" +node_js: + - "node" +env: + global: + - CC_TEST_REPORTER_ID=da46c045c47b340b3044989b44d42517cd36fd17c3e21ede55c45c49c7abe420 +dist: trusty +install: + - npm install +before_script: + - curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter + - chmod +x ./cc-test-reporter + - ./cc-test-reporter before-build +script: + - npm run lint + - npm run test +after_script: + - ./cc-test-reporter after-build --exit-code $TRAVIS_TEST_RESULT diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..944f2e3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2018, Timothy Jones +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md index 670d48b..15a9466 100644 --- a/README.md +++ b/README.md @@ -1 +1,315 @@ -# linkedin-cognito-openid-wrapper +# GitHub OpenID Connect Wrapper for Cognito + +[![Build Status](https://travis-ci.org/TimothyJones/github-cognito-openid-wrapper.svg?branch=master)](https://travis-ci.org/TimothyJones/github-cognito-openid-wrapper) +[![Maintainability](https://api.codeclimate.com/v1/badges/f787719be529b1c0e8ee/maintainability)](https://codeclimate.com/github/TimothyJones/github-openid-wrapper/maintainability) +[![Test Coverage](https://api.codeclimate.com/v1/badges/f787719be529b1c0e8ee/test_coverage)](https://codeclimate.com/github/TimothyJones/github-openid-wrapper/test_coverage) +[![Known Vulnerabilities](https://snyk.io/test/github/TimothyJones/github-cognito-openid-wrapper/badge.svg?targetFile=package.json)](https://snyk.io/test/github/TimothyJones/github-cognito-openid-wrapper?targetFile=package.json) +[![License](https://img.shields.io/badge/License-BSD%203--Clause-blue.svg)](https://opensource.org/licenses/BSD-3-Clause) + +Do you want to add GitHub as an OIDC (OpenID Connect) provider to an AWS Cognito User Pool? Have you run in to trouble because GitHub only provides OAuth2.0 endpoints, and doesn't support OpenID Connect? + +This project allows you to wrap your GitHub OAuth App in an OpenID Connect layer, allowing you to use it with AWS Cognito. + +Here are some questions you may immediately have: + +- **Why does Cognito not support federation with OAuth?** Because OAuth provides + no standard way of requesting user identity data. (see the [background](#background) + section below for more details). + +- **Why has no one written a shim to wrap general OAuth implementations with an + OpenID Connect layer?** Because OAuth provides no standard way of requesting + user identity data, any shim must be custom written for the particular OAuth + implementation that's wrapped. + +- **GitHub is very popular, has someone written this specific custom wrapper + before?** As far as I can tell, if it has been written, it has not been open + sourced. Until now! + +## Project overview + +When deployed, this project sits between Cognito and GitHub: + +![Overview](docs/overview.png) + +This allows you to use GitHub as an OpenID Identity Provider (IdP) for federation with a Cognito User Pool. + +The project implements everything needed by the [OIDC User Pool IdP authentication flow](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-oidc-flow.html) used by Cognito. + +It implements the following endpoints from the +[OpenID Connect Core Spec](https://openid.net/specs/openid-connect-core-1_0.html): + +- Authorization - used to start the authorisation process ([spec](https://openid.net/specs/openid-connect-core-1_0.html#AuthorizationEndpoint)) +- Token - used to exchange an authorisation code for an access and ID token ([spec](https://openid.net/specs/openid-connect-core-1_0.html#TokenEndpoint)) +- UserInfo - used to exchange an access token for information about the user ([spec](https://openid.net/specs/openid-connect-core-1_0.html#UserInfo)) +- jwks - used to describe the keys used to sign ID tokens ([implied by spec](https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata)) + +It also implements the following [OpenID Connect Discovery](https://openid.net/specs/openid-connect-discovery-1_0.html) endpoint: + +- Configuration - used to discover configuration of this OpenID implementation's + endpoints and capabilities. ([spec](https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig)) + +Out of the box, you can deploy it as a CloudFormation stack, or run it as a web server with node. + +## Getting Started + +This project is intended to be deployed as a series of lambda functions alongside +an API Gateway. This means it's easy to use in conjunction with Cognito, and +should be cheap to host and run. + +You can also deploy it as a http server running as a node app. This is useful +for testing, exposing it to Cognito using something like [ngrok](https://ngrok.com/). + +### 1: Setup + +You will need to: + +- Create a Cognito User Pool ([instructions](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pool-as-user-directory.html)). +- Configure App Integration for your User Pool ([instructions](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-configuring-app-integration.html)). Note down the domain name. +- Create a GitHub OAuth App ([instructions](https://developer.github.com/apps/building-oauth-apps/creating-an-oauth-app/), with the following settings: + - Authorization callback URL: `https:///oauth2/idpresponse` + - Note down the Client ID and secret + +(If you use GitHub Enterprise, you need the API & Login URL. This is usually `https:///api/v3` and `https://`.) + +Next you need to decide if you'd like to deploy with lambda/API Gateway (follow Step 2a), or as a node server (follow Step 2b) + +### 2a: Deployment with lambda and API Gateway + +- Install the `aws` and `sam` CLIs from AWS: + + - `aws` ([install instructions](https://docs.aws.amazon.com/cli/latest/userguide/installing.html)) and configured + - `sam` ([install instructions](https://docs.aws.amazon.com/lambda/latest/dg/sam-cli-requirements.html)) + +- Run `aws configure` and set appropriate access keys etc +- Set environment variables for the OAuth App client/secret, callback url, stack name, etc: + + cp example-config.sh config.sh + vim config.sh # Or whatever your favourite editor is + +- Run `npm install` and `npm run deploy` +- Note down the DNS of the deployed API Gateway (available in the AWS console). + +### 2b: Running the node server + +- Set environment variables for the OAuth App client/secret, callback url, and + port to run the server on: + + cp example-config.sh config.sh + vim config.sh # Or whatever your favourite editor is + +- Source the config file: + +``` + source config.sh +``` + +- Run `npm run start` to fire up an auto-refreshing development build of the + server (production deployment is out of scope for this repository, but you can expose it using something like [ngrok](https://ngrok.com/) for easy development and testing with Cognito). + +### 3: Finalise Cognito configuration + +- Configure the OIDC integration in AWS console for Cognito (described below, but following [these instructions](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-oidc-idp.html)). The following settings are required: + - Client ID: The GitHub Client ID above + - Authorize scope: `openid read:user user:email` + - Issuer: `https:///${Stage_Name}` or `https:///` (for the node server). + - If you have deployed the web app: Run discovery (big blue button next to Issuer). + - If you have deployed the lambda/Gateway: For some reason, Cognito is unable to + do OpenID Discovery. You will need to configure the endpoints manually. They are: + - Authorization endpoint: `https:///${Stage_Name}/authorize` + - Token endpoint: `https:///${Stage_Name}/token` + - Userinfo endpoint: `https:///${Stage_Name}/userinfo` + - JWKS uri: `https:///${Stage_Name}/.well-known/jwks.json` +- Configure the Attribute Mapping in the AWS console: + +![Attribute mapping](docs/attribute-mapping.png) + +- Ensure that your new provider is enabled under **Enabled Identity Providers** on the App Client Settings screen under App Integration. + +That's it! If you need to redeploy the lambda/API gateway solution, all you need to do is run `npm run deploy` again. + +### Logging + +This shim also supports logging with Winston. By default, all logging goes to +STDOUT. Beware that if you set the log level to DEBUG, then sensitive user +information may be logged. + +If you're using the node server, you can also use Splunk for logging. +Environment variables configuring splunk are commented in `example-config.sh`. The Splunk HEC URL and access +token are required, and you can also set the source, sourcetype & index for all logged events. + +## The details + +### Background + +There are two important concepts for identity federation: + +- Authentication: Is this user who they say they are? +- Authorisation: Is the user allowed to use a particular resource? + +#### OAuth + +[OAuth2.0](https://tools.ietf.org/html/rfc6749) is an _authorisation_ framework, +used for determining whether a user is allowed to access a resource (like +private user profile data). In order to do this, it's usually necessary for +_authentication_ of the user to happen before authorisation. + +This means that most OAuth2.0 implementations (including GitHub) [include authentication in a step of the authorisation process](https://medium.com/@darutk/new-architecture-of-oauth-2-0-and-openid-connect-implementation-18f408f9338d). +For all practical purposes, most OAuth2.0 implementations (including GitHub)can +be thought of as providing both authorisation and authentication. + +Below is a diagram of the authentication code flow for OAuth: + +![OAuth flow](docs/oauth-flow.svg) + +(The solid lines are http requests from the browser, and then dashed lines are +back-channel requests). + +As you can see in the diagram, a drawback of OAuth is that it provides no +standard way of finding out user data such as name, avatar picture, email +address(es), etc. This is one of the problems that is solved by OpenID. + +#### OpenID Connect + +To provide a standard way of learning about users, +[OpenID Connect](https://openid.net/connect/) is an identity layer built on top +of OAuth2.0. It extends the `token` endpoint from OAuth to include an ID Token +alongside the access token, and provides a `userinfo` endpoint, where information +describing the authenticated user can be accessed. + +![OpenID Connect](docs/openid-flow.svg) + +OpenID Connect describes a standard way to get user data, and is therefore a good choice +for identity federation. + +### A custom shim for GitHub + +This project provides the OpenID shim to wrap GitHub's OAuth implementation, by combining the two +diagrams: + +![GitHub Shim](docs/shim.svg) + +The userinfo request is handled by joining two GitHub API requests: `/user` and `/user/emails`. + +You can compare this workflow to the documented Cognito workflow [here](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-oidc-flow.html) + +#### Code layout + + ├── scripts # Bash scripts for deployment and key generation + ├── src # Source code + │ ├── __mocks__ # Mock private key data for tests + │ └── connectors # Common code for both lambda and web handlers + │ ├── lambda # AWS lambda handlers + │ │ └── util # Helper functions for lambdas + │ └── web # Express.js webserver (useful for local deployment) + ├── docs # Documentation images + ├── config # Configuration for tests + ├── dist-web # Dist folder for web server deployment + └-- dist-lambda # Dist folder for lambda deployment + +#### npm targets + +- `build` and `build-dist`: create packages in the `dist-lambda` folder (for the lambda + deployment) and the `dist-web` folder (for the node web server). +- `test`: Run unit tests with Jest +- `lint`: Run `eslint` to check code style +- `test-dev`: Run unit tests continuously, watching the file system for changes + (useful for development) +- `deploy`: This script builds the project, then creates and deploys the + cloudformation stack with the API gateway and the endpoints as lambdas + +#### Scripts + +- `scripts/create-key.sh`: If the private key is missing, generate a new one. + This is run as a preinstall script before `npm install` +- `scripts/deploy.sh`: This is the deploy part of `npm run deploy`. It uploads + the dist folder to S3, and then creates the cloudformation stack that contains + the API gateway and lambdas + +#### Tests + +Tests are provided with [Jest](https://jestjs.io/) using +[`chai`'s `expect`](http://www.chaijs.com/api/bdd/), included by a shim based on [this blog post](https://medium.com/@RubenOostinga/combining-chai-and-jest-matchers-d12d1ffd0303). + +[Pact](http://pact.io) consumer tests for the GitHub API connection are provided +in `src/github.pact.test.js`. There is currently no provider validation performed. + +#### Private key + +The private key used to make ID tokens is stored in `./jwtRS256.key` once +`scripts/create-key.sh` is run (either manually, or as part of `npm install`). +You may optionally replace it with your own key - if you do this, you will need +to redeploy. + +#### Missing features + +This is a near-complete implementation of [OpenID Connect Core](https://openid.net/specs/openid-connect-core-1_0.html). +However, since the focus was on enabling Cognito's [authentication flow](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-oidc-flow.html), +you may run in to some missing features if you wish to use it with a different +client. + +**Missing Connect Core Features:** + +- Private key rotation ([spec](https://openid.net/specs/openid-connect-core-1_0.html#RotateSigKeys)) +- Refresh tokens ([spec](https://openid.net/specs/openid-connect-core-1_0.html#RefreshTokens)) +- Passing request parameters as JWTs ([spec](https://openid.net/specs/openid-connect-core-1_0.html#JWTRequests)) + +If you don't know what these things are, you are probably ok to use this project. + +**Missing non-core features:** + +A full OpenID implementation would also include: + +- [The Dynamic client registration spec](https://openid.net/specs/openid-connect-registration-1_0.html) +- The [OpenID Connect Discovery](https://openid.net/specs/openid-connect-discovery-1_0.html) endpoints beyond `openid-configuration` + +**Known issues** + +See [the issue tracker](https://github.com/TimothyJones/github-cognito-openid-wrapper/issues) for an up to date list. + +## Extending + +This section contains pointers if you would like to extend this shim. + +### Using other OAuth providers + +If you want to use a provider other than GitHub, you'll need to change the contents of `userinfo` in `src/openid.js`. + +### Using a custom GitHub location + +If you're using an on-site GitHub install, you will need to change the API +endpoints used when the `github` object is initialised. + +### Including additional user information + +If you want to include custom claims based on other GitHub data, +you can extend `userinfo` in `src/openid.js`. You may need to add extra API +client calls in `src/github.js` + +## Contributing + +Contributions are welcome, especially for the missing features! Pull requests and issues are very welcome. + +## FAQ + +### How do I use this to implement Cognito logins in my app? + +Login requests from your app go directly to Cognito, rather than this shim. +This is because the shim sits only between Cognito and GitHub, not between your +app and GitHub. See the [Cognito app integration instructions](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-app-integration.html) +for more details. + +### Can I use this shim to connect to GitHub directly from another OpenID client? + +Yes. This implementation isn't complete, as it focusses exclusively on +Cognito's requirements. However, it does follow the OpenID spec, and is +complete enough to be able to use it as an OpenID connect provider. See the +missing features section above for one or two caveats. + +### How do I contact you to tell you that I built something cool with this code? + +If you build anything cool, ping me [@JonesTim](https://twitter.com/JonesTim) on +twitter (or open an issue if you have any problems). + +## License + +[BSD 3-Clause License](LICENSE) diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 0000000..248fc0d --- /dev/null +++ b/babel.config.js @@ -0,0 +1,19 @@ +module.exports = function(api) { + const presets = [ + [ + '@babel/preset-env', + { + targets: { node: '8.10' } + } + ] + ]; + const plugins = []; + + // Cache the returned value forever and don't call this function again. + api.cache(true); + + return { + presets, + plugins + }; +}; diff --git a/config/setup-pact.js b/config/setup-pact.js new file mode 100644 index 0000000..80c1693 --- /dev/null +++ b/config/setup-pact.js @@ -0,0 +1,17 @@ +const path = require('path'); +const { Pact } = require('@pact-foundation/pact'); +const pkg = require('../package.json'); + +global.port = 8989; +global.PACT_BASE_URL = `http://localhost:${port}`; + +global.provider = new Pact({ + port: global.port, + log: path.resolve(process.cwd(), 'logs', 'mockserver-integration.log'), + dir: path.resolve(process.cwd(), 'pacts'), + spec: 2, + logLevel: 'fatal', + pactfileWriteMode: 'update', + consumer: pkg.name, + provider: 'GitHub.com' +}); diff --git a/config/setup-test-framework-script.js b/config/setup-test-framework-script.js new file mode 100644 index 0000000..fcd6384 --- /dev/null +++ b/config/setup-test-framework-script.js @@ -0,0 +1,35 @@ +const chai = require('chai'); +const chaiAsPromised = require('chai-as-promised'); + +// use chai as promised to get awesome promise handlers +chai.use(chaiAsPromised); + +// Allow chai to print diffs like Jest would +const chaiJestDiff = require('chai-jest-diff').default; +chai.use(chaiJestDiff()); + +// Make sure chai and jasmine ".not" play nice together +const originalNot = Object.getOwnPropertyDescriptor( + chai.Assertion.prototype, + 'not' +).get; +Object.defineProperty(chai.Assertion.prototype, 'not', { + get() { + Object.assign(this, this.assignedNot); + return originalNot.apply(this); + }, + set(newNot) { + this.assignedNot = newNot; + return newNot; + } +}); + +// Combine both jest and chai matchers on expect +const originalExpect = global.expect; + +global.expect = actual => { + const originalMatchers = originalExpect(actual); + const chaiMatchers = chai.expect(actual); + const combinedMatchers = Object.assign(chaiMatchers, originalMatchers); + return combinedMatchers; +}; diff --git a/docs/attribute-mapping.png b/docs/attribute-mapping.png new file mode 100644 index 0000000000000000000000000000000000000000..06ec3759a73668ffafc24b9c08769f06324e8e8b GIT binary patch literal 52434 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 literal 0 HcmV?d00001 diff --git a/docs/oauth-flow.svg b/docs/oauth-flow.svg new file mode 100644 index 0000000..ea7e6f6 --- /dev/null +++ b/docs/oauth-flow.svg @@ -0,0 +1,7 @@ +Client->OAuth Server: Authenticate\n(to get profile) +Note right of OAuth Server: OAuth server\nmay do authorisation here +OAuth Server->Client: Authentication Code +Client-->OAuth Server: Authentication Code +Note right of OAuth Server: Token endpoint uses the code\nto get an access token +OAuth Server-->Client: Access Token +Note over Client,OAuth Server: The access token is now\n used to access custom \nendpoints on the server\n(eg to get user profile data)ClientClientOAuth ServerOAuth ServerAuthenticate(to get profile)OAuth servermay do authorisation hereAuthentication CodeAuthentication CodeToken endpoint uses the codeto get an access tokenAccess TokenThe access token is nowused to access customendpoints on the server(eg to get user profile data) \ No newline at end of file diff --git a/docs/oauth-flow.txt b/docs/oauth-flow.txt new file mode 100644 index 0000000..25e43fd --- /dev/null +++ b/docs/oauth-flow.txt @@ -0,0 +1,7 @@ +Client->OAuth Server: Authenticate\n(to get profile) +Note right of OAuth Server: OAuth server\nmay do authorisation here +OAuth Server->Client: Authentication Code +Client-->OAuth Server: Authentication Code +Note right of OAuth Server: Token endpoint uses the code\nto get an access token +OAuth Server-->Client: Access Token +Note over Client,OAuth Server: The access token is now\n used to access custom \nendpoints on the server\n(eg to get user profile data) diff --git a/docs/openid-flow.svg b/docs/openid-flow.svg new file mode 100644 index 0000000..042a908 --- /dev/null +++ b/docs/openid-flow.svg @@ -0,0 +1,8 @@ +Client->OpenID Connect: Authenticate\n(to get profile) +Note right of OpenID Connect: OpenID Connect server\nmay do authorisation here +OpenID Connect->Client: Authentication Code +Client-->OpenID Connect: Authentication Code +Note right of OpenID Connect: Token endpoint uses the code\nto get an access token\n and an ID token +OpenID Connect-->Client: Tokens +Client-->OpenID Connect: Access token +OpenID Connect-->Client: User informationClientClientOpenID ConnectOpenID ConnectAuthenticate(to get profile)OpenID Connect servermay do authorisation hereAuthentication CodeAuthentication CodeToken endpoint uses the codeto get an access tokenand an ID tokenTokensAccess tokenUser information \ No newline at end of file diff --git a/docs/openid-flow.txt b/docs/openid-flow.txt new file mode 100644 index 0000000..8f00626 --- /dev/null +++ b/docs/openid-flow.txt @@ -0,0 +1,8 @@ +Client->OpenID Connect: Authenticate\n(to get profile) +Note right of OpenID Connect: OpenID Connect server\nmay do authorisation here +OpenID Connect->Client: Authentication Code +Client-->OpenID Connect: Authentication Code +Note right of OpenID Connect: Token endpoint uses the code\nto get an access token\n and an ID token +OpenID Connect-->Client: Tokens +Client-->OpenID Connect: Access token +OpenID Connect-->Client: User information diff --git a/docs/overview.graphml b/docs/overview.graphml new file mode 100644 index 0000000..0ab353b --- /dev/null +++ b/docs/overview.graphml @@ -0,0 +1,180 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + github-cognito-openid-wrapper + + + + + + + + + + + Your App + + + + + + + + + + + AWS Cognito +User Pool + + + + + + + + + + + GitHub OAuth + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + <?xml version="1.0" encoding="utf-8"?> +<!-- Generator: Adobe Illustrator 22.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> +<svg version="1.2" baseProfile="tiny" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" + x="0px" y="0px" viewBox="0 0 2350 2314.8" xml:space="preserve"> +<path d="M1175,0C525.8,0,0,525.8,0,1175c0,552.2,378.9,1010.5,890.1,1139.7c-5.9-14.7-8.8-35.3-8.8-55.8v-199.8H734.4 + c-79.3,0-152.8-35.2-185.1-99.9c-38.2-70.5-44.1-179.2-141-246.8c-29.4-23.5-5.9-47,26.4-44.1c61.7,17.6,111.6,58.8,158.6,120.4 + c47,61.7,67.6,76.4,155.7,76.4c41.1,0,105.7-2.9,164.5-11.8c32.3-82.3,88.1-155.7,155.7-190.9c-393.6-47-581.6-240.9-581.6-505.3 + c0-114.6,49.9-223.3,132.2-317.3c-26.4-91.1-61.7-279.1,11.8-352.5c176.3,0,282,114.6,308.4,143.9c88.1-29.4,185.1-47,284.9-47 + c102.8,0,196.8,17.6,284.9,47c26.4-29.4,132.2-143.9,308.4-143.9c70.5,70.5,38.2,261.4,8.8,352.5c82.3,91.1,129.3,202.7,129.3,317.3 + c0,264.4-185.1,458.3-575.7,499.4c108.7,55.8,185.1,214.4,185.1,331.9V2256c0,8.8-2.9,17.6-2.9,26.4 + C2021,2123.8,2350,1689.1,2350,1175C2350,525.8,1824.2,0,1175,0L1175,0z"/> +</svg> + + <svg width="2140" height="2500" viewBox="0 0 256 299" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid"><path d="M208.752 58.061l25.771-6.636.192.283.651 155.607-.843.846-5.31.227-20.159-3.138-.302-.794V58.061M59.705 218.971l.095.007 68.027 19.767.173.133.296.236-.096 59.232-.2.252-68.295-33.178v-46.449" fill="#7A3E65"/><path d="M208.752 204.456l-80.64 19.312-40.488-9.773-27.919 4.976L128 238.878l105.405-28.537 1.118-2.18-25.771-3.705" fill="#CFB2C1"/><path d="M196.295 79.626l-.657-.749-66.904-19.44-.734.283-.672-.343L22.052 89.734l-.575.703.845.463 24.075 3.53.851-.289 80.64-19.311 40.488 9.773 27.919-4.977" fill="#512843"/><path d="M47.248 240.537l-25.771 6.221-.045-.149-1.015-155.026 1.06-1.146 25.771 3.704v146.396" fill="#C17B9E"/><path d="M82.04 180.403l45.96 5.391.345-.515.187-71.887-.532-.589-45.96 5.392v62.208" fill="#7A3E65"/><path d="M173.96 180.403L128 185.794v-72.991l45.96 5.392v62.208M196.295 79.626L128 59.72V0l68.295 33.177v46.449" fill="#C17B9E"/><path d="M128 0L0 61.793v175.011l21.477 9.954V90.437L128 59.72V0" fill="#7A3E65"/><path d="M234.523 51.425v156.736L128 238.878v59.72l128-61.794V61.793l-21.477-10.368" fill="#C17B9E"/></svg> + + + diff --git a/docs/overview.png b/docs/overview.png new file mode 100644 index 0000000000000000000000000000000000000000..e6d40c4983fd483a011ebb1ef996e6c9f72610dd GIT binary patch literal 27480 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Ï>0+b69&&3SXE6g}*Dvyu^e#KjORX{Kwe84u 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 VhMXo6#{It&kP?>{D-kt(^M7-W*`ELa literal 0 HcmV?d00001 diff --git a/docs/shim.svg b/docs/shim.svg new file mode 100644 index 0000000..a3d0856 --- /dev/null +++ b/docs/shim.svg @@ -0,0 +1,17 @@ +Cognito->OpenID Shim: Authenticate\n(to get profile) +OpenID Shim->GitHub: Authenticate +Note right of GitHub: GitHub does authorisation\n here if necessary +GitHub->OpenID Shim: Authentication Code +OpenID Shim->Cognito: Authentication Code +Cognito-->OpenID Shim: Authentication Code +OpenID Shim-->GitHub: Authentication Code +GitHub-->OpenID Shim: Access Token +Note over OpenID Shim: also generates an ID token +OpenID Shim-->Cognito: Access and ID tokens +Cognito-->OpenID Shim: Request public key +Note right of OpenID Shim: Public key is used \nby cognito to validate\n the ID token +OpenID Shim-->Cognito: Public Key +Cognito-->OpenID Shim: Access token +Note over OpenID Shim, GitHub: Various API calls to\n get user profile data +OpenID Shim-->Cognito: User information +CognitoCognitoOpenID ShimOpenID ShimGitHubGitHubAuthenticate(to get profile)AuthenticateGitHub does authorisationhere if necessaryAuthentication CodeAuthentication CodeAuthentication CodeAuthentication CodeAccess Tokenalso generates an ID tokenAccess and ID tokensRequest public keyPublic key is usedby cognito to validatethe ID tokenPublic KeyAccess tokenVarious API calls toget user profile dataUser information \ No newline at end of file diff --git a/docs/shim.txt b/docs/shim.txt new file mode 100644 index 0000000..21b3b79 --- /dev/null +++ b/docs/shim.txt @@ -0,0 +1,16 @@ +Cognito->OpenID Shim: Authenticate\n(to get profile) +OpenID Shim->GitHub: Authenticate +Note right of GitHub: GitHub does authorisation\n here if necessary +GitHub->OpenID Shim: Authentication Code +OpenID Shim->Cognito: Authentication Code +Cognito-->OpenID Shim: Authentication Code +OpenID Shim-->GitHub: Authentication Code +GitHub-->OpenID Shim: Access Token +Note over OpenID Shim: also generates an ID token +OpenID Shim-->Cognito: Access and ID tokens +Cognito-->OpenID Shim: Request public key +Note right of OpenID Shim: Public key is used \nby cognito to validate\n the ID token +OpenID Shim-->Cognito: Public Key +Cognito-->OpenID Shim: Access token +Note over OpenID Shim, GitHub: Various API calls to\n get user profile data +OpenID Shim-->Cognito: User information diff --git a/example-config.sh b/example-config.sh new file mode 100755 index 0000000..4f410a2 --- /dev/null +++ b/example-config.sh @@ -0,0 +1,26 @@ +#!/bin/bash -eu + +# Variables always required +export GITHUB_CLIENT_ID=# +export GITHUB_CLIENT_SECRET=# +export COGNITO_REDIRECT_URI=# https:///oauth2/idpresponse + +# Variables required if used with GitHub Enterprise +# GITHUB_API_URL=# https:///api/v3 +# GITHUB_LOGIN_URL=# https:// + +# Variables required if Splunk logger is used +# SPLUNK_URL=# https:///services/collector/event/1.0 +# SPLUNK_TOKEN=# Splunk HTTP Event Collector token +# SPLUNK_SOURCE=# Source for all logged events +# SPLUNK_SOURCETYPE=# Sourcetype for all logged events +# SPLUNK_INDEX=# Index for all logged events + +# Variables required if deploying with API Gateway / Lambda +export BUCKET_NAME=# An S3 bucket name to use as the deployment pipeline +export STACK_NAME=# The name of the stack to create +export REGION=# AWS region to deploy the stack and bucket in +export STAGE_NAME=# Stage name to create and deploy to in API gateway + +# Variables required if deploying a node http server +export PORT=# diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..0687811 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,180 @@ +// For a detailed explanation regarding each configuration property, visit: +// https://jestjs.io/docs/en/configuration.html + +module.exports = { + // All imported modules in your tests should be mocked automatically + // automock: false, + + // Stop running tests after the first failure + // bail: false, + + // Respect "browser" field in package.json when resolving modules + // browser: false, + + // The directory where Jest should store its cached dependency information + // cacheDirectory: "/var/folders/35/09t_zsws5d9dnb6jvrwd92rw0000gp/T/jest_dy", + + // Automatically clear mock calls and instances between every test + clearMocks: true, + + // Indicates whether the coverage information should be collected while executing the test + // collectCoverage: false, + + // An array of glob patterns indicating a set of files for which coverage information should be collected + // collectCoverageFrom: null, + + // The directory where Jest should output its coverage files + coverageDirectory: 'coverage', + + // An array of regexp pattern strings used to skip coverage collection + coveragePathIgnorePatterns: ['/node_modules/', '/config/'], + + // A list of reporter names that Jest uses when writing coverage reports + // coverageReporters: [ + // "json", + // "text", + // "lcov", + // "clover" + // ], + + // An object that configures minimum threshold enforcement for coverage results + // coverageThreshold: null, + + // Make calling deprecated APIs throw helpful error messages + // errorOnDeprecated: false, + + // Force coverage collection from ignored files usin a array of glob patterns + // forceCoverageMatch: [], + + // A path to a module which exports an async function that is triggered once before all test suites + // globalSetup: null, + + // A path to a module which exports an async function that is triggered once after all test suites + // globalTeardown: null, + + // A set of global variables that need to be available in all test environments + // globals: {}, + + // An array of directory names to be searched recursively up from the requiring module's location + // moduleDirectories: [ + // "node_modules" + // ], + + // An array of file extensions your modules use + // moduleFileExtensions: [ + // "js", + // "json", + // "jsx", + // "node" + // ], + + // A map from regular expressions to module names that allow to stub out resources with a single module + moduleNameMapper: { + '\\.key$': '/src/__mocks__/privateKeyMock.js', + '\\.key\\.pub$': '/src/__mocks__/publicKeyMock.js' + }, + + // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader + // modulePathIgnorePatterns: [], + + // Activates notifications for test results + // notify: false, + + // An enum that specifies notification mode. Requires { notify: true } + // notifyMode: "always", + + // A preset that is used as a base for Jest's configuration + // preset: null, + + // Run tests from one or more projects + // projects: null, + + // Use this configuration option to add custom reporters to Jest + // reporters: undefined, + + // Automatically reset mock state between every test + // resetMocks: false, + + // Reset the module registry before running each individual test + // resetModules: false, + + // A path to a custom resolver + // resolver: null, + + // Automatically restore mock state between every test + // restoreMocks: false, + + // The root directory that Jest should scan for tests and modules within + // rootDir: null, + + // A list of paths to directories that Jest should use to search for files in + // roots: [ + // "" + // ], + + // Allows you to use a custom runner instead of Jest's default test runner + // runner: "jest-runner", + + // The paths to modules that run some code to configure or set up the testing environment before each test + setupFiles: ['./config/setup-pact.js'], + + // The path to a module that runs some code to configure or set up the testing framework before each test + setupFilesAfterEnv: ['./config/setup-test-framework-script.js'], + + // A list of paths to snapshot serializer modules Jest should use for snapshot testing + // snapshotSerializers: [], + + // The test environment that will be used for testing + testEnvironment: 'node', + + // Options that will be passed to the testEnvironment + // testEnvironmentOptions: {}, + + // Adds a location field to test results + // testLocationInResults: false, + + // The glob patterns Jest uses to detect test files + testMatch: ['**/__tests__/**/*.js?(x)', '**/?(*.)+(spec|test).js?(x)'], + + // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped + // testPathIgnorePatterns: [ + // "/node_modules/" + // ], + + // The regexp pattern Jest uses to detect test files + // testRegex: "", + + // This option allows the use of a custom results processor + // testResultsProcessor: null, + + // This option allows use of a custom test runner + // testRunner: "jasmine2", + + // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href + // testURL: "http://localhost", + + // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" + // timers: "real", + + // A map from regular expressions to paths to transformers + transform: { + '^.+\\.js$': 'babel-jest' + } + + // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation + // transformIgnorePatterns: [ + // "/node_modules/" + // ], + + // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them + // unmockedModulePathPatterns: undefined, + + // Indicates whether each individual test should be reported during the run + // verbose: null, + + // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode + // watchPathIgnorePatterns: [], + + // Whether to use watchman for file crawling + // watchman: true, +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..9dfd0b7 --- /dev/null +++ b/package.json @@ -0,0 +1,68 @@ +{ + "name": "github-cognito-openid-wrapper", + "version": "1.1.0", + "description": "A wrapper to enable AWS Cognito User Pools (which requires OpenID Connect) to talk to GitHub (which only provides OAuth2.0)", + "main": "dist-web/server.js", + "repository": { + "type": "git", + "url": "https://github.com/TimothyJones/github-cognito-openid-wrapper.git" + }, + "scripts": { + "build": "webpack", + "test": "jest --runInBand --coverage", + "test-dev": "jest --runInBand --watch", + "start": "webpack --watch --display errors-only", + "lint": "eslint 'src/**' --ext .js", + "preinstall": "./scripts/create-key.sh", + "prebuild-dist": "npm run lint && npm run test", + "build-dist": "npm run build", + "predeploy": "npm run build-dist", + "deploy": "./scripts/deploy.sh", + "coverage": "jest --runInBand --coverage", + "snyk-protect": "snyk protect", + "prepare": "npm run snyk-protect" + }, + "author": "", + "license": "ISC", + "dependencies": { + "axios": "^0.19.0", + "body-parser": "^1.18.3", + "colors": "^1.3.2", + "express": "^4.16.3", + "json-web-key": "^0.3.0", + "jsonwebtoken": "^8.3.0", + "winston": "^3.2.1", + "winston-splunk-httplogger": "^2.2.0" + }, + "devDependencies": { + "@babel/core": "^7.3.4", + "@babel/preset-env": "^7.3.4", + "@pact-foundation/pact": "^9.5.0", + "babel-jest": "^24.1.0", + "babel-loader": "^8.0.2", + "chai": "^4.1.2", + "chai-as-promised": "^7.1.1", + "chai-jest-diff": "^1.0.2", + "eslint": "^5.15.1", + "eslint-config-airbnb-base": "^13.1.0", + "eslint-config-prettier": "^3.0.1", + "eslint-plugin-chai-expect": "^1.1.1", + "eslint-plugin-chai-friendly": "^0.4.1", + "eslint-plugin-import": "^2.14.0", + "eslint-plugin-jest": "^21.22.0", + "jest": "^24.1.0", + "nodemon": "^1.18.7", + "nodemon-webpack-plugin": "^4.0.6", + "prettier": "^1.14.2", + "raw-loader": "^0.5.1", + "snyk": "^1.232.0", + "to": "^0.2.9", + "webpack": "^4.41.2", + "webpack-cli": "^3.1.0", + "webpack-node-externals": "^1.7.2" + }, + "engines": { + "node": ">=10" + }, + "snyk": true +} diff --git a/scripts/create-key.sh b/scripts/create-key.sh new file mode 100755 index 0000000..bf74432 --- /dev/null +++ b/scripts/create-key.sh @@ -0,0 +1,15 @@ +#!/bin/bash -eu +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")"; pwd)" # Figure out where the script is running +. "$SCRIPT_DIR"/lib-robust-bash.sh # load the robust bash library +PROJECT_ROOT="$SCRIPT_DIR"/.. # Figure out where the project directory is + +require_binary ssh-keygen +require_binary openssl + +KEY_FILE="$PROJECT_ROOT"/jwtRS256.key + +if [ ! -f "$KEY_FILE" ]; then + echo " --- Creating private key, as it does not exist ---" + ssh-keygen -t rsa -b 4096 -m PEM -f "$KEY_FILE" -N '' + openssl rsa -in "$KEY_FILE" -pubout -outform PEM -out "$KEY_FILE".pub +fi diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..5a69e3a --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,24 @@ +#!/bin/bash -eu +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")"; pwd)" # Figure out where the script is running +. "$SCRIPT_DIR"/lib-robust-bash.sh # load the robust bash library +PROJECT_ROOT="$SCRIPT_DIR"/.. # Figure out where the project directory is + +# Ensure dependencies are present + +require_binary aws +require_binary sam + +# Ensure configuration is present + +if [ ! -f "$PROJECT_ROOT/config.sh" ]; then + echo "ERROR: config.sh is missing. Copy example-config.sh and modify as appropriate." + echo " cp example-config.sh config.sh" + exit 1 +fi +source ./config.sh + + +OUTPUT_TEMPLATE_FILE="$PROJECT_ROOT/serverless-output.yml" +aws s3 mb "s3://$BUCKET_NAME" --region "$REGION" || true +sam package --template-file template.yml --output-template-file "$OUTPUT_TEMPLATE_FILE" --s3-bucket "$BUCKET_NAME" +sam deploy --region "$REGION" --template-file "$OUTPUT_TEMPLATE_FILE" --stack-name "$STACK_NAME" --parameter-overrides GitHubClientIdParameter="$GITHUB_CLIENT_ID" GitHubClientSecretParameter="$GITHUB_CLIENT_SECRET" CognitoRedirectUriParameter="$COGNITO_REDIRECT_URI" StageNameParameter="$STAGE_NAME" --capabilities CAPABILITY_IAM diff --git a/scripts/lib-robust-bash.sh b/scripts/lib-robust-bash.sh new file mode 100644 index 0000000..cb0ec8e --- /dev/null +++ b/scripts/lib-robust-bash.sh @@ -0,0 +1,14 @@ +#!/bin/bash -eu + +# Check to see that we have a required binary on the path +function require_binary { + if [ -z "${1:-}" ]; then + error "${FUNCNAME[0]} requires an argument" + exit 1 + fi + + if ! [ -x "$(command -v "$1")" ]; then + error "The required executable '$1' is not on the path." + exit 1 + fi +} diff --git a/src/__mocks__/privateKeyMock.js b/src/__mocks__/privateKeyMock.js new file mode 100644 index 0000000..8ffbb25 --- /dev/null +++ b/src/__mocks__/privateKeyMock.js @@ -0,0 +1,53 @@ +// These keys exist for testing outside webpack. +// They should not be used in production. +module.exports = `-----BEGIN RSA PRIVATE KEY----- +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 +-----END RSA PRIVATE KEY-----`; diff --git a/src/__mocks__/publicKeyMock.js b/src/__mocks__/publicKeyMock.js new file mode 100644 index 0000000..2201fff --- /dev/null +++ b/src/__mocks__/publicKeyMock.js @@ -0,0 +1,16 @@ +// These keys exist for testing outside webpack. +// They should not be used in production. +module.exports = `-----BEGIN PUBLIC KEY----- +MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAvT8Gp2s+dau70ANkGC99 +2iALb7b+o5LJvEQhgfLF3AA8qLbZekFobWHr2oKGEl/MFMD4lGoKB1zgfqfPjwNF +YkEV2Hr4K0c5ULTP4nTVB5c9h66Zg4V3VeatR6UwG5f5O3HPRn3zKAJq+3qfde3Z +phEGgMVmhaouXzT3c9gHzZFbrMNORM/VfyvMhco760mDo/Z0mCvV1qvEi/GVnnst +9KLnp1ZF6Qgq9LF9oBfsnahKRGLUq5X29/onWi/CDQehf8npNI78ypbpqSay2FsG +2WXS05LSfE9KgbwFjSaY/npFk8guTBx7xidFWrCMQZLxP3EKgfK1ehfkiNkdTB28 +8g0jhtnPv0t2yhnUoOIRQN4kH2iylkwo1zjqysPwmQRATDnVwvRzrr1f9phV8hYE +j1N33xVpMJ999vlSlBMcYkC5MDE/onI+cJD1uJw3qk2znMN+JOCSu55gn5mk2k4a +BhMujhyTliM7yikYYpscB/+Qo37NDzSEgVZkzu/cBo9lyAshB2oHLMpBzyjVCpE4 ++A92QvNKLouAjvG6LNioLpBj89Jmyr+jqSNDGhgOlIZVzmxJPWktKI00O1BkxrHM +WZayND8CqZnkITHGJGOv2Yh7v7V+I8GuD1knnKdBvrC3dCjh45/T7jAC6eyIyq9u +0L9itaNdDRhTx1TIIPwTBKsCAwEAAQ== +-----END PUBLIC KEY-----`; diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..db8acd6 --- /dev/null +++ b/src/config.js @@ -0,0 +1,15 @@ +module.exports = { + GITHUB_CLIENT_ID: process.env.GITHUB_CLIENT_ID, + GITHUB_CLIENT_SECRET: process.env.GITHUB_CLIENT_SECRET, + COGNITO_REDIRECT_URI: process.env.COGNITO_REDIRECT_URI, + GITHUB_API_URL: process.env.GITHUB_API_URL, + GITHUB_LOGIN_URL: process.env.GITHUB_LOGIN_URL, + PORT: parseInt(process.env.PORT, 10) || undefined, + + // Splunk logging variables + SPLUNK_URL: process.env.SPLUNK_URL, + SPLUNK_TOKEN: process.env.SPLUNK_TOKEN, + SPLUNK_SOURCE: process.env.SPLUNK_SOURCE, + SPLUNK_SOURCETYPE: process.env.SPLUNK_SOURCETYPE, + SPLUNK_INDEX: process.env.SPLUNK_INDEX +}; diff --git a/src/connectors/controllers.js b/src/connectors/controllers.js new file mode 100644 index 0000000..fec56d2 --- /dev/null +++ b/src/connectors/controllers.js @@ -0,0 +1,80 @@ +const logger = require('./logger'); +const openid = require('../openid'); + +module.exports = respond => ({ + authorize: (client_id, scope, state, response_type) => { + const authorizeUrl = openid.getAuthorizeUrl( + client_id, + scope, + state, + response_type + ); + logger.info('Redirecting to authorizeUrl'); + logger.debug('Authorize Url is: %s', authorizeUrl, {}); + respond.redirect(authorizeUrl); + }, + userinfo: tokenPromise => { + tokenPromise + .then(token => openid.getUserInfo(token)) + .then(userInfo => { + logger.debug('Resolved user infos:', userInfo, {}); + respond.success(userInfo); + }) + .catch(error => { + logger.error( + 'Failed to provide user info: %s', + error.message || error, + {} + ); + respond.error(error); + }); + }, + token: (code, state, host) => { + if (code) { + openid + .getTokens(code, state, host) + .then(tokens => { + logger.debug( + 'Token for (%s, %s, %s) provided', + code, + state, + host, + {} + ); + respond.success(tokens); + }) + .catch(error => { + logger.error( + 'Token for (%s, %s, %s) failed: %s', + code, + state, + host, + error.message || error, + {} + ); + respond.error(error); + }); + } else { + const error = new Error('No code supplied'); + logger.error( + 'Token for (%s, %s, %s) failed: %s', + code, + state, + host, + error.message || error, + {} + ); + respond.error(error); + } + }, + jwks: () => { + const jwks = openid.getJwks(); + logger.info('Providing access to JWKS: %j', jwks, {}); + respond.success(jwks); + }, + openIdConfiguration: host => { + const config = openid.getConfigFor(host); + logger.info('Providing configuration for %s: %j', host, config, {}); + respond.success(config); + } +}); diff --git a/src/connectors/lambda/authorize.js b/src/connectors/lambda/authorize.js new file mode 100644 index 0000000..4891494 --- /dev/null +++ b/src/connectors/lambda/authorize.js @@ -0,0 +1,18 @@ +const responder = require('./util/responder'); +const controllers = require('../controllers'); + +module.exports.handler = (event, context, callback) => { + const { + client_id, + scope, + state, + response_type + } = event.queryStringParameters; + + controllers(responder(callback)).authorize( + client_id, + scope, + state, + response_type + ); +}; diff --git a/src/connectors/lambda/jwks.js b/src/connectors/lambda/jwks.js new file mode 100644 index 0000000..9df2c35 --- /dev/null +++ b/src/connectors/lambda/jwks.js @@ -0,0 +1,6 @@ +const responder = require('./util/responder'); +const controllers = require('../controllers'); + +module.exports.handler = (event, context, callback) => { + controllers(responder(callback)).jwks(); +}; diff --git a/src/connectors/lambda/open-id-configuration.js b/src/connectors/lambda/open-id-configuration.js new file mode 100644 index 0000000..097bda6 --- /dev/null +++ b/src/connectors/lambda/open-id-configuration.js @@ -0,0 +1,12 @@ +const responder = require('./util/responder'); +const auth = require('./util/auth'); +const controllers = require('../controllers'); + +module.exports.handler = (event, context, callback) => { + controllers(responder(callback)).openIdConfiguration( + auth.getIssuer( + event.headers.Host, + event.requestContext && event.requestContext.stage + ) + ); +}; diff --git a/src/connectors/lambda/token.js b/src/connectors/lambda/token.js new file mode 100644 index 0000000..3074e3f --- /dev/null +++ b/src/connectors/lambda/token.js @@ -0,0 +1,34 @@ +const qs = require('querystring'); +const responder = require('./util/responder'); +const auth = require('./util/auth'); +const controllers = require('../controllers'); + +const parseBody = event => { + const contentType = event.headers['Content-Type']; + if (event.body) { + if (contentType.startsWith('application/x-www-form-urlencoded')) { + return qs.parse(event.body); + } + if (contentType.startsWith('application/json')) { + return JSON.parse(event.body); + } + } + return {}; +}; + +module.exports.handler = (event, context, callback) => { + const body = parseBody(event); + const query = event.queryStringParameters || {}; + + const code = body.code || query.code; + const state = body.state || query.state; + + controllers(responder(callback)).token( + code, + state, + auth.getIssuer( + event.headers.Host, + event.requestContext && event.requestContext.stage + ) + ); +}; diff --git a/src/connectors/lambda/userinfo.js b/src/connectors/lambda/userinfo.js new file mode 100644 index 0000000..8e8293b --- /dev/null +++ b/src/connectors/lambda/userinfo.js @@ -0,0 +1,7 @@ +const responder = require('./util/responder'); +const auth = require('./util/auth'); +const controllers = require('../controllers'); + +module.exports.handler = (event, context, callback) => { + controllers(responder(callback)).userinfo(auth.getBearerToken(event)); +}; diff --git a/src/connectors/lambda/util/auth.js b/src/connectors/lambda/util/auth.js new file mode 100644 index 0000000..883b7b6 --- /dev/null +++ b/src/connectors/lambda/util/auth.js @@ -0,0 +1,44 @@ +const logger = require('../../logger'); + +module.exports = { + getBearerToken: req => + new Promise((resolve, reject) => { + // This method implements https://tools.ietf.org/html/rfc6750 + const authHeader = req.headers.Authorization; + logger.debug('Detected authorization header %s', authHeader); + if (authHeader) { + // Section 2.1 Authorization request header + // Should be of the form 'Bearer ' + // We can ignore the 'Bearer ' bit + const authValue = authHeader.split(' ')[1]; + logger.debug('Section 2.1 Authorization bearer header: %s', authValue); + resolve(authValue); + } else if (req.queryStringParameters.access_token) { + // Section 2.3 URI query parameter + const accessToken = req.queryStringParameters.access_token; + logger.debug( + 'Section 2.3 Authorization query parameter: %s', + accessToken + ); + resolve(req.queryStringParameters.access_token); + } else if ( + req.headers['Content-Type'] === 'application/x-www-form-urlencoded' && + req.body + ) { + // Section 2.2 form encoded body parameter + const body = JSON.parse(req.body); + logger.debug('Section 2.2. Authorization form encoded body: %s', body); + resolve(body.access_token); + } else { + const msg = 'No token specified in request'; + logger.warn(msg); + reject(new Error(msg)); + } + }), + + getIssuer: (host, stage) => { + const lStage = stage; + const issuer = `${host}/${lStage}`; + return issuer; + } +}; diff --git a/src/connectors/lambda/util/responder.js b/src/connectors/lambda/util/responder.js new file mode 100644 index 0000000..9271f94 --- /dev/null +++ b/src/connectors/lambda/util/responder.js @@ -0,0 +1,35 @@ +const logger = require('../../logger'); + +module.exports = callback => ({ + success: response => { + logger.info('Success response'); + logger.debug('Response was: ', response); + callback(null, { + statusCode: 200, + body: JSON.stringify(response), + headers: { + 'Content-Type': 'application/json' + } + }); + }, + error: err => { + logger.error('Error response: ', err.message || err); + callback(null, { + statusCode: 400, + body: JSON.stringify(err.message), + headers: { + 'Content-Type': 'application/json' + } + }); + }, + redirect: url => { + logger.info('Redirect response'); + logger.debug('Redirect response to %s', url, {}); + callback(null, { + statusCode: 302, + headers: { + Location: url + } + }); + } +}); diff --git a/src/connectors/logger.js b/src/connectors/logger.js new file mode 100644 index 0000000..046468a --- /dev/null +++ b/src/connectors/logger.js @@ -0,0 +1,49 @@ +const winston = require('winston'); +const { + SPLUNK_URL, + SPLUNK_TOKEN, + SPLUNK_SOURCE, + SPLUNK_SOURCETYPE, + SPLUNK_INDEX +} = require('../config'); + +const logger = winston.createLogger({ + level: 'info' +}); + +// Activate Splunk logging if Splunk's env variables are set +if (SPLUNK_URL) { + const SplunkStreamEvent = require('winston-splunk-httplogger'); // eslint-disable-line global-require + + const splunkSettings = { + url: SPLUNK_URL || 'localhost', + token: SPLUNK_TOKEN, + source: SPLUNK_SOURCE || '/var/log/GHOIdShim.log', + sourcetype: SPLUNK_SOURCETYPE || 'github-cognito-openid-wrapper', + index: SPLUNK_INDEX || 'main', + maxBatchCount: 1 + }; + + logger.add( + new SplunkStreamEvent({ + splunk: splunkSettings, + format: winston.format.combine( + winston.format.splat(), + winston.format.timestamp() + ) + }) + ); +} else { + // STDOUT logging for dev/regular servers + logger.add( + new winston.transports.Console({ + format: winston.format.combine( + winston.format.splat(), + winston.format.colorize({ all: true }), + winston.format.simple() + ) + }) + ); +} + +module.exports = logger; diff --git a/src/connectors/web/app.js b/src/connectors/web/app.js new file mode 100644 index 0000000..782a8b3 --- /dev/null +++ b/src/connectors/web/app.js @@ -0,0 +1,24 @@ +const express = require('express'); +const bodyParser = require('body-parser'); +const routes = require('./routes'); +const { PORT } = require('../../config'); +const validateConfig = require('../../validate-config'); + +require('colors'); + +const app = express(); + +try { + validateConfig(); +} catch (e) { + console.error('Failed to start:'.red, e.message); + console.error(' See the readme for configuration information'); + process.exit(1); +} +console.info('Config is valid'.cyan); + +app.use(bodyParser.json()); +app.use(bodyParser.urlencoded({ extended: true })); +routes(app); +app.listen(PORT); +console.info(`Listening on ${PORT}`.cyan); diff --git a/src/connectors/web/auth.js b/src/connectors/web/auth.js new file mode 100644 index 0000000..75b288b --- /dev/null +++ b/src/connectors/web/auth.js @@ -0,0 +1,24 @@ +module.exports = { + getBearerToken: req => + new Promise((resolve, reject) => { + // This method implements https://tools.ietf.org/html/rfc6750 + const authHeader = req.get('Authorization'); + if (authHeader) { + // Section 2.1 Authorization request header + // Should be of the form 'Bearer ' + // We can ignore the 'Bearer ' bit + resolve(authHeader.split(' ')[1]); + } else if (req.query.access_token) { + // Section 2.3 URI query parameter + resolve(req.query.access_token); + } else if ( + req.get('Content-Type') === 'application/x-www-form-urlencoded' + ) { + // Section 2.2 form encoded body parameter + resolve(req.body.access_token); + } + reject(new Error('No token specified in request')); + }), + + getIssuer: host => `${host}` +}; diff --git a/src/connectors/web/handlers.js b/src/connectors/web/handlers.js new file mode 100644 index 0000000..50d8fc5 --- /dev/null +++ b/src/connectors/web/handlers.js @@ -0,0 +1,29 @@ +const responder = require('./responder'); +const auth = require('./auth'); +const controllers = require('../controllers'); + +module.exports = { + userinfo: (req, res) => { + controllers(responder(res)).userinfo(auth.getBearerToken(req)); + }, + token: (req, res) => { + const code = req.body.code || req.query.code; + const state = req.body.state || req.query.state; + + controllers(responder(res)).token(code, state, req.get('host')); + }, + jwks: (req, res) => controllers(responder(res)).jwks(), + authorize: (req, res) => + responder(res).redirect( + `https://github.com/login/oauth/authorize?client_id=${ + req.query.client_id + }&scope=${req.query.scope}&state=${req.query.state}&response_type=${ + req.query.response_type + }` + ), + openIdConfiguration: (req, res) => { + controllers(responder(res)).openIdConfiguration( + auth.getIssuer(req.get('host')) + ); + } +}; diff --git a/src/connectors/web/responder.js b/src/connectors/web/responder.js new file mode 100644 index 0000000..c5c0f58 --- /dev/null +++ b/src/connectors/web/responder.js @@ -0,0 +1,21 @@ +const util = require('util'); + +require('colors'); + +module.exports = res => ({ + success: data => { + res.format({ + 'application/json': () => { + res.json(data); + }, + default: () => { + res.status(406).send('Not Acceptable'); + } + }); + }, + error: error => { + res.statusCode = 400; + res.end(`Failure: ${util.inspect(error.message)}`); + }, + redirect: url => res.redirect(url) +}); diff --git a/src/connectors/web/routes.js b/src/connectors/web/routes.js new file mode 100644 index 0000000..819b7c4 --- /dev/null +++ b/src/connectors/web/routes.js @@ -0,0 +1,13 @@ +const handlers = require('./handlers'); + +module.exports = app => { + app.get('/userinfo', handlers.userinfo); + app.post('/userinfo', handlers.userinfo); + app.get('/token', handlers.token); + app.post('/token', handlers.token); + app.get('/authorize', handlers.authorize); + app.post('/authorize', handlers.authorize); + app.get('/jwks.json', handlers.jwks); + app.get('/.well-known/jwks.json', handlers.jwks); + app.get('/.well-known/openid-configuration', handlers.openIdConfiguration); +}; diff --git a/src/crypto.js b/src/crypto.js new file mode 100644 index 0000000..59ff87a --- /dev/null +++ b/src/crypto.js @@ -0,0 +1,30 @@ +const JSONWebKey = require('json-web-key'); +const jwt = require('jsonwebtoken'); +const { GITHUB_CLIENT_ID } = require('./config'); +const logger = require('./connectors/logger'); + +const KEY_ID = 'jwtRS256'; +const cert = require('../jwtRS256.key'); +const pubKey = require('../jwtRS256.key.pub'); + +module.exports = { + getPublicKey: () => ({ + alg: 'RS256', + kid: KEY_ID, + ...JSONWebKey.fromPEM(pubKey).toJSON() + }), + + makeIdToken: (payload, host) => { + const enrichedPayload = { + ...payload, + iss: `https://${host}`, + aud: GITHUB_CLIENT_ID + }; + logger.debug('Signing payload %j', enrichedPayload, {}); + return jwt.sign(enrichedPayload, cert, { + expiresIn: '1h', + algorithm: 'RS256', + keyid: KEY_ID + }); + } +}; diff --git a/src/github.js b/src/github.js new file mode 100644 index 0000000..da0f723 --- /dev/null +++ b/src/github.js @@ -0,0 +1,93 @@ +const axios = require('axios'); +const { + GITHUB_CLIENT_ID, + GITHUB_CLIENT_SECRET, + COGNITO_REDIRECT_URI, + GITHUB_API_URL, + GITHUB_LOGIN_URL +} = require('./config'); +const logger = require('./connectors/logger'); + +const getApiEndpoints = ( + apiBaseUrl = GITHUB_API_URL, + loginBaseUrl = GITHUB_LOGIN_URL +) => ({ + userDetails: `${apiBaseUrl}/user`, + userEmails: `${apiBaseUrl}/user/emails`, + oauthToken: `${loginBaseUrl}/login/oauth/access_token`, + oauthAuthorize: `${loginBaseUrl}/login/oauth/authorize` +}); + +const check = response => { + logger.debug('Checking response: %j', response, {}); + if (response.data) { + if (response.data.error) { + throw new Error( + `GitHub API responded with a failure: ${response.data.error}, ${ + response.data.error_description + }` + ); + } else if (response.status === 200) { + return response.data; + } + } + throw new Error( + `GitHub API responded with a failure: ${response.status} (${ + response.statusText + })` + ); +}; + +const gitHubGet = (url, accessToken) => + axios({ + method: 'get', + url, + headers: { + Accept: 'application/vnd.github.v3+json', + Authorization: `token ${accessToken}` + } + }); + +module.exports = (apiBaseUrl, loginBaseUrl) => { + const urls = getApiEndpoints(apiBaseUrl, loginBaseUrl || apiBaseUrl); + return { + getAuthorizeUrl: (client_id, scope, state, response_type) => + `${urls.oauthAuthorize}?client_id=${client_id}&scope=${encodeURIComponent( + scope + )}&state=${state}&response_type=${response_type}`, + getUserDetails: accessToken => + gitHubGet(urls.userDetails, accessToken).then(check), + getUserEmails: accessToken => + gitHubGet(urls.userEmails, accessToken).then(check), + getToken: (code, state) => { + const data = { + // OAuth required fields + grant_type: 'authorization_code', + redirect_uri: COGNITO_REDIRECT_URI, + client_id: GITHUB_CLIENT_ID, + // GitHub Specific + response_type: 'code', + client_secret: GITHUB_CLIENT_SECRET, + code, + // State may not be present, so we conditionally include it + ...(state && { state }) + }; + + logger.debug( + 'Getting token from %s with data: %j', + urls.oauthToken, + data, + {} + ); + return axios({ + method: 'post', + url: urls.oauthToken, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + }, + data + }).then(check); + } + }; +}; diff --git a/src/github.pact.test.js b/src/github.pact.test.js new file mode 100644 index 0000000..dcd3057 --- /dev/null +++ b/src/github.pact.test.js @@ -0,0 +1,359 @@ +const github = require('./github'); + +/* global provider PACT_BASE_URL */ + +jest.mock('./config', () => ({ + COGNITO_REDIRECT_URI: 'COGNITO_REDIRECT_URI', + GITHUB_CLIENT_SECRET: 'GITHUB_CLIENT_SECRET', + GITHUB_CLIENT_ID: 'GITHUB_CLIENT_ID', + GITHUB_API_URL: 'GITHUB_API_URL', + GITHUB_LOGIN_URL: 'GITHUB_LOGIN_URL' +})); + +describe('With an increased jasmine timeout', () => { + let originalTimeout; + + beforeAll(() => { + // We have to increase the jasmine timeout in order to run pact + // on some machines / configurations, since the server sometimes + // takes a little longer to start + originalTimeout = jasmine.DEFAULT_TIMEOUT_INTERVAL; + jasmine.DEFAULT_TIMEOUT_INTERVAL = 30000; + }); + + afterAll(() => { + jasmine.DEFAULT_TIMEOUT_INTERVAL = originalTimeout; + }); + + describe('GitHub Client Pact', () => { + beforeAll(() => provider.setup()); + afterAll(() => provider.finalize()); + afterEach(() => provider.verify()); + + describe('UserDetails endpoint', () => { + const userDetailsRequest = { + uponReceiving: 'a request for user details', + withRequest: { + method: 'GET', + path: '/user', + headers: { + Accept: 'application/vnd.github.v3+json', + Authorization: `token THIS_IS_MY_TOKEN` + } + } + }; + describe('When the access token is good', () => { + const EXPECTED_BODY = { name: 'Tim Jones' }; + beforeEach(() => { + const interaction = { + ...userDetailsRequest, + state: 'Where the access token is good', + willRespondWith: { + status: 200, + headers: { + 'Content-Type': 'application/json' + }, + body: EXPECTED_BODY + } + }; + return provider.addInteraction(interaction); + }); + + // add expectations + it('returns a sucessful body', done => + github(PACT_BASE_URL) + .getUserDetails('THIS_IS_MY_TOKEN') + .then(response => { + expect(response).toEqual(EXPECTED_BODY); + done(); + })); + }); + describe('When the access token is bad', () => { + const EXPECTED_ERROR = { + error: 'This is an error', + error_description: 'This is a description' + }; + beforeEach(() => { + const interaction = { + ...userDetailsRequest, + state: 'Where the access token is bad', + willRespondWith: { + status: 400, + headers: { + 'Content-Type': 'application/json' + }, + body: EXPECTED_ERROR + } + }; + return provider.addInteraction(interaction); + }); + + // add expectations + it('rejects the promise', done => { + github(PACT_BASE_URL) + .getUserDetails('THIS_IS_MY_TOKEN') + .catch(() => { + done(); + }); + }); + }); + describe('When there is a server error response', () => { + const EXPECTED_ERROR = { + error: 'This is an error', + error_description: 'This is a description' + }; + beforeEach(() => { + const interaction = { + ...userDetailsRequest, + state: 'Where there is a server error response', + willRespondWith: { + status: 200, + headers: { + 'Content-Type': 'application/json' + }, + body: EXPECTED_ERROR + } + }; + return provider.addInteraction(interaction); + }); + + // add expectations + it('rejects the promise', done => { + github(PACT_BASE_URL) + .getUserDetails('THIS_IS_MY_TOKEN') + .catch(() => { + done(); + }); + }); + }); + }); + + describe('UserEmails endpoint', () => { + const userEmailsRequest = { + uponReceiving: 'a request for user emails', + withRequest: { + method: 'GET', + path: '/user/emails', + headers: { + Accept: 'application/vnd.github.v3+json', + Authorization: `token THIS_IS_MY_TOKEN` + } + } + }; + describe('When the access token is good', () => { + const EXPECTED_BODY = [{ email: 'ben@example.com', primary: true }]; + beforeEach(() => { + const interaction = { + ...userEmailsRequest, + state: 'Where the access token is good', + willRespondWith: { + status: 200, + headers: { + 'Content-Type': 'application/json' + }, + body: EXPECTED_BODY + } + }; + return provider.addInteraction(interaction); + }); + + // add expectations + it('returns a sucessful body', done => + github(PACT_BASE_URL) + .getUserEmails('THIS_IS_MY_TOKEN') + .then(response => { + expect(response).toEqual(EXPECTED_BODY); + done(); + })); + }); + describe('When the access token is bad', () => { + const EXPECTED_ERROR = { + error: 'This is an error', + error_description: 'This is a description' + }; + beforeEach(() => { + const interaction = { + ...userEmailsRequest, + state: 'Where the access token is bad', + willRespondWith: { + status: 400, + headers: { + 'Content-Type': 'application/json' + }, + body: EXPECTED_ERROR + } + }; + return provider.addInteraction(interaction); + }); + + // add expectations + it('rejects the promise', done => { + github(PACT_BASE_URL) + .getUserEmails('THIS_IS_MY_TOKEN') + .catch(() => { + done(); + }); + }); + }); + describe('When there is a server error response', () => { + const EXPECTED_ERROR = { + error: 'This is an error', + error_description: 'This is a description' + }; + beforeEach(() => { + const interaction = { + ...userEmailsRequest, + state: 'Where there is a server error response', + willRespondWith: { + status: 200, + headers: { + 'Content-Type': 'application/json' + }, + body: EXPECTED_ERROR + } + }; + return provider.addInteraction(interaction); + }); + + // add expectations + it('rejects the promise', done => { + github(PACT_BASE_URL) + .getUserEmails('THIS_IS_MY_TOKEN') + .catch(() => { + done(); + }); + }); + }); + }); + + describe('Authorization endpoint', () => { + describe('always', () => { + it('returns a redirect url', () => { + expect( + github(PACT_BASE_URL).getAuthorizeUrl( + 'client_id', + 'scope', + 'state', + 'response_type' + ) + ).to.equal( + `${PACT_BASE_URL}/login/oauth/authorize?client_id=client_id&scope=scope&state=state&response_type=response_type` + ); + }); + }); + }); + + describe('Auth Token endpoint', () => { + const accessTokenRequest = { + uponReceiving: 'a request for an access token', + withRequest: { + method: 'POST', + path: '/login/oauth/access_token', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + }, + body: { + // OAuth required fields + grant_type: 'authorization_code', + redirect_uri: 'COGNITO_REDIRECT_URI', + client_id: 'GITHUB_CLIENT_ID', + // GitHub Specific + response_type: 'code', + client_secret: 'GITHUB_CLIENT_SECRET', + code: 'SOME_CODE' + } + } + }; + + describe('When the code is good', () => { + const EXPECTED_BODY = { + access_token: 'xxxx', + refresh_token: 'yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy', + expires_in: 21600 + }; + beforeEach(() => { + const interaction = { + ...accessTokenRequest, + state: 'Where the code is good', + willRespondWith: { + status: 200, + headers: { + 'Content-Type': 'application/json' + }, + body: EXPECTED_BODY + } + }; + return provider.addInteraction(interaction); + }); + + // add expectations + it('returns a sucessful body', done => + github(PACT_BASE_URL) + .getToken('SOME_CODE') + .then(response => { + expect(response).toEqual(EXPECTED_BODY); + done(); + })); + }); + describe('When the code is bad', () => { + const EXPECTED_ERROR = { + error: 'This is an error', + error_description: 'This is a description' + }; + beforeEach(() => { + const interaction = { + ...accessTokenRequest, + state: 'Where the code is bad', + willRespondWith: { + status: 400, + headers: { + 'Content-Type': 'application/json' + }, + body: EXPECTED_ERROR + } + }; + return provider.addInteraction(interaction); + }); + + // add expectations + it('rejects the promise', done => { + github(PACT_BASE_URL) + .getToken('SOME_CODE') + .catch(() => { + done(); + }); + }); + }); + describe('When there is a server error response', () => { + const EXPECTED_ERROR = { + error: 'This is an error', + error_description: 'This is a description' + }; + beforeEach(() => { + const interaction = { + ...accessTokenRequest, + state: 'Where there is a server error response', + willRespondWith: { + status: 200, + headers: { + 'Content-Type': 'application/json' + }, + body: EXPECTED_ERROR + } + }; + return provider.addInteraction(interaction); + }); + + // add expectations + it('rejects the promise', done => { + github(PACT_BASE_URL) + .getToken('SOME_CODE') + .catch(() => { + done(); + }); + }); + }); + }); + }); +}); diff --git a/src/helpers.js b/src/helpers.js new file mode 100644 index 0000000..a4e43ac --- /dev/null +++ b/src/helpers.js @@ -0,0 +1,3 @@ +module.exports = { + NumericDate: date => Math.floor(date / 1000) +}; diff --git a/src/helpers.test.js b/src/helpers.test.js new file mode 100644 index 0000000..7169ec3 --- /dev/null +++ b/src/helpers.test.js @@ -0,0 +1,16 @@ +const { NumericDate } = require('./helpers'); + +describe('NumericDate function', () => { + describe('with a date that could be rounded up', () => { + const date = new Date(1233999); + it('Returns the date in whole seconds since epoch', () => { + expect(NumericDate(date)).to.equal(1233); + }); + }); + describe('with a date that would not be rounded up', () => { + const date = new Date(1234000); + it('Returns the date in whole seconds since epoch', () => { + expect(NumericDate(date)).to.equal(1234); + }); + }); +}); diff --git a/src/openid.js b/src/openid.js new file mode 100644 index 0000000..36bb962 --- /dev/null +++ b/src/openid.js @@ -0,0 +1,149 @@ +const logger = require('./connectors/logger'); +const { NumericDate } = require('./helpers'); +const crypto = require('./crypto'); +const github = require('./github'); + +const getJwks = () => ({ keys: [crypto.getPublicKey()] }); + +const getUserInfo = accessToken => + Promise.all([ + github() + .getUserDetails(accessToken) + .then(userDetails => { + logger.debug('Fetched user details: %j', userDetails, {}); + // Here we map the github user response to the standard claims from + // OpenID. The mapping was constructed by following + // https://developer.github.com/v3/users/ + // and http://openid.net/specs/openid-connect-core-1_0.html#StandardClaims + const claims = { + sub: `${userDetails.id}`, // OpenID requires a string + name: userDetails.name, + preferred_username: userDetails.login, + profile: userDetails.html_url, + picture: userDetails.avatar_url, + website: userDetails.blog, + updated_at: NumericDate( + // OpenID requires the seconds since epoch in UTC + new Date(Date.parse(userDetails.updated_at)) + ) + }; + logger.debug('Resolved claims: %j', claims, {}); + return claims; + }), + github() + .getUserEmails(accessToken) + .then(userEmails => { + logger.debug('Fetched user emails: %j', userEmails, {}); + const primaryEmail = userEmails.find(email => email.primary); + if (primaryEmail === undefined) { + throw new Error('User did not have a primary email address'); + } + const claims = { + email: primaryEmail.email, + email_verified: primaryEmail.verified + }; + logger.debug('Resolved claims: %j', claims, {}); + return claims; + }) + ]).then(claims => { + const mergedClaims = claims.reduce( + (acc, claim) => ({ ...acc, ...claim }), + {} + ); + logger.debug('Resolved combined claims: %j', mergedClaims, {}); + return mergedClaims; + }); + +const getAuthorizeUrl = (client_id, scope, state, response_type) => + github().getAuthorizeUrl(client_id, scope, state, response_type); + +const getTokens = (code, state, host) => + github() + .getToken(code, state) + .then(githubToken => { + logger.debug('Got token: %s', githubToken, {}); + // GitHub returns scopes separated by commas + // But OAuth wants them to be spaces + // https://tools.ietf.org/html/rfc6749#section-5.1 + // Also, we need to add openid as a scope, + // since GitHub will have stripped it + const scope = `openid ${githubToken.scope.replace(',', ' ')}`; + + // ** JWT ID Token required fields ** + // iss - issuer https url + // aud - audience that this token is valid for (GITHUB_CLIENT_ID) + // sub - subject identifier - must be unique + // ** Also required, but provided by jsonwebtoken ** + // exp - expiry time for the id token (seconds since epoch in UTC) + // iat - time that the JWT was issued (seconds since epoch in UTC) + + return new Promise(resolve => { + const payload = { + // This was commented because Cognito times out in under a second + // and generating the userInfo takes too long. + // It means the ID token is empty except for metadata. + // ...userInfo, + }; + + const idToken = crypto.makeIdToken(payload, host); + const tokenResponse = { + ...githubToken, + scope, + id_token: idToken + }; + + logger.debug('Resolved token response: %j', tokenResponse, {}); + + resolve(tokenResponse); + }); + }); + +const getConfigFor = host => ({ + issuer: `https://${host}`, + authorization_endpoint: `https://${host}/authorize`, + token_endpoint: `https://${host}/token`, + token_endpoint_auth_methods_supported: [ + 'client_secret_basic', + 'private_key_jwt' + ], + token_endpoint_auth_signing_alg_values_supported: ['RS256'], + userinfo_endpoint: `https://${host}/userinfo`, + // check_session_iframe: 'https://server.example.com/connect/check_session', + // end_session_endpoint: 'https://server.example.com/connect/end_session', + jwks_uri: `https://${host}/.well-known/jwks.json`, + // registration_endpoint: 'https://server.example.com/connect/register', + scopes_supported: ['openid', 'read:user', 'user:email'], + response_types_supported: [ + 'code', + 'code id_token', + 'id_token', + 'token id_token' + ], + + subject_types_supported: ['public'], + userinfo_signing_alg_values_supported: ['none'], + id_token_signing_alg_values_supported: ['RS256'], + request_object_signing_alg_values_supported: ['none'], + display_values_supported: ['page', 'popup'], + claims_supported: [ + 'sub', + 'name', + 'preferred_username', + 'profile', + 'picture', + 'website', + 'email', + 'email_verified', + 'updated_at', + 'iss', + 'aud' + ] +}); + +module.exports = { + getTokens, + getUserInfo, + getJwks, + getConfigFor, + getAuthorizeUrl +}; diff --git a/src/openid.test.js b/src/openid.test.js new file mode 100644 index 0000000..79c5ba2 --- /dev/null +++ b/src/openid.test.js @@ -0,0 +1,196 @@ +const openid = require('./openid'); +const github = require('./github'); +const crypto = require('./crypto'); + +jest.mock('./github'); +jest.mock('./crypto'); + +const MOCK_TOKEN = 'MOCK_TOKEN'; +const MOCK_CODE = 'MOCK_CODE'; + +describe('openid domain layer', () => { + const githubMock = { + getUserEmails: jest.fn(), + getUserDetails: jest.fn(), + getToken: jest.fn(), + getAuthorizeUrl: jest.fn() + }; + + beforeEach(() => { + github.mockImplementation(() => githubMock); + }); + + describe('userinfo function', () => { + const mockEmailsWithPrimary = withPrimary => { + githubMock.getUserEmails.mockImplementation(() => + Promise.resolve([ + { + primary: false, + email: 'not-this-email@example.com', + verified: false + }, + { primary: withPrimary, email: 'email@example.com', verified: true } + ]) + ); + }; + + describe('with a good token', () => { + describe('with complete user details', () => { + beforeEach(() => { + githubMock.getUserDetails.mockImplementation(() => + Promise.resolve({ + sub: 'Some sub', + name: 'some name', + login: 'username', + html_url: 'some profile', + avatar_url: 'picture.jpg', + blog: 'website', + updated_at: '2008-01-14T04:33:35Z' + }) + ); + }); + describe('with a primary email', () => { + beforeEach(() => { + mockEmailsWithPrimary(true); + }); + it('Returns the aggregated complete object', async () => { + const response = await openid.getUserInfo(MOCK_TOKEN); + expect(response).to.deep.equal({ + email: 'email@example.com', + email_verified: true, + name: 'some name', + picture: 'picture.jpg', + preferred_username: 'username', + profile: 'some profile', + sub: 'undefined', + updated_at: 1200285215, + website: 'website' + }); + }); + }); + describe('without a primary email', () => { + beforeEach(() => { + mockEmailsWithPrimary(false); + }); + it('fails', () => + expect(openid.getUserInfo('MOCK_TOKEN')).to.eventually.be.rejected); + }); + }); + }); + describe('with a bad token', () => { + beforeEach(() => { + githubMock.getUserDetails.mockImplementation(() => + Promise.reject(new Error('Bad token')) + ); + githubMock.getUserEmails.mockImplementation(() => + Promise.reject(new Error('Bad token')) + ); + }); + it('fails', () => + expect(openid.getUserInfo('bad token')).to.eventually.be.rejected); + }); + }); + describe('token function', () => { + describe('with the correct code', () => { + beforeEach(() => { + githubMock.getToken.mockImplementation(() => + Promise.resolve({ + access_token: 'SOME_TOKEN', + token_type: 'bearer', + scope: 'scope1,scope2' + }) + ); + crypto.makeIdToken.mockImplementation(() => 'ENCODED TOKEN'); + }); + + it('returns a token', async () => { + const token = await openid.getTokens( + MOCK_CODE, + 'some state', + 'somehost.com' + ); + expect(token).to.deep.equal({ + access_token: 'SOME_TOKEN', + id_token: 'ENCODED TOKEN', + scope: 'openid scope1 scope2', + token_type: 'bearer' + }); + }); + }); + describe('with a bad code', () => { + beforeEach(() => { + githubMock.getToken.mockImplementation(() => + Promise.reject(new Error('Bad code')) + ); + }); + it('fails', () => + expect(openid.getUserInfo('bad token', 'two', 'three')).to.eventually.be + .rejected); + }); + }); + describe('jwks', () => { + it('Returns the right structure', () => { + const mockKey = { key: 'mock' }; + crypto.getPublicKey.mockImplementation(() => mockKey); + expect(openid.getJwks()).to.deep.equal({ keys: [mockKey] }); + }); + }); + describe('authorization', () => { + beforeEach(() => { + githubMock.getAuthorizeUrl.mockImplementation( + (client_id, scope, state, response_type) => + `https://not-a-real-host.com/authorize?client_id=${client_id}&scope=${scope}&state=${state}&response_type=${response_type}` + ); + }); + it('Redirects to the authorization URL', () => { + expect( + openid.getAuthorizeUrl('client_id', 'scope', 'state', 'response_type') + ).to.equal( + 'https://not-a-real-host.com/authorize?client_id=client_id&scope=scope&state=state&response_type=response_type' + ); + }); + }); + describe('openid-configuration', () => { + describe('with a supplied hostname', () => { + it('returns the correct response', () => { + expect(openid.getConfigFor('not-a-real-host.com')).to.deep.equal({ + authorization_endpoint: 'https://not-a-real-host.com/authorize', + claims_supported: [ + 'sub', + 'name', + 'preferred_username', + 'profile', + 'picture', + 'website', + 'email', + 'email_verified', + 'updated_at', + 'iss', + 'aud' + ], + display_values_supported: ['page', 'popup'], + id_token_signing_alg_values_supported: ['RS256'], + issuer: 'https://not-a-real-host.com', + jwks_uri: 'https://not-a-real-host.com/.well-known/jwks.json', + request_object_signing_alg_values_supported: ['none'], + response_types_supported: [ + 'code', + 'code id_token', + 'id_token', + 'token id_token' + ], + scopes_supported: ['openid', 'read:user', 'user:email'], + subject_types_supported: ['public'], + token_endpoint: 'https://not-a-real-host.com/token', + token_endpoint_auth_methods_supported: [ + 'client_secret_basic', + 'private_key_jwt' + ], + token_endpoint_auth_signing_alg_values_supported: ['RS256'], + userinfo_endpoint: 'https://not-a-real-host.com/userinfo', + userinfo_signing_alg_values_supported: ['none'] + }); + }); + }); + }); +}); diff --git a/src/validate-config.js b/src/validate-config.js new file mode 100644 index 0000000..26bc6dd --- /dev/null +++ b/src/validate-config.js @@ -0,0 +1,30 @@ +const config = require('./config'); + +const ensureString = variableName => { + if (typeof config[variableName] !== 'string') { + throw new Error( + `Environment variable ${variableName} must be set and be a string` + ); + } +}; + +const ensureNumber = variableName => { + if (typeof config[variableName] !== 'number') { + throw new Error( + `Environment variable ${variableName} must be set and be a number` + ); + } +}; + +const requiredStrings = [ + 'GITHUB_CLIENT_ID', + 'GITHUB_CLIENT_SECRET', + 'COGNITO_REDIRECT_URI' +]; + +const requiredNumbers = ['PORT']; + +module.exports = () => { + requiredStrings.forEach(ensureString); + requiredNumbers.forEach(ensureNumber); +}; diff --git a/template.yml b/template.yml new file mode 100644 index 0000000..9ba05d8 --- /dev/null +++ b/template.yml @@ -0,0 +1,124 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: > + Github Cognito OpenID Wrapper (SSO) + +# More info about Globals: https://github.com/awslabs/serverless-application-model/blob/master/docs/globals.rst +Globals: + Function: + Runtime: nodejs10 + Timeout: 15 + Environment: + Variables: + GITHUB_CLIENT_ID: + Ref: GitHubClientIdParameter + GITHUB_CLIENT_SECRET: + Ref: GitHubClientSecretParameter + COGNITO_REDIRECT_URI: + Ref: CognitoRedirectUriParameter + GITHUB_API_URL: + Ref: GitHubUrlParameter + GITHUB_LOGIN_URL: + Ref: GitHubLoginUrlParameter + +Parameters: + GitHubClientIdParameter: + Type: String + GitHubClientSecretParameter: + Type: String + CognitoRedirectUriParameter: + Type: String + GitHubUrlParameter: + Type: String + Default: "https://api.github.com" + MinLength: 1 + GitHubLoginUrlParameter: + Type: String + Default: "https://github.com" + MinLength: 1 + StageNameParameter: + Type: String + +Resources: + GithubOAuthApi: + Type: AWS::Serverless::Api + Properties: + StageName: !Ref StageNameParameter + OpenApiVersion: "2.0" + OpenIdDiscovery: + Type: AWS::Serverless::Function + Properties: + CodeUri: ./dist-lambda + Handler: openIdConfiguration.handler + Events: + GetResource: + Type: Api + Properties: + Path: /.well-known/openid-configuration + Method: get + RestApiId: !Ref GithubOAuthApi + Authorize: + Type: AWS::Serverless::Function + Properties: + CodeUri: ./dist-lambda + Handler: authorize.handler + Events: + GetResource: + Type: Api + Properties: + Path: /authorize + Method: get + RestApiId: !Ref GithubOAuthApi + Token: + Type: AWS::Serverless::Function + Properties: + CodeUri: ./dist-lambda + Handler: token.handler + Events: + GetResource: + Type: Api + Properties: + Path: /token + Method: get + RestApiId: !Ref GithubOAuthApi + PostResource: + Type: Api + Properties: + Path: /token + Method: post + RestApiId: !Ref GithubOAuthApi + UserInfo: + Type: AWS::Serverless::Function + Properties: + CodeUri: ./dist-lambda + Handler: userinfo.handler + Events: + GetResource: + Type: Api + Properties: + Path: /userinfo + Method: get + RestApiId: !Ref GithubOAuthApi + PostResource: + Type: Api + Properties: + Path: /userinfo + Method: post + RestApiId: !Ref GithubOAuthApi + Jwks: + Type: AWS::Serverless::Function + Properties: + CodeUri: ./dist-lambda + Handler: jwks.handler + Events: + GetResource: + Type: Api + Properties: + Path: /.well-known/jwks.json + Method: get + RestApiId: !Ref GithubOAuthApi + +Outputs: + GitHubShimIssuer: + Description: "GitHub OpenID Shim Issuer" + Value: !Sub "https://${GithubOAuthApi}.execute-api.${AWS::Region}.amazonaws.com/${StageNameParameter}" diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 0000000..2f5eddb --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,60 @@ +const NodemonPlugin = require('nodemon-webpack-plugin'); +const nodeExternals = require('webpack-node-externals'); + +const baseConfig = { + mode: 'development', + target: 'node', + devtool: 'source-map', + module: { + rules: [ + { + test: /\.js$/, + exclude: /(node_modules)/, + use: { + loader: 'babel-loader' + } + }, + { + test: /\.(key|key.pub)$/, + use: [ + { + loader: 'raw-loader' + } + ] + } + ] + } +}; + +const config = [ + { + ...baseConfig, + output: { + libraryTarget: 'commonjs2', + path: `${__dirname}/dist-lambda`, + filename: '[name].js' + }, + entry: { + openIdConfiguration: './src/connectors/lambda/open-id-configuration.js', + token: './src/connectors/lambda/token.js', + userinfo: './src/connectors/lambda/userinfo.js', + jwks: './src/connectors/lambda/jwks.js', + authorize: './src/connectors/lambda/authorize.js' + } + }, + { + ...baseConfig, + output: { + libraryTarget: 'commonjs2', + path: `${__dirname}/dist-web`, + filename: '[name].js' + }, + entry: { + server: './src/connectors/web/app.js' + }, + externals: [nodeExternals()], + plugins: [new NodemonPlugin()] + } +]; + +module.exports = config; From f03ab7946875eb0cb904a19ad60a2dc5db6aa6d6 Mon Sep 17 00:00:00 2001 From: Andrew Plummer Date: Thu, 23 Jan 2020 13:17:17 +0000 Subject: [PATCH 2/8] Remove Pact --- README.md | 3 - config/setup-pact.js | 17 -- jest.config.js | 2 +- package.json | 1 - src/github.pact.test.js | 359 ---------------------------------------- 5 files changed, 1 insertion(+), 381 deletions(-) delete mode 100644 config/setup-pact.js delete mode 100644 src/github.pact.test.js diff --git a/README.md b/README.md index 15a9466..481763e 100644 --- a/README.md +++ b/README.md @@ -230,9 +230,6 @@ You can compare this workflow to the documented Cognito workflow [here](https:// Tests are provided with [Jest](https://jestjs.io/) using [`chai`'s `expect`](http://www.chaijs.com/api/bdd/), included by a shim based on [this blog post](https://medium.com/@RubenOostinga/combining-chai-and-jest-matchers-d12d1ffd0303). -[Pact](http://pact.io) consumer tests for the GitHub API connection are provided -in `src/github.pact.test.js`. There is currently no provider validation performed. - #### Private key The private key used to make ID tokens is stored in `./jwtRS256.key` once diff --git a/config/setup-pact.js b/config/setup-pact.js deleted file mode 100644 index 80c1693..0000000 --- a/config/setup-pact.js +++ /dev/null @@ -1,17 +0,0 @@ -const path = require('path'); -const { Pact } = require('@pact-foundation/pact'); -const pkg = require('../package.json'); - -global.port = 8989; -global.PACT_BASE_URL = `http://localhost:${port}`; - -global.provider = new Pact({ - port: global.port, - log: path.resolve(process.cwd(), 'logs', 'mockserver-integration.log'), - dir: path.resolve(process.cwd(), 'pacts'), - spec: 2, - logLevel: 'fatal', - pactfileWriteMode: 'update', - consumer: pkg.name, - provider: 'GitHub.com' -}); diff --git a/jest.config.js b/jest.config.js index 0687811..9ef0b75 100644 --- a/jest.config.js +++ b/jest.config.js @@ -116,7 +116,7 @@ module.exports = { // runner: "jest-runner", // The paths to modules that run some code to configure or set up the testing environment before each test - setupFiles: ['./config/setup-pact.js'], + // setupFiles: [], // The path to a module that runs some code to configure or set up the testing framework before each test setupFilesAfterEnv: ['./config/setup-test-framework-script.js'], diff --git a/package.json b/package.json index 9dfd0b7..059435d 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,6 @@ "devDependencies": { "@babel/core": "^7.3.4", "@babel/preset-env": "^7.3.4", - "@pact-foundation/pact": "^9.5.0", "babel-jest": "^24.1.0", "babel-loader": "^8.0.2", "chai": "^4.1.2", diff --git a/src/github.pact.test.js b/src/github.pact.test.js deleted file mode 100644 index dcd3057..0000000 --- a/src/github.pact.test.js +++ /dev/null @@ -1,359 +0,0 @@ -const github = require('./github'); - -/* global provider PACT_BASE_URL */ - -jest.mock('./config', () => ({ - COGNITO_REDIRECT_URI: 'COGNITO_REDIRECT_URI', - GITHUB_CLIENT_SECRET: 'GITHUB_CLIENT_SECRET', - GITHUB_CLIENT_ID: 'GITHUB_CLIENT_ID', - GITHUB_API_URL: 'GITHUB_API_URL', - GITHUB_LOGIN_URL: 'GITHUB_LOGIN_URL' -})); - -describe('With an increased jasmine timeout', () => { - let originalTimeout; - - beforeAll(() => { - // We have to increase the jasmine timeout in order to run pact - // on some machines / configurations, since the server sometimes - // takes a little longer to start - originalTimeout = jasmine.DEFAULT_TIMEOUT_INTERVAL; - jasmine.DEFAULT_TIMEOUT_INTERVAL = 30000; - }); - - afterAll(() => { - jasmine.DEFAULT_TIMEOUT_INTERVAL = originalTimeout; - }); - - describe('GitHub Client Pact', () => { - beforeAll(() => provider.setup()); - afterAll(() => provider.finalize()); - afterEach(() => provider.verify()); - - describe('UserDetails endpoint', () => { - const userDetailsRequest = { - uponReceiving: 'a request for user details', - withRequest: { - method: 'GET', - path: '/user', - headers: { - Accept: 'application/vnd.github.v3+json', - Authorization: `token THIS_IS_MY_TOKEN` - } - } - }; - describe('When the access token is good', () => { - const EXPECTED_BODY = { name: 'Tim Jones' }; - beforeEach(() => { - const interaction = { - ...userDetailsRequest, - state: 'Where the access token is good', - willRespondWith: { - status: 200, - headers: { - 'Content-Type': 'application/json' - }, - body: EXPECTED_BODY - } - }; - return provider.addInteraction(interaction); - }); - - // add expectations - it('returns a sucessful body', done => - github(PACT_BASE_URL) - .getUserDetails('THIS_IS_MY_TOKEN') - .then(response => { - expect(response).toEqual(EXPECTED_BODY); - done(); - })); - }); - describe('When the access token is bad', () => { - const EXPECTED_ERROR = { - error: 'This is an error', - error_description: 'This is a description' - }; - beforeEach(() => { - const interaction = { - ...userDetailsRequest, - state: 'Where the access token is bad', - willRespondWith: { - status: 400, - headers: { - 'Content-Type': 'application/json' - }, - body: EXPECTED_ERROR - } - }; - return provider.addInteraction(interaction); - }); - - // add expectations - it('rejects the promise', done => { - github(PACT_BASE_URL) - .getUserDetails('THIS_IS_MY_TOKEN') - .catch(() => { - done(); - }); - }); - }); - describe('When there is a server error response', () => { - const EXPECTED_ERROR = { - error: 'This is an error', - error_description: 'This is a description' - }; - beforeEach(() => { - const interaction = { - ...userDetailsRequest, - state: 'Where there is a server error response', - willRespondWith: { - status: 200, - headers: { - 'Content-Type': 'application/json' - }, - body: EXPECTED_ERROR - } - }; - return provider.addInteraction(interaction); - }); - - // add expectations - it('rejects the promise', done => { - github(PACT_BASE_URL) - .getUserDetails('THIS_IS_MY_TOKEN') - .catch(() => { - done(); - }); - }); - }); - }); - - describe('UserEmails endpoint', () => { - const userEmailsRequest = { - uponReceiving: 'a request for user emails', - withRequest: { - method: 'GET', - path: '/user/emails', - headers: { - Accept: 'application/vnd.github.v3+json', - Authorization: `token THIS_IS_MY_TOKEN` - } - } - }; - describe('When the access token is good', () => { - const EXPECTED_BODY = [{ email: 'ben@example.com', primary: true }]; - beforeEach(() => { - const interaction = { - ...userEmailsRequest, - state: 'Where the access token is good', - willRespondWith: { - status: 200, - headers: { - 'Content-Type': 'application/json' - }, - body: EXPECTED_BODY - } - }; - return provider.addInteraction(interaction); - }); - - // add expectations - it('returns a sucessful body', done => - github(PACT_BASE_URL) - .getUserEmails('THIS_IS_MY_TOKEN') - .then(response => { - expect(response).toEqual(EXPECTED_BODY); - done(); - })); - }); - describe('When the access token is bad', () => { - const EXPECTED_ERROR = { - error: 'This is an error', - error_description: 'This is a description' - }; - beforeEach(() => { - const interaction = { - ...userEmailsRequest, - state: 'Where the access token is bad', - willRespondWith: { - status: 400, - headers: { - 'Content-Type': 'application/json' - }, - body: EXPECTED_ERROR - } - }; - return provider.addInteraction(interaction); - }); - - // add expectations - it('rejects the promise', done => { - github(PACT_BASE_URL) - .getUserEmails('THIS_IS_MY_TOKEN') - .catch(() => { - done(); - }); - }); - }); - describe('When there is a server error response', () => { - const EXPECTED_ERROR = { - error: 'This is an error', - error_description: 'This is a description' - }; - beforeEach(() => { - const interaction = { - ...userEmailsRequest, - state: 'Where there is a server error response', - willRespondWith: { - status: 200, - headers: { - 'Content-Type': 'application/json' - }, - body: EXPECTED_ERROR - } - }; - return provider.addInteraction(interaction); - }); - - // add expectations - it('rejects the promise', done => { - github(PACT_BASE_URL) - .getUserEmails('THIS_IS_MY_TOKEN') - .catch(() => { - done(); - }); - }); - }); - }); - - describe('Authorization endpoint', () => { - describe('always', () => { - it('returns a redirect url', () => { - expect( - github(PACT_BASE_URL).getAuthorizeUrl( - 'client_id', - 'scope', - 'state', - 'response_type' - ) - ).to.equal( - `${PACT_BASE_URL}/login/oauth/authorize?client_id=client_id&scope=scope&state=state&response_type=response_type` - ); - }); - }); - }); - - describe('Auth Token endpoint', () => { - const accessTokenRequest = { - uponReceiving: 'a request for an access token', - withRequest: { - method: 'POST', - path: '/login/oauth/access_token', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json' - }, - body: { - // OAuth required fields - grant_type: 'authorization_code', - redirect_uri: 'COGNITO_REDIRECT_URI', - client_id: 'GITHUB_CLIENT_ID', - // GitHub Specific - response_type: 'code', - client_secret: 'GITHUB_CLIENT_SECRET', - code: 'SOME_CODE' - } - } - }; - - describe('When the code is good', () => { - const EXPECTED_BODY = { - access_token: 'xxxx', - refresh_token: 'yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy', - expires_in: 21600 - }; - beforeEach(() => { - const interaction = { - ...accessTokenRequest, - state: 'Where the code is good', - willRespondWith: { - status: 200, - headers: { - 'Content-Type': 'application/json' - }, - body: EXPECTED_BODY - } - }; - return provider.addInteraction(interaction); - }); - - // add expectations - it('returns a sucessful body', done => - github(PACT_BASE_URL) - .getToken('SOME_CODE') - .then(response => { - expect(response).toEqual(EXPECTED_BODY); - done(); - })); - }); - describe('When the code is bad', () => { - const EXPECTED_ERROR = { - error: 'This is an error', - error_description: 'This is a description' - }; - beforeEach(() => { - const interaction = { - ...accessTokenRequest, - state: 'Where the code is bad', - willRespondWith: { - status: 400, - headers: { - 'Content-Type': 'application/json' - }, - body: EXPECTED_ERROR - } - }; - return provider.addInteraction(interaction); - }); - - // add expectations - it('rejects the promise', done => { - github(PACT_BASE_URL) - .getToken('SOME_CODE') - .catch(() => { - done(); - }); - }); - }); - describe('When there is a server error response', () => { - const EXPECTED_ERROR = { - error: 'This is an error', - error_description: 'This is a description' - }; - beforeEach(() => { - const interaction = { - ...accessTokenRequest, - state: 'Where there is a server error response', - willRespondWith: { - status: 200, - headers: { - 'Content-Type': 'application/json' - }, - body: EXPECTED_ERROR - } - }; - return provider.addInteraction(interaction); - }); - - // add expectations - it('rejects the promise', done => { - github(PACT_BASE_URL) - .getToken('SOME_CODE') - .catch(() => { - done(); - }); - }); - }); - }); - }); -}); From dba3cc552e1e9ad594d3925253f5679cf8de896c Mon Sep 17 00:00:00 2001 From: Andrew Plummer Date: Thu, 23 Jan 2020 13:17:55 +0000 Subject: [PATCH 3/8] Remove snyk --- README.md | 1 - package.json | 8 ++------ 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 481763e..74a2d7f 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,6 @@ [![Build Status](https://travis-ci.org/TimothyJones/github-cognito-openid-wrapper.svg?branch=master)](https://travis-ci.org/TimothyJones/github-cognito-openid-wrapper) [![Maintainability](https://api.codeclimate.com/v1/badges/f787719be529b1c0e8ee/maintainability)](https://codeclimate.com/github/TimothyJones/github-openid-wrapper/maintainability) [![Test Coverage](https://api.codeclimate.com/v1/badges/f787719be529b1c0e8ee/test_coverage)](https://codeclimate.com/github/TimothyJones/github-openid-wrapper/test_coverage) -[![Known Vulnerabilities](https://snyk.io/test/github/TimothyJones/github-cognito-openid-wrapper/badge.svg?targetFile=package.json)](https://snyk.io/test/github/TimothyJones/github-cognito-openid-wrapper?targetFile=package.json) [![License](https://img.shields.io/badge/License-BSD%203--Clause-blue.svg)](https://opensource.org/licenses/BSD-3-Clause) Do you want to add GitHub as an OIDC (OpenID Connect) provider to an AWS Cognito User Pool? Have you run in to trouble because GitHub only provides OAuth2.0 endpoints, and doesn't support OpenID Connect? diff --git a/package.json b/package.json index 059435d..d011cd0 100644 --- a/package.json +++ b/package.json @@ -18,9 +18,7 @@ "build-dist": "npm run build", "predeploy": "npm run build-dist", "deploy": "./scripts/deploy.sh", - "coverage": "jest --runInBand --coverage", - "snyk-protect": "snyk protect", - "prepare": "npm run snyk-protect" + "coverage": "jest --runInBand --coverage" }, "author": "", "license": "ISC", @@ -54,7 +52,6 @@ "nodemon-webpack-plugin": "^4.0.6", "prettier": "^1.14.2", "raw-loader": "^0.5.1", - "snyk": "^1.232.0", "to": "^0.2.9", "webpack": "^4.41.2", "webpack-cli": "^3.1.0", @@ -62,6 +59,5 @@ }, "engines": { "node": ">=10" - }, - "snyk": true + } } From 7d5c4a9ec113c179f8f0b98446df2ef360aade76 Mon Sep 17 00:00:00 2001 From: Andrew Plummer Date: Thu, 23 Jan 2020 13:18:07 +0000 Subject: [PATCH 4/8] Remove badges --- README.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/README.md b/README.md index 74a2d7f..290ecc8 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,5 @@ # GitHub OpenID Connect Wrapper for Cognito -[![Build Status](https://travis-ci.org/TimothyJones/github-cognito-openid-wrapper.svg?branch=master)](https://travis-ci.org/TimothyJones/github-cognito-openid-wrapper) -[![Maintainability](https://api.codeclimate.com/v1/badges/f787719be529b1c0e8ee/maintainability)](https://codeclimate.com/github/TimothyJones/github-openid-wrapper/maintainability) -[![Test Coverage](https://api.codeclimate.com/v1/badges/f787719be529b1c0e8ee/test_coverage)](https://codeclimate.com/github/TimothyJones/github-openid-wrapper/test_coverage) -[![License](https://img.shields.io/badge/License-BSD%203--Clause-blue.svg)](https://opensource.org/licenses/BSD-3-Clause) - Do you want to add GitHub as an OIDC (OpenID Connect) provider to an AWS Cognito User Pool? Have you run in to trouble because GitHub only provides OAuth2.0 endpoints, and doesn't support OpenID Connect? This project allows you to wrap your GitHub OAuth App in an OpenID Connect layer, allowing you to use it with AWS Cognito. From 51f0623610fa0e2568f432319c778441008b65c5 Mon Sep 17 00:00:00 2001 From: Andrew Plummer Date: Thu, 23 Jan 2020 13:18:13 +0000 Subject: [PATCH 5/8] Update URL in package.json --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d011cd0..c124553 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "dist-web/server.js", "repository": { "type": "git", - "url": "https://github.com/TimothyJones/github-cognito-openid-wrapper.git" + "url": "https://github.com/isotoma/linkedin-cognito-openid-wrapper.git" }, "scripts": { "build": "webpack", From 031012b058e2587aa91664751e1e9d5f6c609a1e Mon Sep 17 00:00:00 2001 From: Andrew Plummer Date: Thu, 23 Jan 2020 16:24:22 +0000 Subject: [PATCH 6/8] Remove irrelevant bits --- package.json | 2 - scripts/deploy.sh | 24 --------- template.yml | 124 ---------------------------------------------- 3 files changed, 150 deletions(-) delete mode 100755 scripts/deploy.sh delete mode 100644 template.yml diff --git a/package.json b/package.json index c124553..8f25707 100644 --- a/package.json +++ b/package.json @@ -16,8 +16,6 @@ "preinstall": "./scripts/create-key.sh", "prebuild-dist": "npm run lint && npm run test", "build-dist": "npm run build", - "predeploy": "npm run build-dist", - "deploy": "./scripts/deploy.sh", "coverage": "jest --runInBand --coverage" }, "author": "", diff --git a/scripts/deploy.sh b/scripts/deploy.sh deleted file mode 100755 index 5a69e3a..0000000 --- a/scripts/deploy.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/bin/bash -eu -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")"; pwd)" # Figure out where the script is running -. "$SCRIPT_DIR"/lib-robust-bash.sh # load the robust bash library -PROJECT_ROOT="$SCRIPT_DIR"/.. # Figure out where the project directory is - -# Ensure dependencies are present - -require_binary aws -require_binary sam - -# Ensure configuration is present - -if [ ! -f "$PROJECT_ROOT/config.sh" ]; then - echo "ERROR: config.sh is missing. Copy example-config.sh and modify as appropriate." - echo " cp example-config.sh config.sh" - exit 1 -fi -source ./config.sh - - -OUTPUT_TEMPLATE_FILE="$PROJECT_ROOT/serverless-output.yml" -aws s3 mb "s3://$BUCKET_NAME" --region "$REGION" || true -sam package --template-file template.yml --output-template-file "$OUTPUT_TEMPLATE_FILE" --s3-bucket "$BUCKET_NAME" -sam deploy --region "$REGION" --template-file "$OUTPUT_TEMPLATE_FILE" --stack-name "$STACK_NAME" --parameter-overrides GitHubClientIdParameter="$GITHUB_CLIENT_ID" GitHubClientSecretParameter="$GITHUB_CLIENT_SECRET" CognitoRedirectUriParameter="$COGNITO_REDIRECT_URI" StageNameParameter="$STAGE_NAME" --capabilities CAPABILITY_IAM diff --git a/template.yml b/template.yml deleted file mode 100644 index 9ba05d8..0000000 --- a/template.yml +++ /dev/null @@ -1,124 +0,0 @@ -AWSTemplateFormatVersion: '2010-09-09' -Transform: AWS::Serverless-2016-10-31 -Description: > - Github Cognito OpenID Wrapper (SSO) - -# More info about Globals: https://github.com/awslabs/serverless-application-model/blob/master/docs/globals.rst -Globals: - Function: - Runtime: nodejs10 - Timeout: 15 - Environment: - Variables: - GITHUB_CLIENT_ID: - Ref: GitHubClientIdParameter - GITHUB_CLIENT_SECRET: - Ref: GitHubClientSecretParameter - COGNITO_REDIRECT_URI: - Ref: CognitoRedirectUriParameter - GITHUB_API_URL: - Ref: GitHubUrlParameter - GITHUB_LOGIN_URL: - Ref: GitHubLoginUrlParameter - -Parameters: - GitHubClientIdParameter: - Type: String - GitHubClientSecretParameter: - Type: String - CognitoRedirectUriParameter: - Type: String - GitHubUrlParameter: - Type: String - Default: "https://api.github.com" - MinLength: 1 - GitHubLoginUrlParameter: - Type: String - Default: "https://github.com" - MinLength: 1 - StageNameParameter: - Type: String - -Resources: - GithubOAuthApi: - Type: AWS::Serverless::Api - Properties: - StageName: !Ref StageNameParameter - OpenApiVersion: "2.0" - OpenIdDiscovery: - Type: AWS::Serverless::Function - Properties: - CodeUri: ./dist-lambda - Handler: openIdConfiguration.handler - Events: - GetResource: - Type: Api - Properties: - Path: /.well-known/openid-configuration - Method: get - RestApiId: !Ref GithubOAuthApi - Authorize: - Type: AWS::Serverless::Function - Properties: - CodeUri: ./dist-lambda - Handler: authorize.handler - Events: - GetResource: - Type: Api - Properties: - Path: /authorize - Method: get - RestApiId: !Ref GithubOAuthApi - Token: - Type: AWS::Serverless::Function - Properties: - CodeUri: ./dist-lambda - Handler: token.handler - Events: - GetResource: - Type: Api - Properties: - Path: /token - Method: get - RestApiId: !Ref GithubOAuthApi - PostResource: - Type: Api - Properties: - Path: /token - Method: post - RestApiId: !Ref GithubOAuthApi - UserInfo: - Type: AWS::Serverless::Function - Properties: - CodeUri: ./dist-lambda - Handler: userinfo.handler - Events: - GetResource: - Type: Api - Properties: - Path: /userinfo - Method: get - RestApiId: !Ref GithubOAuthApi - PostResource: - Type: Api - Properties: - Path: /userinfo - Method: post - RestApiId: !Ref GithubOAuthApi - Jwks: - Type: AWS::Serverless::Function - Properties: - CodeUri: ./dist-lambda - Handler: jwks.handler - Events: - GetResource: - Type: Api - Properties: - Path: /.well-known/jwks.json - Method: get - RestApiId: !Ref GithubOAuthApi - -Outputs: - GitHubShimIssuer: - Description: "GitHub OpenID Shim Issuer" - Value: !Sub "https://${GithubOAuthApi}.execute-api.${AWS::Region}.amazonaws.com/${StageNameParameter}" From 2eab7f2722564257435b24ef930e994730e6e5ae Mon Sep 17 00:00:00 2001 From: Andrew Plummer Date: Thu, 23 Jan 2020 16:25:19 +0000 Subject: [PATCH 7/8] Easy github->linkedin changes --- example-config.sh | 15 ++------------- package.json | 4 ++-- src/config.js | 8 ++++---- src/connectors/logger.js | 2 +- src/crypto.js | 4 ++-- src/{github.js => linkedin.js} | 0 src/openid.js | 2 +- src/validate-config.js | 4 ++-- 8 files changed, 14 insertions(+), 25 deletions(-) rename src/{github.js => linkedin.js} (100%) diff --git a/example-config.sh b/example-config.sh index 4f410a2..c5f709d 100755 --- a/example-config.sh +++ b/example-config.sh @@ -1,21 +1,10 @@ #!/bin/bash -eu # Variables always required -export GITHUB_CLIENT_ID=# -export GITHUB_CLIENT_SECRET=# +export LINKEDIN_CLIENT_ID=# +export LINKEDIN_CLIENT_SECRET=# export COGNITO_REDIRECT_URI=# https:///oauth2/idpresponse -# Variables required if used with GitHub Enterprise -# GITHUB_API_URL=# https:///api/v3 -# GITHUB_LOGIN_URL=# https:// - -# Variables required if Splunk logger is used -# SPLUNK_URL=# https:///services/collector/event/1.0 -# SPLUNK_TOKEN=# Splunk HTTP Event Collector token -# SPLUNK_SOURCE=# Source for all logged events -# SPLUNK_SOURCETYPE=# Sourcetype for all logged events -# SPLUNK_INDEX=# Index for all logged events - # Variables required if deploying with API Gateway / Lambda export BUCKET_NAME=# An S3 bucket name to use as the deployment pipeline export STACK_NAME=# The name of the stack to create diff --git a/package.json b/package.json index 8f25707..20b5c18 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "github-cognito-openid-wrapper", + "name": "linkedin-cognito-openid-wrapper", "version": "1.1.0", - "description": "A wrapper to enable AWS Cognito User Pools (which requires OpenID Connect) to talk to GitHub (which only provides OAuth2.0)", + "description": "A wrapper to enable AWS Cognito User Pools (which requires OpenID Connect) to talk to LinkedIn (which only provides OAuth2.0)", "main": "dist-web/server.js", "repository": { "type": "git", diff --git a/src/config.js b/src/config.js index db8acd6..3350793 100644 --- a/src/config.js +++ b/src/config.js @@ -1,9 +1,9 @@ module.exports = { - GITHUB_CLIENT_ID: process.env.GITHUB_CLIENT_ID, - GITHUB_CLIENT_SECRET: process.env.GITHUB_CLIENT_SECRET, + LINKEDIN_CLIENT_ID: process.env.LINKEDIN_CLIENT_ID, + LINKEDIN_CLIENT_SECRET: process.env.LINKEDIN_CLIENT_SECRET, COGNITO_REDIRECT_URI: process.env.COGNITO_REDIRECT_URI, - GITHUB_API_URL: process.env.GITHUB_API_URL, - GITHUB_LOGIN_URL: process.env.GITHUB_LOGIN_URL, + LINKEDIN_API_URL: process.env.LINKEDIN_API_URL, + LINKEDIN_LOGIN_URL: process.env.LINKEDIN_LOGIN_URL, PORT: parseInt(process.env.PORT, 10) || undefined, // Splunk logging variables diff --git a/src/connectors/logger.js b/src/connectors/logger.js index 046468a..6d6a921 100644 --- a/src/connectors/logger.js +++ b/src/connectors/logger.js @@ -19,7 +19,7 @@ if (SPLUNK_URL) { url: SPLUNK_URL || 'localhost', token: SPLUNK_TOKEN, source: SPLUNK_SOURCE || '/var/log/GHOIdShim.log', - sourcetype: SPLUNK_SOURCETYPE || 'github-cognito-openid-wrapper', + sourcetype: SPLUNK_SOURCETYPE || 'linkedin-cognito-openid-wrapper', index: SPLUNK_INDEX || 'main', maxBatchCount: 1 }; diff --git a/src/crypto.js b/src/crypto.js index 59ff87a..61a7248 100644 --- a/src/crypto.js +++ b/src/crypto.js @@ -1,6 +1,6 @@ const JSONWebKey = require('json-web-key'); const jwt = require('jsonwebtoken'); -const { GITHUB_CLIENT_ID } = require('./config'); +const { LINKEDIN_CLIENT_ID } = require('./config'); const logger = require('./connectors/logger'); const KEY_ID = 'jwtRS256'; @@ -18,7 +18,7 @@ module.exports = { const enrichedPayload = { ...payload, iss: `https://${host}`, - aud: GITHUB_CLIENT_ID + aud: LINKEDIN_CLIENT_ID }; logger.debug('Signing payload %j', enrichedPayload, {}); return jwt.sign(enrichedPayload, cert, { diff --git a/src/github.js b/src/linkedin.js similarity index 100% rename from src/github.js rename to src/linkedin.js diff --git a/src/openid.js b/src/openid.js index 36bb962..a24cac1 100644 --- a/src/openid.js +++ b/src/openid.js @@ -1,7 +1,7 @@ const logger = require('./connectors/logger'); const { NumericDate } = require('./helpers'); const crypto = require('./crypto'); -const github = require('./github'); +const github = require('./linkedin'); const getJwks = () => ({ keys: [crypto.getPublicKey()] }); diff --git a/src/validate-config.js b/src/validate-config.js index 26bc6dd..5646bcf 100644 --- a/src/validate-config.js +++ b/src/validate-config.js @@ -17,8 +17,8 @@ const ensureNumber = variableName => { }; const requiredStrings = [ - 'GITHUB_CLIENT_ID', - 'GITHUB_CLIENT_SECRET', + 'LINKEDIN_CLIENT_ID', + 'LINKEDIN_CLIENT_SECRET', 'COGNITO_REDIRECT_URI' ]; From 7a9f5e0912b41c7b5968d5f41b3a147d199f86b5 Mon Sep 17 00:00:00 2001 From: Andrew Plummer Date: Thu, 23 Jan 2020 16:26:13 +0000 Subject: [PATCH 8/8] Tricky github->linkedin changes --- src/config.js | 1 + src/connectors/logger.js | 1 - src/connectors/web/handlers.js | 2 +- src/linkedin.js | 75 +++++++++++++++++++--------------- src/openid.js | 57 ++++++++------------------ 5 files changed, 62 insertions(+), 74 deletions(-) diff --git a/src/config.js b/src/config.js index 3350793..7c37e0d 100644 --- a/src/config.js +++ b/src/config.js @@ -5,6 +5,7 @@ module.exports = { LINKEDIN_API_URL: process.env.LINKEDIN_API_URL, LINKEDIN_LOGIN_URL: process.env.LINKEDIN_LOGIN_URL, PORT: parseInt(process.env.PORT, 10) || undefined, + LINKEDIN_SCOPE: process.env.LINKEDIN_SCOPE, // Splunk logging variables SPLUNK_URL: process.env.SPLUNK_URL, diff --git a/src/connectors/logger.js b/src/connectors/logger.js index 6d6a921..28bcae6 100644 --- a/src/connectors/logger.js +++ b/src/connectors/logger.js @@ -39,7 +39,6 @@ if (SPLUNK_URL) { new winston.transports.Console({ format: winston.format.combine( winston.format.splat(), - winston.format.colorize({ all: true }), winston.format.simple() ) }) diff --git a/src/connectors/web/handlers.js b/src/connectors/web/handlers.js index 50d8fc5..7fba00d 100644 --- a/src/connectors/web/handlers.js +++ b/src/connectors/web/handlers.js @@ -15,7 +15,7 @@ module.exports = { jwks: (req, res) => controllers(responder(res)).jwks(), authorize: (req, res) => responder(res).redirect( - `https://github.com/login/oauth/authorize?client_id=${ + `https://www.linkedin.com/oauth/v2/authorization?client_id=${ req.query.client_id }&scope=${req.query.scope}&state=${req.query.state}&response_type=${ req.query.response_type diff --git a/src/linkedin.js b/src/linkedin.js index da0f723..716e200 100644 --- a/src/linkedin.js +++ b/src/linkedin.js @@ -1,21 +1,23 @@ const axios = require('axios'); +const qs = require('qs'); const { - GITHUB_CLIENT_ID, - GITHUB_CLIENT_SECRET, + LINKEDIN_CLIENT_ID, + LINKEDIN_CLIENT_SECRET, COGNITO_REDIRECT_URI, - GITHUB_API_URL, - GITHUB_LOGIN_URL + LINKEDIN_API_URL, + LINKEDIN_LOGIN_URL, + LINKEDIN_SCOPE, } = require('./config'); const logger = require('./connectors/logger'); const getApiEndpoints = ( - apiBaseUrl = GITHUB_API_URL, - loginBaseUrl = GITHUB_LOGIN_URL + apiBaseUrl = LINKEDIN_API_URL, + loginBaseUrl = LINKEDIN_LOGIN_URL ) => ({ - userDetails: `${apiBaseUrl}/user`, - userEmails: `${apiBaseUrl}/user/emails`, - oauthToken: `${loginBaseUrl}/login/oauth/access_token`, - oauthAuthorize: `${loginBaseUrl}/login/oauth/authorize` + userDetails: `${apiBaseUrl}/v2/me`, + userEmails: `${apiBaseUrl}/v2/clientAwareMemberHandles?q=members&projection=(elements*(primary,type,handle~))`, + oauthToken: `${apiBaseUrl}/oauth/v2/accessToken`, + oauthAuthorize: `${loginBaseUrl}/oauth/v2/authorization`, }); const check = response => { @@ -23,7 +25,7 @@ const check = response => { if (response.data) { if (response.data.error) { throw new Error( - `GitHub API responded with a failure: ${response.data.error}, ${ + `LinkedIn API responded with a failure: ${response.data.error}, ${ response.data.error_description }` ); @@ -32,42 +34,42 @@ const check = response => { } } throw new Error( - `GitHub API responded with a failure: ${response.status} (${ + `LinkedIn API responded with a failure: ${response.status} (${ response.statusText })` ); }; -const gitHubGet = (url, accessToken) => +const linkedinGet = (url, accessToken) => axios({ method: 'get', url, headers: { - Accept: 'application/vnd.github.v3+json', - Authorization: `token ${accessToken}` + Authorization: `Bearer ${accessToken}` } }); module.exports = (apiBaseUrl, loginBaseUrl) => { const urls = getApiEndpoints(apiBaseUrl, loginBaseUrl || apiBaseUrl); return { - getAuthorizeUrl: (client_id, scope, state, response_type) => - `${urls.oauthAuthorize}?client_id=${client_id}&scope=${encodeURIComponent( - scope - )}&state=${state}&response_type=${response_type}`, + getAuthorizeUrl: (client_id, scope, state, response_type) => { + const scopesToSend = scope.split(' ').filter(s => s !== 'openid').join(' '); + return `${urls.oauthAuthorize}?client_id=${client_id}&scope=${encodeURIComponent( + scopesToSend + )}&state=${state}&response_type=${response_type}&redirect_uri=${COGNITO_REDIRECT_URI}`; + }, getUserDetails: accessToken => - gitHubGet(urls.userDetails, accessToken).then(check), + linkedinGet(urls.userDetails, accessToken).then(check), getUserEmails: accessToken => - gitHubGet(urls.userEmails, accessToken).then(check), + linkedinGet(urls.userEmails, accessToken).then(check), getToken: (code, state) => { const data = { // OAuth required fields grant_type: 'authorization_code', redirect_uri: COGNITO_REDIRECT_URI, - client_id: GITHUB_CLIENT_ID, - // GitHub Specific + client_id: LINKEDIN_CLIENT_ID, response_type: 'code', - client_secret: GITHUB_CLIENT_SECRET, + client_secret: LINKEDIN_CLIENT_SECRET, code, // State may not be present, so we conditionally include it ...(state && { state }) @@ -79,15 +81,22 @@ module.exports = (apiBaseUrl, loginBaseUrl) => { data, {} ); - return axios({ - method: 'post', - url: urls.oauthToken, - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json' - }, - data - }).then(check); + return axios.post( + urls.oauthToken, + qs.stringify(data), + { + headers: { + Accept: 'application/json', + // 'Content-Type': 'application/json' + }, + } + ).then(check) + .then(data => { + // Because LinkedIn doesn't return the scopes + data.scope = LINKEDIN_SCOPE; + data.token_type = 'bearer'; + return data; + }) } }; }; diff --git a/src/openid.js b/src/openid.js index a24cac1..9ee79cf 100644 --- a/src/openid.js +++ b/src/openid.js @@ -1,46 +1,40 @@ const logger = require('./connectors/logger'); const { NumericDate } = require('./helpers'); const crypto = require('./crypto'); -const github = require('./linkedin'); +const linkedin = require('./linkedin'); const getJwks = () => ({ keys: [crypto.getPublicKey()] }); const getUserInfo = accessToken => Promise.all([ - github() + linkedin() .getUserDetails(accessToken) .then(userDetails => { logger.debug('Fetched user details: %j', userDetails, {}); - // Here we map the github user response to the standard claims from + // Here we map the linkedin user response to the standard claims from // OpenID. The mapping was constructed by following - // https://developer.github.com/v3/users/ - // and http://openid.net/specs/openid-connect-core-1_0.html#StandardClaims + // https://docs.microsoft.com/en-us/linkedin/shared/integrations/people/profile-api?context=linkedin/consumer/context + // and + // https://docs.microsoft.com/en-us/linkedin/shared/references/v2/profile/lite-profile const claims = { sub: `${userDetails.id}`, // OpenID requires a string - name: userDetails.name, - preferred_username: userDetails.login, - profile: userDetails.html_url, - picture: userDetails.avatar_url, - website: userDetails.blog, - updated_at: NumericDate( - // OpenID requires the seconds since epoch in UTC - new Date(Date.parse(userDetails.updated_at)) - ) + name: `${userDetails.firstName.localized} ${userDetails.lastName.localized}`, }; logger.debug('Resolved claims: %j', claims, {}); return claims; }), - github() + linkedin() .getUserEmails(accessToken) .then(userEmails => { logger.debug('Fetched user emails: %j', userEmails, {}); - const primaryEmail = userEmails.find(email => email.primary); + const primaryEmail = userEmails.elements.find(email => email.primary && (email.type === 'EMAIL')); if (primaryEmail === undefined) { throw new Error('User did not have a primary email address'); } + const emailAddress = primaryEmail['handle~'].emailAddress; const claims = { - email: primaryEmail.email, - email_verified: primaryEmail.verified + email: emailAddress, + email_verified: true, }; logger.debug('Resolved claims: %j', claims, {}); return claims; @@ -55,23 +49,16 @@ const getUserInfo = accessToken => }); const getAuthorizeUrl = (client_id, scope, state, response_type) => - github().getAuthorizeUrl(client_id, scope, state, response_type); + linkedin().getAuthorizeUrl(client_id, scope, state, response_type); const getTokens = (code, state, host) => - github() + linkedin() .getToken(code, state) - .then(githubToken => { - logger.debug('Got token: %s', githubToken, {}); - // GitHub returns scopes separated by commas - // But OAuth wants them to be spaces - // https://tools.ietf.org/html/rfc6749#section-5.1 - // Also, we need to add openid as a scope, - // since GitHub will have stripped it - const scope = `openid ${githubToken.scope.replace(',', ' ')}`; - + .then(linkedinToken => { + logger.debug('Got token: %s', linkedinToken, {}); // ** JWT ID Token required fields ** // iss - issuer https url - // aud - audience that this token is valid for (GITHUB_CLIENT_ID) + // aud - audience that this token is valid for (LINKEDIN_CLIENT_ID) // sub - subject identifier - must be unique // ** Also required, but provided by jsonwebtoken ** // exp - expiry time for the id token (seconds since epoch in UTC) @@ -87,8 +74,7 @@ const getTokens = (code, state, host) => const idToken = crypto.makeIdToken(payload, host); const tokenResponse = { - ...githubToken, - scope, + ...linkedinToken, id_token: idToken }; @@ -128,15 +114,8 @@ const getConfigFor = host => ({ claims_supported: [ 'sub', 'name', - 'preferred_username', - 'profile', - 'picture', - 'website', 'email', 'email_verified', - 'updated_at', - 'iss', - 'aud' ] });