From 6735c9f8916a9e9578d4b4857d76fda420246872 Mon Sep 17 00:00:00 2001 From: Gareth Watts Date: Mon, 3 Aug 2020 16:13:29 -0500 Subject: [PATCH] Add generic Google handlers This commit extends the generic package support to include Google authenticators It generates separate packages for "hosted-domain" and "email-lookup" validation types, but does not yet include one for "google-groups" as that requires a JSON file at runtime; need to determine a good option to supply that as a secret. --- .github/workflows/ci.yml | 4 + .github/workflows/release.yml | 22 ++++++ README.md | 36 ++++++--- authn/openid.index.js | 72 ++++++++++-------- build/build.js | 135 ++++++++++++++++++++++++---------- config/generic.config.js | 2 +- 6 files changed, 189 insertions(+), 82 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a2d117b..536c367 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,10 @@ jobs: - run: npm run-script build-ci okta_native - run: test -f distributions/okta_native/okta_native.zip + - run: npm run-script build-ci google hosted-domain + - run: test -f distributions/google/google-hosted_domain.zip + - run: npm run-script build-ci google email-lookup + - run: test -f distributions/google/google-email_lookup.zip - run: npm run-script build-ci rotate_key_pair - run: test -f distributions/rotate_key_pair/rotate_key_pair.zip diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fda1291..4b7368a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,6 +26,10 @@ jobs: - name: Build okta_native run: npm run-script build-ci okta_native + - name: Build google-hosted_domain + run: npm run-script build-ci google hosted-domain + - name: Build google-email_lookup + run: npm run-script build-ci google email-lookup - name: Build rotate_key_pair run: npm run-script build-ci rotate_key_pair @@ -49,6 +53,24 @@ jobs: asset_path: ./distributions/okta_native/okta_native.zip asset_name: okta_native_${{ steps.get_variables.outputs.tag_name }}.zip asset_content_type: application/zip + - name: Upload google-hosted_domain + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./distributions/google/google-hosted_domain.zip + asset_name: google-hosted_domain_${{ steps.get_variables.outputs.tag_name }}.zip + asset_content_type: application/zip + - name: Upload google-email-lookup + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./distributions/google/google-email_lookup.zip + asset_name: google-email_lookup_${{ steps.get_variables.outputs.tag_name }}.zip + asset_content_type: application/zip - name: Upload rotate_key_pair uses: actions/upload-release-asset@v1 env: diff --git a/README.md b/README.md index 71e13bb..ab0aa34 100755 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ The build script prompts you for the required configuration parameters, which ar ### Generic Packages -A generic package retrieves its configuration at runtime from the [AWS Systems Manager](https://aws.amazon.com/systems-manager/) Parameter Store and [AWS Secrets Manager](https://aws.amazon.com/secrets-manager/). Currently, support for this type of package has been added for OKTA Native only. To disable all issued JWTs, rotate the key pair using the Secrets Manager console. +A generic package retrieves its configuration at runtime from the [AWS Systems Manager](https://aws.amazon.com/systems-manager/) Parameter Store and [AWS Secrets Manager](https://aws.amazon.com/secrets-manager/). Currently, support for this type of package has been added for OKTA Native and Google only. To disable all issued JWTs, rotate the key pair using the Secrets Manager console. Generic packages are available for download from the Releases page of this GitHub repository. Or, to build a generic package yourself, execute: @@ -39,8 +39,11 @@ Generic packages are available for download from the Releases page of this GitHu The supported values of `package` are: * `okta_native` - builds a generic Lambda package for OKTA Native authentication +* `google hosted-domain` - builds a generic Lambda package for Google authentication, using hosted domain authentication +* `google email-lookup` - builds a generic Lambda package for Google authentication, using JSON email lookup authentication * `rotate_key_pair` - builds a Lambda package for rotating the RSA keys in AWS Secrets Manager + ## Identity Provider Guides ### Github @@ -62,14 +65,28 @@ The supported values of `package` are: 1. Create an **OAuth Client ID** from the **Create credentials** menu 1. Select **Web application** for the Application type 1. Under **Authorized redirect URIs**, enter your Cloudfront hostname with your preferred path value for the authorization callback. Example: `https://my-cloudfront-site.example.com/_callback` -1. Execute `./build.sh` in the downloaded directory. NPM will run to download dependencies and a RSA key will be generated. -1. Choose `Google` as the authorization method and enter the values for Client ID, Client Secret, Redirect URI, Hosted Domain and Session Duration -1. Select the preferred authentication method - 1. Hosted Domain (verify email's domain matches that of the given hosted domain) - 1. JSON Email Lookup - 1. Enter your JSON Email Lookup URL (example below) that consists of a single JSON array of emails to search through - 1. Google Groups Lookup - 1. [Use Google Groups to authorize users](https://github.com/Widen/cloudfront-auth/wiki/Google-Groups-Setup) +1. Decide on whether you want to use a custom package or a generic package + * For a custom package: + 1. Execute `./build.sh` in the downloaded directory. NPM will run to download dependencies and a RSA key will be generated. + 1. Choose `Google` as the authorization method and enter the values for Client ID, Client Secret, Redirect URI, Hosted Domain and Session Duration + 1. Select the preferred authentication method + 1. Hosted Domain (verify email's domain matches that of the given hosted domain) + 1. JSON Email Lookup + 1. Enter your JSON Email Lookup URL (example below) that consists of a single JSON array of emails to search through + 1. Google Groups Lookup + 1. [Use Google Groups to authorize users](https://github.com/Widen/cloudfront-auth/wiki/Google-Groups-Setup) + 1. Find the resulting `zip` file in your distribution folder + * For a generic package: + 1. Create the parameters below in the AWS Systems Manager Parameter Store in the `us-east-1` region. Replace `{name}` with the name that you will give the Lambda authentication function. + * `/{name}/client-id` (The Oauth Client ID, above) + * `/{name}/client-secret` (The Oauth Client Secret, above; this can be stored as a SecureString in parameter store) + * `/{name}/domain-name` (e.g. `my-site.cloudfront.net`) + * `/{name}/hosted-domain` (if using the `google-hosted_domain` provider) + * `/{name}/json-lookup-endpoint` (if using the `google-email_lookup` provider) + * `/{name}/callback-path` (e.g. `/_callback`) + * `/{name}/session-duration` (in seconds) + 1. Download the latest `google-hosted-domain.zip` or `google-email_lookup.zip` asset from the Releases page + 1. Upload the resulting `zip` file found in your distribution folder using the AWS Lambda console and jump to the [configuration step](#configure-lambda-and-cloudfront) ### Microsoft Azure @@ -162,6 +179,7 @@ The supported values of `package` are: 1. Download the latest `okta_native_*.zip` asset from the Releases page 1. Upload the `zip` file using the AWS Lambda console and jump to the [configuration step](#configure-lambda-and-cloudfront) + ## Configure Lambda and CloudFront See [Manual Deployment](https://github.com/Widen/cloudfront-auth/wiki/Manual-Deployment) __*or*__ [AWS SAM Deployment](https://github.com/Widen/cloudfront-auth/wiki/AWS-SAM-Deployment) diff --git a/authn/openid.index.js b/authn/openid.index.js index 898c272..a3f1b60 100644 --- a/authn/openid.index.js +++ b/authn/openid.index.js @@ -5,6 +5,7 @@ const cookie = require('cookie'); const jwkToPem = require('jwk-to-pem'); const auth = require('./auth.js'); const nonce = require('./nonce.js'); +const cfg = require('./config.js'); const axios = require('axios'); var discoveryDocument; var jwks; @@ -12,41 +13,48 @@ var config; exports.handler = (event, context, callback) => { if (typeof jwks == 'undefined' || typeof discoveryDocument == 'undefined' || typeof config == 'undefined') { - config = JSON.parse(fs.readFileSync('config.json', 'utf8')); + cfg.getConfig('config.json', context.functionName, function(error, result) { + if (error) { + console.log("Internal server error: " + error.message); + internalServerError(callback); + } else { + config = result; - // Get Discovery Document data - console.log("Get discovery document data"); - axios.get(config.DISCOVERY_DOCUMENT) - .then(function(response) { - console.log(response); + // Get Discovery Document data + console.log("Get discovery document data"); + axios.get(config.DISCOVERY_DOCUMENT) + .then(function(response) { + console.log(response); - // Get jwks from discovery document url - console.log("Get jwks from discovery document"); - discoveryDocument = response.data; - if (discoveryDocument.hasOwnProperty('jwks_uri')) { + // Get jwks from discovery document url + console.log("Get jwks from discovery document"); + discoveryDocument = response.data; + if (discoveryDocument.hasOwnProperty('jwks_uri')) { - // Get public key and verify JWT - axios.get(discoveryDocument.jwks_uri) - .then(function(response) { - console.log(response); - jwks = response.data; + // Get public key and verify JWT + axios.get(discoveryDocument.jwks_uri) + .then(function(response) { + console.log(response); + jwks = response.data; - // Callback to main function - mainProcess(event, context, callback); - }) - .catch(function(error) { - console.log("Internal server error: " + error.message); + // Callback to main function + mainProcess(event, context, callback); + }) + .catch(function(error) { + console.log("Internal server error: " + error.message); + internalServerError(callback); + }); + } else { + console.log("Internal server error: Unable to find JWK in discovery document"); internalServerError(callback); - }); - } else { - console.log("Internal server error: Unable to find JWK in discovery document"); - internalServerError(callback); - } - }) - .catch(function(error) { - console.log("Internal server error: " + error.message); - internalServerError(callback); - }); + } + }) + .catch(function(error) { + console.log("Internal server error: " + error.message); + internalServerError(callback); + }); + } + }); } else { mainProcess(event, context, callback); } @@ -175,12 +183,12 @@ function mainProcess(event, context, callback) { { "audience": headers.host[0].value, "subject": auth.getSubject(decodedData), - "expiresIn": config.SESSION_DURATION, + "expiresIn": parseInt(config.SESSION_DURATION), "algorithm": "RS256" } // Options ), { path: '/', - maxAge: config.SESSION_DURATION + maxAge: parseInt(config.SESSION_DURATION) }) }, { diff --git a/build/build.js b/build/build.js index ade0568..a748d6d 100755 --- a/build/build.js +++ b/build/build.js @@ -10,14 +10,19 @@ var config = { AUTH_REQUEST: {}, TOKEN_REQUEST: {} }; var oldConfig; config.DISTRIBUTION = process.argv[2] +config.SUBDIST = ''; if (config.DISTRIBUTION) { config.AUTHN = config.DISTRIBUTION.toUpperCase(); + config.AUTHZ = process.argv[3]; shell.mkdir('-p', 'distributions/' + config.DISTRIBUTION); switch (config.DISTRIBUTION) { case 'okta_native': genericOktaConfiguration(); break; + case 'google': + genericGoogleConfiguration(); + break; case 'rotate_key_pair': buildRotateKeyPair(); break; @@ -254,56 +259,46 @@ function googleConfiguration() { config.TOKEN_REQUEST.redirect_uri = result.REDIRECT_URI; config.TOKEN_REQUEST.grant_type = 'authorization_code'; - config.AUTHZ = result.AUTHZ; - - shell.cp('./authn/openid.index.js', './distributions/' + config.DISTRIBUTION + '/index.js'); - shell.cp('./nonce.js', './distributions/' + config.DISTRIBUTION + '/nonce.js'); - - fs.writeFileSync('distributions/' + config.DISTRIBUTION + '/config.json', JSON.stringify(result, null, 4)); - switch (result.AUTHZ) { case '1': - shell.cp('./authz/google.hosted-domain.js', './distributions/' + config.DISTRIBUTION + '/auth.js'); - shell.cp('./nonce.js', './distributions/' + config.DISTRIBUTION + '/nonce.js'); - writeConfig(config, zip, ['config.json', 'index.js', 'auth.js', 'nonce.js']); + config.AUTHZ = 'hosted-domain'; + buildGoogle(false); break; case '2': - shell.cp('./authz/google.json-email-lookup.js', './distributions/' + config.DISTRIBUTION + '/auth.js'); + config.AUTHZ = 'email-lookup'; prompt.start(); prompt.message = colors.blue(">>>"); prompt.get({ properties: { JSON_EMAIL_LOOKUP: { description: colors.red("JSON email lookup endpoint"), + required: true, default: R.pathOr('', ['JSON_EMAIL_LOOKUP'], oldConfig) } } }, function (err, result) { config.JSON_EMAIL_LOOKUP = result.JSON_EMAIL_LOOKUP; - writeConfig(config, zip, ['config.json', 'index.js', 'auth.js', 'nonce.js']); + buildGoogle(false); }); break; case '3': + config.AUTHZ='groups-lookup'; prompt.start(); prompt.message = colors.blue(">>>"); prompt.get({ properties: { + SERVICE_ACCOUNT_EMAIL: { + description: colors.red("Service Account Email"), + required: true, + default: R.pathOr('', ['SERVICE_ACCOUNT_EMAIL'], oldConfig) + }, MOVE: { message: colors.red("Place ") + colors.blue("google-authz.json") + colors.red(" file into ") + colors.blue("distributions/" + config.DISTRIBUTION) + colors.red(" folder. Press enter when done") } } }, function (err, result) { - if (!shell.test('-f', 'distributions/' + config.DISTRIBUTION + '/google-authz.json')) { - console.log('Need google-authz.json to use google groups authentication. Stopping build...'); - } else { - var googleAuthz = JSON.parse(fs.readFileSync('distributions/' + config.DISTRIBUTION + '/google-authz.json')); - if (!googleAuthz.hasOwnProperty('cloudfront_authz_groups')) { - console.log('google-authz.json is missing cloudfront_authz_groups. Stopping build...'); - } else { - shell.cp('./authz/google.groups-lookup.js', './distributions/' + config.DISTRIBUTION + '/auth.js'); - googleGroupsConfiguration(); - } - } + config.SERVICE_ACCOUNT_EMAIL = result.SERVICE_ACCOUNT_EMAIL; + buildGoogle(false); }); break; default: @@ -312,21 +307,77 @@ function googleConfiguration() { }); } -function googleGroupsConfiguration() { - prompt.start(); - prompt.message = colors.blue(">>>"); - prompt.get({ - properties: { - SERVICE_ACCOUNT_EMAIL: { - description: colors.red("Service Account Email"), - required: true, - default: R.pathOr('', ['SERVICE_ACCOUNT_EMAIL'], oldConfig) - } +function genericGoogleConfiguration() { + config.PRIVATE_KEY = '${private-key}'; + config.PUBLIC_KEY = '${public-key}'; + + config.DISCOVERY_DOCUMENT = 'https://accounts.google.com/.well-known/openid-configuration'; + config.SESSION_DURATION = '${session-duration}'; // seconds + + config.CALLBACK_PATH = '${callback-path}'; + config.HOSTED_DOMAIN = '${hosted-domain}'; + + config.AUTH_REQUEST.client_id = '${client-id}'; + config.AUTH_REQUEST.response_type = 'code'; + config.AUTH_REQUEST.scope = 'openid email'; + config.AUTH_REQUEST.redirect_uri = 'https://${domain-name}${callback-path}'; + + config.AUTH_REQUEST.hd = '${hosted-domain}'; + + config.TOKEN_REQUEST.client_id = '${client-id}'; + config.TOKEN_REQUEST.client_secret = '${client-secret}'; + config.TOKEN_REQUEST.redirect_uri = 'https://${domain-name}${callback-path}'; + config.TOKEN_REQUEST.grant_type = 'authorization_code'; + + buildGoogle(true) +} + +function buildGoogle(isGeneric) { + var files = ["config.json", "config.js", "index.js", "auth.js", "nonce.js"]; + + switch (config.AUTHZ) { + case 'hosted-domain': + config.SUBDIST = isGeneric && "hosted_domain"; + shell.cp('./authz/google.hosted-domain.js', './distributions/' + config.DISTRIBUTION + '/auth.js'); + shell.cp('./nonce.js', './distributions/' + config.DISTRIBUTION + '/nonce.js'); + break; + + case 'email-lookup': + config.SUBDIST = isGeneric && "email_lookup"; + shell.cp('./authz/google.json-email-lookup.js', './distributions/' + config.DISTRIBUTION + '/auth.js'); + if (isGeneric) { + config.JSON_EMAIL_LOOKUP = '${json-lookup-endpoint}'; + } + break; + + case 'groups-lookup': + config.SUBDIST = isGeneric && "groups_lookup"; + if (isGeneric) { + // TODO: provide a base64 encoded method for user-supplied google-authz.json + console.log("generic builds for groups-lookup not yet supported"); + return; + } + shell.cp('./authz/google.groups-lookup.js', './distributions/' + config.DISTRIBUTION + '/auth.js'); + if (!shell.test('-f', 'distributions/' + config.DISTRIBUTION + '/google-authz.json')) { + console.log('Need google-authz.json to use google groups authentication. Stopping build...'); + return; + } + var googleAuthz = JSON.parse(fs.readFileSync('distributions/' + config.DISTRIBUTION + '/google-authz.json')); + if (!googleAuthz.hasOwnProperty('cloudfront_authz_groups')) { + console.log('google-authz.json is missing cloudfront_authz_groups. Stopping build...'); + return; + } + files.push('google-authz.json'); + break; + + default: + console.log("Invalid Google auth type"); + return; } - }, function (err, result) { - config.SERVICE_ACCOUNT_EMAIL = result.SERVICE_ACCOUNT_EMAIL; - writeConfig(config, zip, ['config.json', 'index.js', 'auth.js', 'google-authz.json', 'nonce.js']); - }); + shell.cp('./authn/openid.index.js', './distributions/' + config.DISTRIBUTION + '/index.js'); + shell.cp('./nonce.js', './distributions/' + config.DISTRIBUTION + '/nonce.js'); + shell.cp(isGeneric ? './config/generic.config.js' : './config/custom.config.js', './distributions/' + config.DISTRIBUTION + '/config.js'); + writeConfig(config, zip, files); } function oktaConfiguration() { @@ -647,6 +698,10 @@ function buildRotateKeyPair() { function zip(files) { var filesString = ''; + var zipName = config.DISTRIBUTION; + if (config.SUBDIST) { + zipName += '-'+config.SUBDIST; + } for (var i = 0; i < files.length; i++) { filesString += ' distributions/' + config.DISTRIBUTION + '/' + files[i] + ' '; } @@ -654,9 +709,9 @@ function zip(files) { fs.unlinkSync('distributions/' + config.DISTRIBUTION + '/' + config.DISTRIBUTION + '.zip'); } catch (err) { } - shell.exec('zip -q distributions/' + config.DISTRIBUTION + '/' + config.DISTRIBUTION + '.zip ' + 'package-lock.json package.json -r node_modules'); - shell.exec('zip -q -r -j distributions/' + config.DISTRIBUTION + '/' + config.DISTRIBUTION + '.zip ' + filesString); - console.log(colors.green("Done... created Lambda function distributions/" + config.DISTRIBUTION + "/" + config.DISTRIBUTION + ".zip")); + shell.exec('zip -q distributions/' + config.DISTRIBUTION + '/' + zipName + '.zip ' + 'package-lock.json package.json -r node_modules'); + shell.exec('zip -q -r -j distributions/' + config.DISTRIBUTION + '/' + zipName + '.zip ' + filesString); + console.log(colors.green("Done... created Lambda function distributions/" + config.DISTRIBUTION + "/" + zipName + ".zip")); } function writeConfig(result, callback, files) { diff --git a/config/generic.config.js b/config/generic.config.js index e5c91cd..23dac5f 100644 --- a/config/generic.config.js +++ b/config/generic.config.js @@ -11,7 +11,7 @@ module.exports.getConfig = function (fileName, functionName, callback) { // Get parameters from SSM Parameter Store const ssm = new aws.SSM({ region: 'us-east-1' }); - const getParametersByPathPromise = ssm.getParametersByPath({ Path: `/${name}` }).promise(); + const getParametersByPathPromise = ssm.getParametersByPath({ Path: `/${name}`, WithDecryption: true }).promise(); // Get key pair from Secrets Manager const secretsmanager = new aws.SecretsManager({ region: 'us-east-1' });