Skip to content
This repository has been archived by the owner on Feb 11, 2023. It is now read-only.

Externalise configuration and secrets #70

Open
wants to merge 68 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
68 commits
Select commit Hold shift + click to select a range
c7be318
Merge pull request #2 from iress/fix-code-challenge-import
apgrucza May 28, 2020
ff883df
Generic package (#3)
apgrucza May 29, 2020
dd7fd03
Fixed release asset name (#4)
apgrucza May 29, 2020
a228ede
Release asset name (#5)
apgrucza May 31, 2020
655da6c
Convert tabs to spaces (#6)
apgrucza Jun 5, 2020
fb89b40
Add reference to GitHub Releases (#7)
apgrucza Jun 5, 2020
36bec4c
Mocha (#8)
apgrucza Jun 16, 2020
748a756
Pass expiresIn as seconds rather than milliseconds (#9)
apgrucza Jul 16, 2020
280de0b
CI scripts to exclude devDependencies from Lambda (#10)
apgrucza Jul 17, 2020
c1bade0
Exclude devDependencies when using build.sh (#11)
apgrucza Jul 31, 2020
1d83d49
Use access token and dont re-sign using custom keys. (#13)
nbshetty Oct 5, 2020
c0941d5
Fix/nonce check (#16)
nbshetty Oct 7, 2020
5eba0d3
use the access token kid to pick up the signing key (#17)
nbshetty Oct 8, 2020
ac736eb
PFS-1811 Add scope variable for AUTH_REQUEST
Iress-Kian Apr 29, 2021
30d2dd8
Merge pull request #18 from iress/PFS-1811
Iress-Kian May 3, 2021
4785c61
PFS-1811 Update READ.ME (#19)
Iress-Kian May 3, 2021
30ef2cc
Change example base URL for Okta Native [skip ci] (#20)
apgrucza Jul 26, 2021
18b00ac
Bump Node version to 14 and update packages (#21)
apgrucza Jul 30, 2021
912a036
WTL-852 Add Terraform configuration (#22)
apgrucza Sep 8, 2021
16abdc5
Added Terraform example - CloudFront distribution (#23)
apgrucza Sep 12, 2021
9bd02c2
allow secrets manager secrets to be encrypted with a CMK
Sep 29, 2021
70bbe91
updated variable description
Sep 29, 2021
57af25c
Merge pull request #25 from iress/encrypt-sec-manager-secret-cmk
chris-wilbur-wilson Sep 30, 2021
863ec52
Allow user to specify a customized IDP. (#26)
Nov 30, 2021
2b9e826
Update fault value of IDP to be a space to avoid SSM parameter creati…
Dec 1, 2021
89df7c3
remove max version constraint on provider
Tar-Elendil Oct 12, 2022
3946160
secret rotation now depends on the permissions
iress-ac Oct 14, 2022
5c9c396
Ensure permissions are deployed before the secret rotation is applied
patrickherrera Oct 17, 2022
699dcf8
Merge pull request #29 from Tar-Elendil/provider_constraint_update
patrickherrera Oct 17, 2022
0fb3237
fix: Migrate away from 'override_json' which is deprecated in AWS Pro…
patrickherrera Oct 17, 2022
c6c30b5
implement logout
iress-ac Nov 9, 2022
51ee344
add logout path to generic config
iress-ac Nov 10, 2022
21b2496
add logout path variable
iress-ac Nov 10, 2022
cd16605
change logout to redirect to auth provider
iress-ac Nov 11, 2022
9ca91fb
fix redirect path
iress-ac Nov 11, 2022
505eebe
Merge pull request #36 from iress-ac/add-logout-route
iress-ac Nov 14, 2022
b1af407
Correct default logout path
iress-ac Nov 15, 2022
095c614
Merge pull request #37 from iress/default-logout-path
iress-ac Nov 15, 2022
f4c0e44
Merge pull request #32 from iress/deprecation_fix
anevis Jul 12, 2023
8455c15
fix: uri encode template replacement to ovoid XSS
SiCoe Sep 22, 2023
d9e32aa
NONCE and CV cookies as `secure` for pkce
SiCoe Sep 22, 2023
6fc36a2
all cookies sameSite as Strict for pkce
SiCoe Sep 25, 2023
c5fc6c4
HTML instead of URI encoding for unauthorized body
SiCoe Sep 25, 2023
801f50d
include nodejs18.x in build targets
SiCoe Sep 25, 2023
7d0c091
reference version 4 in docs and examples
SiCoe Sep 25, 2023
35b12be
Merge pull request #41 from iress/node-18
SiCoe Sep 26, 2023
bf49023
Merge pull request #39 from iress/DP-481
SiCoe Sep 26, 2023
d1a2225
Merge branch 'v4' into DP-479
SiCoe Sep 26, 2023
fee9836
Merge pull request #40 from iress/DP-479
SiCoe Sep 26, 2023
ac267da
use sameSite: strict and secure for openid
SiCoe Sep 26, 2023
360ada9
use sameSite: strict and secure for github
SiCoe Sep 26, 2023
c84be23
Merge pull request #42 from iress/openid-secure-samesite
SiCoe Sep 26, 2023
888a212
include node 14 in "engines" of package
SiCoe Sep 26, 2023
abc09fa
correct footers to correct repository url
SiCoe Sep 27, 2023
d34734d
include Content-Type header in responses
SiCoe Sep 27, 2023
60915a5
Merge pull request #44 from iress/content-type
SiCoe Sep 27, 2023
d46dbbe
Merge branch 'v4' into footer
SiCoe Sep 27, 2023
0d6d923
Merge pull request #43 from iress/footer
SiCoe Sep 27, 2023
185bfdb
replace dependancy 'entities' with 'html-entities'
SiCoe Oct 2, 2023
1d9f87a
Merge pull request #38 from iress/DP-803
SiCoe Oct 2, 2023
4454871
update npm dependancies to remove vulnerabilities
SiCoe Oct 2, 2023
183bf6b
Merge pull request #45 from iress/v4
SiCoe Oct 2, 2023
483f7e1
default runtime to nodejs16.x
SiCoe Oct 2, 2023
59cdf41
Merge pull request #46 from iress/fix-aws-sdk
SiCoe Oct 2, 2023
5824dc9
set SameSite cookie attribute to lax for CV and NONCE
SiCoe Oct 3, 2023
f051484
Merge pull request #47 from iress/SameSite-lax
SiCoe Oct 3, 2023
03efeef
fix: stop redirect loop caused by TOKEN cookie not sent
SiCoe Oct 3, 2023
bdd68f0
Merge pull request #48 from iress/fix-same-site
SiCoe Oct 3, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: Generic Package CI
on: push

jobs:
build:

runs-on: ubuntu-latest

strategy:
matrix:
node-version: [14.x, 16.x]

steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}

- run: npm run-script test-ci

- run: npm run-script build-ci okta_native
- run: test -f distributions/okta_native/okta_native.zip
- run: npm run-script build-ci rotate_key_pair
- run: test -f distributions/rotate_key_pair/rotate_key_pair.zip

- uses: actions/upload-artifact@v2
with:
name: packages_${{ matrix.node-version }}
path: distributions/*/*.zip
36 changes: 36 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name: Upload Release Assets

on:
push:
# Match against refs/tags
tags:
- 'v*' # Push events matching v*, e.g. v1.0.0, v1.1.0

jobs:
build:
name: Upload Release Assets
runs-on: ubuntu-latest
steps:

- name: Checkout code
uses: actions/checkout@v2

- name: Use Node.js 14
uses: actions/setup-node@v2
with:
node-version: '14'

- name: Run tests
run: npm run-script test-ci

- name: Build okta_native
run: npm run-script build-ci okta_native
- name: Build rotate_key_pair
run: npm run-script build-ci rotate_key_pair

- name: Create Release
uses: softprops/action-gh-release@v1
with:
files: |
./distributions/okta_native/okta_native.zip
./distributions/rotate_key_pair/rotate_key_pair.zip
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,6 @@ tests/logs/*
tests/config
/distributions
packaged.yaml
.terraform*
terraform.tfstate*
/infra/terraform/modules/_lambda/packages/*
67 changes: 57 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,36 @@
# Cloudfront Auth

[Google Apps (G Suite)](https://developers.google.com/identity/protocols/OpenIDConnect), [Microsoft Azure AD](https://docs.microsoft.com/en-us/azure/active-directory/develop/active-directory-protocols-oauth-code), [GitHub](https://developer.github.com/apps/building-oauth-apps/authorization-options-for-oauth-apps/), [OKTA](https://www.okta.com/), [Auth0](https://auth0.com/), [Centrify](https://centrify.com) authentication for [CloudFront](https://aws.amazon.com/cloudfront/) using [Lambda@Edge](http://docs.aws.amazon.com/lambda/latest/dg/lambda-edge.html). The original use case for `cloudfront-auth` was to serve private S3 content over HTTPS without running a proxy server in EC2 to authenticate requests; but `cloudfront-auth` can be used authenticate requests of any Cloudfront origin configuration.

## Description

Upon successful authentication, a cookie (named `TOKEN`) with the value of a signed JWT is set and the user redirected back to the originally requested path. Upon each request, Lambda@Edge checks the JWT for validity (signature, expiration date, audience and matching hosted domain) and will redirect the user to configured provider's login when their session has timed out.

## Usage

If your CloudFront distribution is pointed at a S3 bucket, [configure origin access identity](http://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-restricting-access-to-s3.html#private-content-creating-oai-console) so S3 objects can be stored with private permissions. (Origin access identity requires the S3 ACL owner be the account owner. Use our [s3-object-owner-monitor](https://github.com/Widen/s3-object-owner-monitor) Lambda function if writing objects across multiple accounts.)

Enable SSL/HTTPS on your CloudFront distribution; AWS Certificate Manager can be used to provision a no-cost certificate.

Session duration is defined as the number of hours that the JWT is valid for. After session expiration, cloudfront-auth will redirect the user to the configured provider to re-authenticate. RSA keys are used to sign and validate the JWT. If the files `id_rsa` and `id_rsa.pub` do not exist they will be automatically generated by the build. To disable all issued JWTs upload a new ZIP using the Lambda Console after deleting the `id_rsa` and `id_rsa.pub` files (a new key will be automatically generated).
Session duration is defined as the number of hours that the JWT is valid for. After session expiration, cloudfront-auth will redirect the user to the configured provider to re-authenticate. RSA keys are used to sign and validate the JWT.

## Lambda Deployment Packages

### Custom Packages

A custom package has all the configuration baked into it (including secrets). To build a custom package, execute:

```bash
./build.sh
```

The build script prompts you for the required configuration parameters, which are described in [Identity Provider Guides](#identity-provider-guides). If the files `id_rsa` and `id_rsa.pub` do not exist they will be automatically generated by the build. To disable all issued JWTs upload a new ZIP using the Lambda Console after deleting the `id_rsa` and `id_rsa.pub` files (a new key will be automatically generated).

### 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.

Generic packages are pre-built and available for download from the Releases page of this GitHub repository. [Terraform modules](./infra/terraform/README.md) are available for downloading and deploying generic packages and their configuration resources.

## Identity Provider Guides

Expand All @@ -20,7 +42,7 @@ Session duration is defined as the number of hours that the JWT is valid for. Af
1. For **Authorization callback URL** 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 `Github` as the authorization method and enter the values for Client ID, Client Secret, Redirect URI, Session Duration and Organization
- cloudfront-auth will check that users are a member of the entered Organization.
* cloudfront-auth will check that users are a member of the entered Organization.
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)

### Google
Expand All @@ -47,7 +69,7 @@ Session duration is defined as the number of hours that the JWT is valid for. Af
1. In your Azure portal, go to Azure Active Directory and select **App registrations**
1. Create a new application registration with an application type of **Web app / api**
1. Once created, go to your application `Settings -> Keys` and make a new key with your desired duration. Click save and copy the value. This will be your `client_secret`
1. Above where you selected `Keys`, go to `Reply URLs` and enter your Cloudfront hostname with your preferred path value for the authorization callback. Example: https://my-cloudfront-site.example.com/_callback
1. Above where you selected `Keys`, go to `Reply URLs` and 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 `Microsoft` as the authorization method and enter the values for [Tenant](https://docs.microsoft.com/en-us/azure/active-directory/develop/active-directory-howto-tenant), Client ID (**Application ID**), Client Secret (**previously created key**), Redirect URI and Session Duration
1. Select the preferred authentication method
Expand Down Expand Up @@ -115,32 +137,57 @@ Session duration is defined as the number of hours that the JWT is valid for. Af
1. Client Id from the application created in our previous step (can be found at the bottom of the general tab)
1. Base Url
1. This is named the 'Org URL' and can be found in the top right of the Dashboard tab.
1. To use the [generic package](#generic-packages), jump straight to [Terraform Modules for CloudFront Authentication](./infra/terraform/README.md).
1. Execute `./build.sh` in the downloaded directory. NPM will run to download dependencies and a RSA key will be generated.
1. Choose `OKTA Native` as the authorization method and enter the values for Base URL (Org URL), Client ID, PKCE Code Verifier Length, Redirect URI, and Session Duration
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)

## Configure Lambda and CloudFront

[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)
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)

## Authorization Method Examples

- [Use Google Groups to authorize users](https://github.com/Widen/cloudfront-auth/wiki/Google-Groups-Setup)
* [Use Google Groups to authorize users](https://github.com/Widen/cloudfront-auth/wiki/Google-Groups-Setup)

- JSON array of email addresses
* JSON array of email addresses

```
```json
[ "[email protected]", "[email protected]" ]
```

## Testing

Detailed instructions on testing your function can be found [in the Wiki](https://github.com/Widen/cloudfront-auth/wiki/Debug-&-Test).

## Build Requirements
- [npm](https://www.npmjs.com/) ^5.6.0
- [node](https://nodejs.org/en/) ^10.0
- [openssl](https://www.openssl.org)

* [npm](https://www.npmjs.com/) ^7.20.0
* [node](https://nodejs.org/en/) ^14.0
* [openssl](https://www.openssl.org)

## Building Generic Packages

If you need to build a generic package yourself, execute:

```bash
./build.sh package
```

The supported values of `package` are:

* `okta_native` - builds a generic Lambda package for OKTA Native authentication
* `rotate_key_pair` - builds a Lambda package for rotating the RSA keys in AWS Secrets Manager

GitHub Actions automatically creates a new GitHub release when the repository owner pushes a tag that begins with `v`:

```sh
git tag -a -m "Target AWS Lambda Node.js 14.x runtime" v3.0.0
git push origin v3.0.0
```

## Contributing

All contributions are welcome. Please create an issue in order open up communication with the community.

When implementing a new flow or using an already implemented flow, be sure to follow the same style used in `build.js`. The config.json file should have an object for each request made. For example, `openid.index.js` converts config.AUTH_REQUEST and config.TOKEN_REQUEST to querystrings for simplified requests (after adding dynamic variables such as state or nonce). For implementations that are not generic (most), endpoints are hardcoded in to the config (or discovery documents).
Expand Down
6 changes: 3 additions & 3 deletions authn/openid.index.js
Original file line number Diff line number Diff line change
Expand Up @@ -294,9 +294,9 @@ function unauthorized(error, error_description, error_uri, callback) {
</html>
`;

page = page.replace(/%error%/g, error);
page = page.replace(/%error_description%/g, error_description);
page = page.replace(/%error_uri%/g, error_uri);
page = page.replace(/%error%/g, encodeURI(error).replace(/%20/g,' '));
page = page.replace(/%error_description%/g, encodeURI(error_description).replace(/%20/g,' '));
page = page.replace(/%error_uri%/g, encodeURI(error_uri));

// Unauthorized access attempt. Reset token and nonce cookies
const response = {
Expand Down
120 changes: 65 additions & 55 deletions authn/pkce.index.js
Original file line number Diff line number Diff line change
@@ -1,53 +1,60 @@
const qs = require('querystring');
const fs = require('fs');
const jwt = require('jsonwebtoken');
const cookie = require('cookie');
const jwkToPem = require('jwk-to-pem');
const auth = require('./auth.js');
const nonce = require('./nonce.js');
const codeChallenge = require('./code-challenge.js');
const cfg = require('./config.js');
const axios = require('axios');
var discoveryDocument;
var jwks;
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);
}
Expand Down Expand Up @@ -123,21 +130,19 @@ function mainProcess(event, context, callback) {
.then(function(response) {
console.log(response);
const decodedData = jwt.decode(response.data.id_token, {complete: true});
console.log(decodedData);
const decodedAccessTokenData = jwt.decode(response.data.access_token, {complete: true});
console.log("Decoded id_token: ", decodedData);
console.log("Decoded access_token", decodedAccessTokenData);
try {
console.log("Searching for JWK from discovery document");

// Search for correct JWK from discovery document and create PEM
var pem = "";
for (var i = 0; i < jwks.keys.length; i++) {
if (decodedData.header.kid === jwks.keys[i].kid) {
pem = jwkToPem(jwks.keys[i]);
}
}
console.log("Verifying JWT");
var pem = createPEM(decodedAccessTokenData.header.kid); //We are verifying the access token

// Verify the JWT, the payload email, and that the email ends with configured hosted domain
jwt.verify(response.data.id_token, pem, { algorithms: ['RS256'] }, function(err, decoded) {
console.log("Verifying JWT");
const access_token = response.data.access_token;
// Verify the access token JWT, the payload email, and that the email ends with configured hosted domain
jwt.verify(access_token, pem, { algorithms: ['RS256'] }, function(err, decoded) {
if (err) {
switch (err.name) {
case 'TokenExpiredError':
Expand All @@ -157,7 +162,7 @@ function mainProcess(event, context, callback) {
// Validate nonce
if ("cookie" in headers
&& "NONCE" in cookie.parse(headers["cookie"][0].value)
&& nonce.validateNonce(decoded.nonce, cookie.parse(headers["cookie"][0].value).NONCE)) {
&& nonce.validateNonce(decodedData.payload.nonce, cookie.parse(headers["cookie"][0].value).NONCE)) {
console.log("Setting cookie and redirecting.");

// Once verified, create new JWT for this server
Expand All @@ -175,18 +180,11 @@ function mainProcess(event, context, callback) {
"set-cookie" : [
{
"key": "Set-Cookie",
"value" : cookie.serialize('TOKEN', jwt.sign(
{ },
config.PRIVATE_KEY.trim(),
{
"audience": headers.host[0].value,
"subject": auth.getSubject(decodedData),
"expiresIn": config.SESSION_DURATION,
"algorithm": "RS256"
} // Options
), {
"value" : cookie.serialize('TOKEN', access_token, {
path: '/',
maxAge: config.SESSION_DURATION
httpOnly: true,
secure: true,
maxAge: parseInt(config.SESSION_DURATION)
})
},
{
Expand Down Expand Up @@ -217,9 +215,11 @@ function mainProcess(event, context, callback) {
} else if ("cookie" in headers
&& "TOKEN" in cookie.parse(headers["cookie"][0].value)) {
console.log("Request received with TOKEN cookie. Validating.");

const decodedToken = jwt.decode(cookie.parse(headers["cookie"][0].value).TOKEN, {complete: true});
// Search for correct JWK from discovery document and create PEM
var pem = createPEM(decodedToken.header.kid);
// Verify the JWT, the payload email, and that the email ends with configured hosted domain
jwt.verify(cookie.parse(headers["cookie"][0].value).TOKEN, config.PUBLIC_KEY.trim(), { algorithms: ['RS256'] }, function(err, decoded) {
jwt.verify(cookie.parse(headers["cookie"][0].value).TOKEN, pem, { algorithms: ['RS256'] }, function(err, decoded) {
if (err) {
switch (err.name) {
case 'TokenExpiredError':
Expand All @@ -243,6 +243,16 @@ function mainProcess(event, context, callback) {
console.log("Redirecting to OIDC provider.");
redirect(request, headers, callback);
}

function createPEM(kid) {
var pem = "";
for (var i = 0; i < jwks.keys.length; i++) {
if (kid === jwks.keys[i].kid) {
pem = jwkToPem(jwks.keys[i]);
}
}
return pem;
}
}

function redirect(request, headers, callback) {
Expand Down
2 changes: 1 addition & 1 deletion build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,4 @@ if [ ! -d "distributions" ]; then
fi
fi

npm run-script build
npm run-script build-ci "$@"
Loading