├── .env.example ├── .github ├── CODEOWNERS └── workflows │ └── ci.yaml ├── .gitignore ├── .npmignore ├── .prettierrc.json ├── .vscode └── settings.json ├── README.md ├── app.json ├── docker-compose.yml ├── docs ├── example.png ├── proof-process.png └── security │ ├── requesters.md │ └── storing-otps.md ├── jest.config.js ├── jest.setup.js ├── package.json ├── patches ├── evergreen-ui+6.13.3.patch └── parse5+7.1.1.patch ├── postcss.config.js ├── src ├── __mocks__ │ ├── db.ts │ ├── fake-sass.js │ └── project.ts ├── client │ ├── __tests__ │ │ └── utils.spec.ts │ ├── ambient.d.ts │ ├── base.css │ ├── components │ │ ├── AddProjectDialog.scss │ │ ├── AddProjectDialog.tsx │ │ ├── App.scss │ │ ├── App.tsx │ │ ├── DangerZone.scss │ │ ├── DangerZone.tsx │ │ ├── Dashboard.scss │ │ ├── Dashboard.tsx │ │ ├── MainAppRouter.tsx │ │ ├── MenuHeader.scss │ │ ├── MenuHeader.tsx │ │ ├── ProjectConfig.scss │ │ ├── ProjectConfig.tsx │ │ ├── ProjectSecret.scss │ │ ├── ProjectSecret.tsx │ │ ├── ReqResConfig.scss │ │ ├── RequesterConfig.tsx │ │ ├── ResponderConfig.tsx │ │ ├── SlackOAuthResult.scss │ │ ├── SlackOAuthResult.tsx │ │ ├── __tests__ │ │ │ ├── MainAppRouter.spec.tsx │ │ │ ├── MenuHeader.spec.tsx │ │ │ ├── ProjectSecret.spec.tsx │ │ │ ├── RequesterConfig.spec.tsx │ │ │ └── __snapshots__ │ │ │ │ ├── MainAppRouter.spec.tsx.snap │ │ │ │ ├── MenuHeader.spec.tsx.snap │ │ │ │ ├── ProjectSecret.spec.tsx.snap │ │ │ │ └── RequesterConfig.spec.tsx.snap │ │ ├── configurators │ │ │ ├── CircleCIRequesterConfig.tsx │ │ │ ├── GenericAccessTokenRequesterConfig.tsx │ │ │ ├── GitHubActionsRequesterConfig.tsx │ │ │ ├── SlackResponderConfig.tsx │ │ │ └── __tests__ │ │ │ │ ├── CircleCIRequesterConfig.spec.tsx │ │ │ │ ├── GenericAccessTokenRequesterConfig.spec.tsx │ │ │ │ └── __snapshots__ │ │ │ │ ├── CircleCIRequesterConfig.spec.tsx.snap │ │ │ │ └── GenericAccessTokenRequesterConfig.spec.tsx.snap │ │ └── icons │ │ │ ├── CircleCI.tsx │ │ │ ├── GitHub.tsx │ │ │ ├── Logo.tsx │ │ │ ├── Rocket.tsx │ │ │ ├── Slack.tsx │ │ │ ├── __tests__ │ │ │ ├── CircleCI.spec.tsx │ │ │ ├── GitHub.spec.tsx │ │ │ ├── Logo.spec.tsx │ │ │ ├── Rocket.spec.tsx │ │ │ ├── Slack.spec.tsx │ │ │ └── __snapshots__ │ │ │ │ ├── CircleCI.spec.tsx.snap │ │ │ │ ├── GitHub.spec.tsx.snap │ │ │ │ ├── Logo.spec.tsx.snap │ │ │ │ ├── Rocket.spec.tsx.snap │ │ │ │ └── Slack.spec.tsx.snap │ │ │ └── icon-props.ts │ ├── index.tsx │ ├── polyfill.ts │ ├── state │ │ └── user.ts │ ├── template.ejs │ └── utils.ts ├── common │ ├── __tests__ │ │ └── types.spec.ts │ └── types.ts └── server │ ├── api │ ├── auth │ │ └── index.ts │ ├── index.ts │ ├── project │ │ ├── __tests__ │ │ │ └── _safe.spec.ts │ │ ├── _safe.ts │ │ ├── config.ts │ │ └── index.ts │ ├── repo │ │ └── index.ts │ └── request │ │ ├── __tests__ │ │ ├── __snapshots__ │ │ │ └── requester.spec.ts.snap │ │ ├── index.spec.ts │ │ └── requester.spec.ts │ │ ├── index.ts │ │ └── requester.ts │ ├── db │ └── models.ts │ ├── helpers │ ├── __tests__ │ │ ├── a.spec.ts │ │ └── middleware.spec.ts │ ├── _joi_extract.ts │ ├── _middleware.ts │ ├── a.ts │ ├── auth.ts │ └── oidc.ts │ ├── index.ts │ ├── requesters │ ├── CircleCIRequester.ts │ ├── GitHubActionsRequester.ts │ └── Requester.ts │ ├── responders │ ├── Responder.ts │ ├── SlackResponder.ts │ └── index.ts │ └── server.ts ├── tsconfig.json ├── tsconfig.public.json ├── typings └── ambiend.d.ts ├── webpack.config.js ├── webpack.production.config.js ├── webpack.rules.js └── yarn.lock /.env.example: -------------------------------------------------------------------------------- 1 | # Slack App Details 2 | SLACK_SIGNING_SECRET= 3 | SLACK_CLIENT_ID= 4 | SLACK_CLIENT_SECRET= 5 | 6 | # Redis session secret 7 | SESSION_SECRET= 8 | 9 | # GitHub App Details 10 | GITHUB_CLIENT_ID= 11 | GITHUB_CLIENT_SECRET= -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @continuousauth/wg-ecosystem @continuousauth/wg-infra 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 11 | - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 12 | with: 13 | node-version: '20.x' 14 | - name: Restore Cache 15 | uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 16 | with: 17 | path: node_modules 18 | key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }} 19 | restore-keys: | 20 | ${{ runner.os }}-yarn- 21 | - name: Install Dependencies 22 | run: yarn --frozen-lockfile 23 | - name: Build CFA 24 | run: yarn build 25 | test: 26 | runs-on: ubuntu-latest 27 | 28 | steps: 29 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 30 | - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 31 | with: 32 | node-version: '20.x' 33 | - name: Restore Cache 34 | uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 35 | with: 36 | path: node_modules 37 | key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }} 38 | restore-keys: | 39 | ${{ runner.os }}-yarn- 40 | - name: Install Dependencies 41 | run: yarn --frozen-lockfile 42 | - name: Run Tests 43 | run: yarn test:ci --maxWorkers=2 44 | - name: Run Linter 45 | run: yarn prettier:check 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | *.log 4 | .env 5 | docker 6 | coverage 7 | tsconfig.tsbuildinfo 8 | public_out 9 | junit.xml 10 | .envrc 11 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | lib/server 3 | *.log 4 | tsconfig.json 5 | .env* 6 | docker 7 | app.json 8 | .prettierrc.json 9 | docker-compose.yml 10 | docs 11 | coverage 12 | jest.config.js 13 | __tests__ -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "tabWidth": 2, 4 | "singleQuote": true, 5 | "printWidth": 100, 6 | "parser": "typescript" 7 | } 8 | 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "typescript.tsdk": "node_modules/typescript/lib" 4 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CFA: Continuous Factor Authentication 2 | 3 | [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/continuousauth/web/ci.yaml?branch=main&label=CI&logo=github&style=for-the-badge)](https://github.com/continuousauth/web/actions/workflows/ci.yaml) 4 | 5 | This service is responsible for safely requesting and delivering a 2FA token to an arbitrary CI job. Typically though a tool like `semantic-release`. 6 | 7 | ## Usage 8 | 9 | Head over to https://continuousauth.dev and sign in to start using CFA. You can also check out our [usage documentation](https://docs.continuousauth.dev) for more information on how to use CFA and what it does. 10 | 11 | ## What does it look like? 12 | 13 | Something like this: 14 | 15 | ![CFA Example](docs/example.png) 16 | 17 | ## How does it work? 18 | 19 | At a high level CFA is just a proxy for 2FA, in a 2FA model there is "something you know" and "something you have". In the CFA model the "something you know" is still your NPM auth token, and the "something you have" is still the OTP generator. CFA just safely mediates a connection between the CI build and you by validating the CI build through both a CFA token and by forcing the CI build to "prove" it is actually asking for a token. 20 | 21 | Included below is a flow diagram which explains what CFA verifies and how it verifies it. Other important parts of the code for this verification process can be found in each `Requester` implementation [`src/server/requesters`](src/server/requesters). 22 | 23 | ![Proof Process](./docs/proof-process.png) 24 | 25 | ## Heroku Configuration 26 | 27 | You should probably just use the hosted version of CFA at https://continuousauth.dev but if you really want to deploy it yourself these are things you need to do. 28 | 29 | The following environment variables need to be set: 30 | 31 | * `PORT`: Which port to run on (`3000` by default) 32 | * `SLACK_CLIENT_ID`: Slack app id 33 | * `SLACK_CLIENT_SECRET`: Slack app secret 34 | * `SLACK_SIGNING_SECRET`: Slack app signing secret 35 | * `GITHUB_CLIENT_SECRET`: GitHub token that allows `electron-bot` to clone `electron` 36 | * `GITHUB_CLIENT_ID`: Same GitHub token as above 37 | * `SESSION_SECRET`: Secret to use for web UI session tokens 38 | * `DATABASE_URL`: In prod, use this to set a postgres connection URL 39 | 40 | Optional variables: 41 | 42 | * `DEBUG`: Used by tons of modules used by the bot, set it to `*` for verbose output 43 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "continous-auth", 3 | "description": "Safely enable 2FA on your CI published packages", 4 | "scripts": { 5 | }, 6 | "env": { 7 | "SLACK_CLIENT_ID": { 8 | "required": true 9 | }, 10 | "SLACK_CLIENT_SECRET": { 11 | "required": true 12 | }, 13 | "SLACK_SIGNING_SECRET": { 14 | "required": true 15 | }, 16 | "SESSION_SECRET": { 17 | "required": true 18 | }, 19 | "GITHUB_CLIENT_ID": { 20 | "required": true 21 | }, 22 | "GITHUB_CLIENT_SECRET": { 23 | "required": true 24 | } 25 | }, 26 | "formation": {}, 27 | "addons": [], 28 | "buildpacks": [ 29 | { 30 | "url": "heroku/nodejs" 31 | } 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | 3 | services: 4 | cfa_db: 5 | image: postgres 6 | container_name: cfa.postgres 7 | environment: 8 | - POSTGRES_DB=cfa 9 | - POSTGRES_USER=cfa-user 10 | - POSTGRES_PASSWORD=cfa-pass 11 | volumes: 12 | - ./docker/db:/var/lib/postgresql/data 13 | ports: 14 | - "5433:5432" 15 | cfa_redis: 16 | image: redis 17 | container_name: cfa.redis 18 | ports: 19 | - "6379:6379" -------------------------------------------------------------------------------- /docs/example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/continuousauth/web/1cfead31c74a1ec77d2e8494c0bc7ad11c94915a/docs/example.png -------------------------------------------------------------------------------- /docs/proof-process.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/continuousauth/web/1cfead31c74a1ec77d2e8494c0bc7ad11c94915a/docs/proof-process.png -------------------------------------------------------------------------------- /docs/security/requesters.md: -------------------------------------------------------------------------------- 1 | # Requesters 2 | 3 | On CFA a Requester is the CFA component responsible for receiving **incoming** 4 | OTP requests from third party services such as your CI providers and performing 5 | the following set of operations on them (all Requesters must at a minimum 6 | perform these steps). 7 | 8 | ## Receive the request 9 | 10 | This normally happens through an HTTP endpoint in the format: 11 | 12 | `POST /api/request/:projectId/:requesterKey` 13 | 14 | The POST body varies depending on the requester, check out the requester 15 | documentation to figure out what should be in the body of the request or 16 | just use the `@continuous-auth/client` module which handles all of this 17 | for you. 18 | 19 | ## Validate the project 20 | 21 | This is _mostly_ handled by a piece of global middleware registered for 22 | `/api/request/:projectId` that will reject requests for disabled projects, 23 | projects that have incomplete setups and projects that do not exist. 24 | 25 | The individual requester should ensure that itself is completely configured 26 | before processing any incoming messages. If any config is missing or invalid 27 | instantly stop processing the request. 28 | 29 | ## Validate the request 30 | 31 | Once a request for an OTP has been recieved the Requester on CFA should ensure 32 | that we aren't being lied to or tricked / middle-manned in some way. For a 33 | hypthetical CI service this would include: 34 | 35 | * Require the POST body include the build number 36 | * Ensure that build is running 37 | * Ensure that build is on the default branch of the repository 38 | * Ensure that build was naturally triggered (not manually triggered by a user) 39 | * Ensure (if possible) that users can't ssh into the build job while it's running 40 | or that no user has connected to the job. 41 | 42 | ## Request the token from the registered Responder 43 | 44 | Once everything has been validated the requester will finally look up the 45 | configured responder for the current project and send the OTP request. 46 | 47 | # Implementation 48 | 49 | Implementing a new requester just requires that you implement the [`Requester`](../../src/server/requesters/Requester.ts) 50 | interface and register the new requester routes in [`src/server/api/request/index.ts`](../../src/server/api/request/index.ts). Check the usage of `createRequesterRoutes`. 51 | 52 | You should review existing implementations to get a good idea of code style and the 53 | intention behind the methods in the interface. 54 | -------------------------------------------------------------------------------- /docs/security/storing-otps.md: -------------------------------------------------------------------------------- 1 | # Storing OTPs 2 | 3 | CFA permenantly stores your provided OTPs alongside the request, there is no 4 | real **technical** reason for this we could easily wipe them after the request 5 | has been obtained and at some point we might. Whiling CFA is still in preview 6 | we keep it around for debuggin purposes. To explain how this doesn't make your 7 | OTP secret less secure I'd like to refer you to the original [OTP RFC] 8 | 9 | > Assuming an adversary is able to observe numerous protocol exchanges 10 | > and collect sequences of successful authentication values. This 11 | > adversary, trying to build a function F to generate HOTP values based 12 | > on his observations, will not have a significant advantage over a 13 | > random guess. 14 | 15 | Basically, you can have as many examples of OTPs as you want and it won't make 16 | it any easier for an attacker to guess a valid OTP. 17 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | preset: 'ts-jest', 5 | testEnvironment: 'node', 6 | testPathIgnorePatterns: ['node_modules', path.resolve(__dirname, 'lib')], 7 | collectCoverageFrom: [ 8 | "src/**/*.ts", 9 | "src/**/*.tsx" 10 | ], 11 | moduleNameMapper: { 12 | '\\.scss$': '/src/__mocks__/fake-sass.js' 13 | }, 14 | setupFiles: [ 15 | './jest.setup.js' 16 | ], 17 | snapshotSerializers: [ 18 | 'enzyme-to-json/serializer' 19 | ], 20 | reporters: [ 21 | 'default', 22 | 'jest-junit' 23 | ] 24 | }; -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | const Adapter = require('enzyme-adapter-react-16'); 2 | 3 | require('enzyme').configure({ 4 | adapter: new Adapter() 5 | }); 6 | 7 | process.env.SLACK_SIGNING_SECRET = 1; 8 | process.env.SLACK_CLIENT_ID = 1; 9 | process.env.SLACK_CLIENT_SECRET = 1; 10 | process.env.SESSION_SECRET = 1; 11 | process.env.GITHUB_CLIENT_ID = 1; 12 | process.env.GITHUB_CLIENT_SECRET = 1; 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@continuous-auth/client", 3 | "version": "1.0.3", 4 | "description": "Safely enable 2FA on your CI published packages", 5 | "main": "lib/module/index.js", 6 | "author": "Samuel Attard", 7 | "license": "MIT", 8 | "scripts": { 9 | "build": "npm run build:client && npm run build:server", 10 | "build:client": "rm -rf public_out && cross-env NODE_ENV=production webpack --config webpack.production.config.js", 11 | "build:server": "rm -rf lib && tsc", 12 | "prettier:check": "prettier --check \"src/**/*.{ts,tsx}\"", 13 | "prettier:write": "prettier --write \"src/**/*.{ts,tsx}\"", 14 | "start": "npm run start:prod", 15 | "start:dev:server": "cross-env DEBUG=cfa* REDIS_URL=redis://127.0.0.1 NO_DB_SSL=true DATABASE_URL=postgresql://cfa-user:cfa-pass@localhost:5433/cfa node lib/server", 16 | "start:prod": "cross-env DEBUG=cfa* node lib/server", 17 | "test": "rm -rf lib && jest --coverage", 18 | "test:ci": "jest --ci --coverage --reporters=default --reporters=jest-junit", 19 | "watch:dev:client": "node --max-old-space-size=4096 node_modules/.bin/webpack-dev-server --progress --profile --open", 20 | "watch:dev:server": "nodemon --watch src/server --exec \"tsc && npm run start:dev:server\" -e ts", 21 | "postinstall": "patch-package" 22 | }, 23 | "devDependencies": { 24 | "@fortawesome/fontawesome-svg-core": "^6.2.0", 25 | "@fortawesome/free-brands-svg-icons": "^6.2.0", 26 | "@fortawesome/free-solid-svg-icons": "^6.2.0", 27 | "@fortawesome/react-fontawesome": "^0.2.0", 28 | "@sentry/browser": "^8.33.0", 29 | "@types/body-parser": "^1.17.1", 30 | "@types/compression": "^1.0.1", 31 | "@types/debug": "^4.1.5", 32 | "@types/dotenv-safe": "^8.1.0", 33 | "@types/enzyme": "^3.10.4", 34 | "@types/express": "^4.17.0", 35 | "@types/express-session": "^1.15.16", 36 | "@types/fs-extra": "^8.0.1", 37 | "@types/jest": "^29.0.0", 38 | "@types/joi": "^14.3.4", 39 | "@types/jwk-to-pem": "^2.0.1", 40 | "@types/libsodium-wrappers": "^0.7.14", 41 | "@types/morgan": "^1.7.37", 42 | "@types/node": "^20.0.0", 43 | "@types/passport": "^1.0.0", 44 | "@types/passport-github": "^1.1.5", 45 | "@types/pg-promise": "^5.4.3", 46 | "@types/react": "^16.0.0", 47 | "@types/react-dom": "^16.0.0", 48 | "@types/react-router-dom": "^5.1.3", 49 | "@types/supertest": "^2.0.8", 50 | "@types/uuid": "^8.3.4", 51 | "@types/validator": "^12.0.1", 52 | "@types/webpack-env": "^1.14.1", 53 | "autoprefixer": "^10.4.12", 54 | "cache-loader": "^4.1.0", 55 | "core-js": "^3.5.0", 56 | "csp-html-webpack-plugin": "^5.1.0", 57 | "css-loader": "^6.7.1", 58 | "enzyme": "^3.10.0", 59 | "enzyme-adapter-react-16": "^1.15.1", 60 | "enzyme-to-json": "^3.4.3", 61 | "evergreen-ui": "^6.0.0", 62 | "fs-extra": "^8.1.0", 63 | "html-webpack-plugin": "^5.5.0", 64 | "identity-obj-proxy": "^3.0.0", 65 | "jest": "^29.0.0", 66 | "jest-environment-jsdom": "^29.2.0", 67 | "jest-express": "^1.10.1", 68 | "jest-junit": "^10.0.0", 69 | "mini-css-extract-plugin": "^2.6.1", 70 | "nodemon": "^2.0.2", 71 | "normalize.css": "^8.0.1", 72 | "optimize-css-assets-webpack-plugin": "^6.0.1", 73 | "postcss-loader": "^7.0.1", 74 | "prettier": "^3.3.3", 75 | "react": "^16.12.0", 76 | "react-dom": "^16.12.0", 77 | "react-hooks-async": "^3.9.0", 78 | "react-hot-loader": "^4.12.18", 79 | "react-is": "^18.2.0", 80 | "react-router-dom": "^5.1.2", 81 | "sass": "^1.77.8", 82 | "sass-loader": "^15.0.0", 83 | "sqlite3": "^5.1.2", 84 | "style-loader": "^3.3.1", 85 | "supertest": "^6.3.0", 86 | "ts-jest": "^29.0.3", 87 | "ts-loader": "^9.4.1", 88 | "typescript": "~5.5.4", 89 | "webpack": "^5.74.0", 90 | "webpack-bundle-analyzer": "^4.6.1", 91 | "webpack-cli": "^4.10.0", 92 | "webpack-dev-server": "^5.2.1", 93 | "webpack-subresource-integrity": "^5.1.0" 94 | }, 95 | "dependencies": { 96 | "@octokit/auth-app": "^6.1.1", 97 | "@octokit/rest": "^19.0.5", 98 | "@sentry/node": "^5.10.2", 99 | "@slack/bolt": "^3.19.0", 100 | "compression": "^1.7.4", 101 | "connect-redis": "^7.1.0", 102 | "cross-env": "^6.0.3", 103 | "debug": "^4.1.1", 104 | "dotenv-safe": "^8.2.0", 105 | "express-history-api-fallback": "^2.2.1", 106 | "express-session": "^1.17.0", 107 | "joi": "^14.3.1", 108 | "jsonwebtoken": "^9.0.2", 109 | "jwk-to-pem": "^2.0.5", 110 | "libsodium-wrappers": "^0.7.13", 111 | "morgan": "^1.9.1", 112 | "openid-client": "^5.1.10", 113 | "passport": "^0.6.0", 114 | "passport-github": "^1.1.0", 115 | "patch-package": "^6.2.0", 116 | "pg": "^8.6.0", 117 | "redis": "^4.6.7", 118 | "reflect-metadata": "^0.1.13", 119 | "sequelize": "^6.37.3", 120 | "sequelize-typescript": "^2.1.6", 121 | "uuid": "^9.0.0" 122 | }, 123 | "heroku-run-build-script": true, 124 | "resolutions": { 125 | "@types/react": "^16.0.0" 126 | }, 127 | "browserslist": [ 128 | "last 2 versions, not dead, > 0.2%" 129 | ] 130 | } 131 | -------------------------------------------------------------------------------- /patches/evergreen-ui+6.13.3.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/evergreen-ui/index.d.ts b/node_modules/evergreen-ui/index.d.ts 2 | index ab8d9bd..ef184ea 100644 3 | --- a/node_modules/evergreen-ui/index.d.ts 4 | +++ b/node_modules/evergreen-ui/index.d.ts 5 | @@ -324,7 +324,7 @@ export type StyleProps = { 6 | export type ComponentStyle = { 7 | baseStyle?: Partial> 8 | appearances?: { [appearance: string]: Partial> } 9 | - sizes?: { [size: Size]: Partial> } 10 | + sizes?: { [size: string]: Partial> } & { [size: number]: Partial> } 11 | } 12 | 13 | export type ComponentStyles = { 14 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | const autoprefixer = require('autoprefixer'); 2 | 3 | module.exports = { 4 | plugins: [ 5 | autoprefixer(), 6 | ], 7 | }; 8 | -------------------------------------------------------------------------------- /src/__mocks__/db.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs-extra'; 2 | import * as os from 'os'; 3 | import * as path from 'path'; 4 | import { Sequelize } from 'sequelize-typescript'; 5 | 6 | import { __overrideSequelizeInstanceForTesting } from '../server/db/models'; 7 | 8 | /** 9 | * Hooks into jest beforeEach and afterEach to set up and 10 | * teardown unique database instances usiong sqlite 11 | */ 12 | export const temporaryDatabaseForTestScope = () => { 13 | let dir: string; 14 | 15 | beforeEach(async () => { 16 | dir = await fs.promises.mkdtemp(path.resolve(os.tmpdir(), 'cfa-test-db-')); 17 | 18 | await __overrideSequelizeInstanceForTesting( 19 | new Sequelize({ 20 | dialect: 'sqlite', 21 | storage: path.resolve(dir, 'test.db'), 22 | logging: false, 23 | }), 24 | ); 25 | }); 26 | 27 | afterEach(async () => { 28 | await fs.remove(dir); 29 | }); 30 | }; 31 | -------------------------------------------------------------------------------- /src/__mocks__/fake-sass.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | default: require('identity-obj-proxy'), 3 | }; 4 | -------------------------------------------------------------------------------- /src/__mocks__/project.ts: -------------------------------------------------------------------------------- 1 | import { FullProject } from '../common/types'; 2 | 3 | export const mockProject = (): FullProject => ({ 4 | secret: 'my_secret', 5 | enabled: true, 6 | requester_circleCI: null, 7 | requester_gitHub: null, 8 | responder_slack: null, 9 | id: '123', 10 | repoName: 'my-repo', 11 | repoOwner: 'my-owner', 12 | defaultBranch: 'main', 13 | }); 14 | -------------------------------------------------------------------------------- /src/client/__tests__/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { cx, defaultFetchInit, defaultBodyReader, projectHasAnyConfig } from '../utils'; 2 | 3 | describe('cx', () => { 4 | it('should join provided classes', () => { 5 | expect(cx('class1', 'class2')).toMatchInlineSnapshot(`"class1 class2"`); 6 | }); 7 | 8 | it('should ignore null and undefined classes', () => { 9 | expect(cx('class1', null, 'class3', undefined, 'class5')).toMatchInlineSnapshot( 10 | `"class1 class3 class5"`, 11 | ); 12 | }); 13 | }); 14 | 15 | describe('projectHasAnyConfig', () => { 16 | it('should return true if the project has circleci configured', () => { 17 | expect(projectHasAnyConfig({ requester_circleCI: true } as any)).toBe(true); 18 | }); 19 | 20 | it('should return true if the project has github configured', () => { 21 | expect(projectHasAnyConfig({ requester_gitHub: true } as any)).toBe(true); 22 | }); 23 | 24 | it('should return true if the project has slack configured', () => { 25 | expect(projectHasAnyConfig({ responder_slack: true } as any)).toBe(true); 26 | }); 27 | 28 | it('should return false if the project has nothing configured', () => { 29 | expect(projectHasAnyConfig({ random_key: 123 } as any)).toBe(false); 30 | }); 31 | }); 32 | 33 | describe('memoized react hook inputs', () => { 34 | describe('defaultFetchInit', () => { 35 | it('should have no keys', () => { 36 | expect(Object.keys(defaultFetchInit)).toHaveLength(0); 37 | }); 38 | }); 39 | 40 | describe('defaultBodyReader', () => { 41 | it('should call json on the response', async () => { 42 | expect( 43 | await defaultBodyReader({ 44 | json() { 45 | return Promise.resolve(123); 46 | }, 47 | }), 48 | ).toBe(123); 49 | }); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /src/client/ambient.d.ts: -------------------------------------------------------------------------------- 1 | // declare module 'evergreen-ui'; 2 | declare module '*.scss' { 3 | const foo: Record; 4 | export default foo; 5 | } 6 | -------------------------------------------------------------------------------- /src/client/base.css: -------------------------------------------------------------------------------- 1 | * { 2 | font-family: -apple-system,system-ui,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif; 3 | } 4 | 5 | body { 6 | background: #fbfbfb; 7 | } -------------------------------------------------------------------------------- /src/client/components/AddProjectDialog.scss: -------------------------------------------------------------------------------- 1 | .label { 2 | font-size: 12px; 3 | margin-right: 8px; 4 | display: inline-block; 5 | width: 80px; 6 | margin-top: 20px; 7 | } -------------------------------------------------------------------------------- /src/client/components/AddProjectDialog.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Button, Dialog, Pane, Paragraph, SelectMenu, toaster } from 'evergreen-ui'; 3 | import { SimpleRepo, SimpleProject } from '../../common/types'; 4 | 5 | import styles from './AddProjectDialog.scss'; 6 | import { useAsyncTaskFetch } from 'react-hooks-async'; 7 | import { defaultBodyReader } from '../utils'; 8 | 9 | export interface Props { 10 | isOpen: boolean; 11 | onClose: (newProject?: SimpleProject) => void; 12 | repos: SimpleRepo[]; 13 | } 14 | 15 | export function AddProjectDialog({ isOpen, onClose, repos }: Props) { 16 | const [selectedOwner, setSelectedOwner] = React.useState(undefined); 17 | const [selectedRepo, setSelectedRepo] = React.useState(undefined); 18 | const owners = Array.from(new Set(repos.map((r) => r.repoOwner))).sort(); 19 | const possibleRepos = selectedOwner ? repos.filter((r) => r.repoOwner === selectedOwner) : []; 20 | const selectedRepoObject = selectedRepo ? repos.find((r) => r.id === selectedRepo) : null; 21 | const selectedRepoName = selectedRepoObject ? selectedRepoObject.repoName : null; 22 | 23 | const createProjectOptions = React.useMemo( 24 | () => ({ 25 | method: 'POST', 26 | headers: new Headers({ 27 | 'Content-Type': 'application/json', 28 | }), 29 | body: JSON.stringify({ 30 | repoId: selectedRepoObject ? selectedRepoObject.id : null, 31 | }), 32 | }), 33 | [selectedRepo], 34 | ); 35 | const createProjectTask = useAsyncTaskFetch( 36 | '/api/project', 37 | createProjectOptions, 38 | defaultBodyReader, 39 | ); 40 | 41 | const creatingProject = createProjectTask.started && createProjectTask.pending; 42 | React.useMemo(() => { 43 | if (createProjectTask.error) { 44 | toaster.danger('Failed to add project, please choose a different repository and try again.'); 45 | } 46 | }, [createProjectTask.error]); 47 | React.useEffect(() => { 48 | setSelectedRepo(undefined); 49 | setSelectedOwner(undefined); 50 | if (!createProjectTask.result) return; 51 | toaster.success( 52 | 'Successfully added project, select it on the Dashboard to continue setting it up.', 53 | ); 54 | onClose(createProjectTask.result); 55 | }, [createProjectTask.result]); 56 | 57 | return ( 58 | createProjectTask.start()} 66 | shouldCloseOnOverlayClick={!creatingProject} 67 | shouldCloseOnEscapePress={!creatingProject} 68 | > 69 | Choose which repository you want to add a project for: 70 | 71 | Owner: 72 | ({ 75 | label: owner, 76 | value: owner, 77 | }))} 78 | selected={selectedOwner} 79 | closeOnSelect 80 | onSelect={(item) => { 81 | setSelectedRepo(undefined); 82 | setSelectedOwner(item.value as string); 83 | }} 84 | > 85 | 86 | 87 | 88 | 89 | Repository: 90 | ({ 93 | label: repo.repoName, 94 | value: repo.id, 95 | }))} 96 | selected={selectedRepo} 97 | closeOnSelect 98 | onSelect={(item) => setSelectedRepo(item.value as string)} 99 | > 100 | 103 | 104 | 105 | 106 | Please note that you can only add repositories to CFA that you have "admin" level access to. 107 | If you don't have that level of access you should reach out to someone on your team who does 108 | and ask them to do this bit. 109 | 110 | 111 | ); 112 | } 113 | -------------------------------------------------------------------------------- /src/client/components/App.scss: -------------------------------------------------------------------------------- 1 | .login { 2 | border: 1px solid; 3 | background-color: #444; 4 | border-color: rgba(0,0,0,0.2); 5 | font-weight: normal; 6 | line-height: 1.42857143; 7 | user-select: none; 8 | padding: 8px 12px; 9 | color: #fff; 10 | text-decoration: none !important; 11 | font-size: 14px; 12 | 13 | &:hover { 14 | background-color: #2b2b2b; 15 | border-color: rgba(0,0,0,0.2); 16 | } 17 | } -------------------------------------------------------------------------------- /src/client/components/App.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { hot } from 'react-hot-loader/root'; 3 | import { Alert, Pane, Spinner } from 'evergreen-ui'; 4 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 5 | import { faGithub } from '@fortawesome/free-brands-svg-icons/faGithub'; 6 | import { useFetch } from 'react-hooks-async'; 7 | 8 | import { UserState } from '../state/user'; 9 | 10 | import styles from './App.scss'; 11 | import { User } from '../../common/types'; 12 | import { MainAppRouter } from './MainAppRouter'; 13 | import { defaultFetchInit, defaultBodyReader } from '../utils'; 14 | 15 | function AppInner() { 16 | const meFetch = useFetch('/api/auth/me', defaultFetchInit, defaultBodyReader); 17 | 18 | if (meFetch.pending) 19 | return ( 20 | 21 | 22 | 23 | ); 24 | 25 | if (meFetch.error) 26 | return ( 27 | 28 | 29 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | Log in with GitHub 40 | 41 | 42 | 43 | ); 44 | 45 | return ( 46 | 47 | 48 | 49 | ); 50 | } 51 | 52 | let HotApp = AppInner; 53 | if (process.env.NODE_ENV !== 'production') { 54 | HotApp = hot(AppInner); 55 | } 56 | 57 | export const App = HotApp; 58 | -------------------------------------------------------------------------------- /src/client/components/DangerZone.scss: -------------------------------------------------------------------------------- 1 | .group { 2 | margin: 8px 0; 3 | 4 | >* { 5 | margin-left: 8px !important; 6 | 7 | &:first-child { 8 | margin-left: 0; 9 | } 10 | } 11 | } -------------------------------------------------------------------------------- /src/client/components/DangerZone.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Alert, Button, Dialog, Pane, toaster } from 'evergreen-ui'; 3 | import { FullProject } from '../../common/types'; 4 | 5 | import styles from './DangerZone.scss'; 6 | import { useAsyncTaskFetch } from 'react-hooks-async'; 7 | import { defaultBodyReader } from '../utils'; 8 | 9 | export interface Props { 10 | project: FullProject; 11 | setProject: (project: FullProject) => void; 12 | } 13 | 14 | enum ConfirmState { 15 | CLOSED, 16 | OPEN, 17 | } 18 | 19 | export function DangerZone({ project, setProject }: Props) { 20 | const [confirmState, setConfirmState] = React.useState<{ 21 | state: ConfirmState; 22 | pendingAction: (() => void) | null; 23 | }>({ 24 | state: ConfirmState.CLOSED, 25 | pendingAction: null, 26 | }); 27 | const regenerateSecretOptions = React.useMemo( 28 | () => ({ 29 | method: 'PATCH', 30 | }), 31 | [project.id, project.secret], 32 | ); 33 | const regenerateSecretTask = useAsyncTaskFetch( 34 | `/api/project/${project.id}/secret`, 35 | regenerateSecretOptions, 36 | defaultBodyReader, 37 | ); 38 | 39 | const disableOptions = React.useMemo( 40 | () => ({ 41 | method: 'DELETE', 42 | }), 43 | [project.id], 44 | ); 45 | const disableTask = useAsyncTaskFetch( 46 | `/api/project/${project.id}`, 47 | disableOptions, 48 | defaultBodyReader, 49 | ); 50 | 51 | const regeneratePending = regenerateSecretTask.started && regenerateSecretTask.pending; 52 | const disablePending = disableTask.started && disableTask.pending; 53 | 54 | React.useEffect(() => { 55 | if (regenerateSecretTask.error) { 56 | toaster.danger('Failed to regenerate project secret, please try again later.'); 57 | } 58 | }, [regenerateSecretTask.error]); 59 | React.useEffect(() => { 60 | if (regenerateSecretTask.result) { 61 | toaster.success( 62 | 'Project secret has been regenerated successfully, please update your CI configurations with the new secret.', 63 | ); 64 | setProject(regenerateSecretTask.result); 65 | } 66 | }, [regenerateSecretTask.result]); 67 | 68 | React.useEffect(() => { 69 | if (disableTask.error) { 70 | toaster.danger('Failed to disable project, please try again later.'); 71 | } 72 | }, [disableTask.error]); 73 | React.useEffect(() => { 74 | if (disableTask.result) { 75 | toaster.warning( 76 | 'Project has been disabled. CFA will no longer process incoming requests for this project.', 77 | ); 78 | setProject(disableTask.result); 79 | } 80 | }, [disableTask.result]); 81 | 82 | return ( 83 | 84 | setConfirmState({ state: ConfirmState.CLOSED, pendingAction: null })} 89 | confirmLabel="Yes" 90 | onConfirm={() => { 91 | if (confirmState.pendingAction) confirmState.pendingAction(); 92 | setConfirmState({ state: ConfirmState.CLOSED, pendingAction: null }); 93 | }} 94 | onCancel={() => setConfirmState({ state: ConfirmState.CLOSED, pendingAction: null })} 95 | shouldCloseOnOverlayClick={false} 96 | shouldCloseOnEscapePress={false} 97 | > 98 | This action is not easily reversable, please double check this is actually what you want to 99 | do. 100 | 101 | 102 | Watch out! The options below are scary and do bad things, please double check before you go 103 | clicking them. 104 | 105 | 106 | 117 | 128 | 129 | 130 | ); 131 | } 132 | -------------------------------------------------------------------------------- /src/client/components/Dashboard.scss: -------------------------------------------------------------------------------- 1 | .loadingText { 2 | color: #1F4160; 3 | } 4 | 5 | .rocket { 6 | height: 196px; 7 | } 8 | 9 | .rocketTextHeader { 10 | margin-top: 32px; 11 | font-size: 22px; 12 | color: #1F4160; 13 | } 14 | 15 | .rocketText { 16 | margin-top: 0; 17 | font-size: 16px; 18 | color: #1F4160; 19 | } 20 | 21 | .projectList { 22 | margin: 0 auto; 23 | padding-left: 22px; 24 | padding-right: 22px; 25 | width: 100%; 26 | max-width: 700px; 27 | 28 | .projectCard { 29 | box-shadow: 0 0 0 2px rgba(0,0,0,.03); 30 | margin-bottom: 20px; 31 | background: #fff; 32 | 33 | .projectHeader { 34 | align-items: center; 35 | border-bottom: 2px solid #f3f3f3; 36 | display: flex; 37 | margin-bottom: 12px; 38 | padding: 8px 11px; 39 | font-weight: 500; 40 | color: #606060; 41 | font-size: 14px; 42 | } 43 | 44 | .configRow { 45 | align-items: center; 46 | display: flex; 47 | flex-direction: row; 48 | font-size: 13px; 49 | padding: 0 11px 12px; 50 | 51 | .configIcon { 52 | height: 16px; 53 | margin: 0 8px; 54 | } 55 | } 56 | 57 | .errorRow { 58 | border-bottom: 2px solid #f3f3f3; 59 | margin-bottom: 12px; 60 | } 61 | 62 | .projectFooter { 63 | border-top: 2px solid #f3f3f3; 64 | padding: 5px 0; 65 | text-align: center; 66 | 67 | a { 68 | background: transparent; 69 | border: 0; 70 | border-radius: 3px; 71 | color: #24292e; 72 | cursor: pointer; 73 | font-size: 13px; 74 | font-weight: 500; 75 | padding: 5px 12px; 76 | display: inline-block; 77 | text-decoration: none; 78 | 79 | &:hover { 80 | background-color: rgba(67, 90, 111, 0.06); 81 | } 82 | } 83 | } 84 | } 85 | } -------------------------------------------------------------------------------- /src/client/components/Dashboard.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useFetch } from 'react-hooks-async'; 3 | import { 4 | Alert, 5 | Button, 6 | CircleArrowLeftIcon, 7 | CircleArrowRightIcon, 8 | WarningSignIcon, 9 | Pane, 10 | Paragraph, 11 | Position, 12 | Spinner, 13 | Tooltip, 14 | } from 'evergreen-ui'; 15 | 16 | import styles from './Dashboard.scss'; 17 | import { ReposResponse, SimpleProject, projectIsMissingConfig } from '../../common/types'; 18 | import { Rocket } from './icons/Rocket'; 19 | import { AddProjectDialog } from './AddProjectDialog'; 20 | import { CircleCILogo } from './icons/CircleCI'; 21 | import { SlackLogo } from './icons/Slack'; 22 | import { Link } from 'react-router-dom'; 23 | import { cx, projectHasAnyConfig, defaultFetchInit, defaultBodyReader } from '../utils'; 24 | import { GitHubLogo } from './icons/GitHub'; 25 | 26 | export function Dashboard() { 27 | const reposFetch = useFetch('/api/repos', defaultFetchInit, defaultBodyReader); 28 | const [isAddProjectOpen, setAddProjectOpen] = React.useState(false); 29 | 30 | if (reposFetch.pending) { 31 | return ( 32 | 39 | 40 |

Loading your projects...

41 |
42 | ); 43 | } 44 | 45 | if (reposFetch.error || !reposFetch.result) { 46 | return ( 47 | 48 | 52 | 53 | ); 54 | } 55 | 56 | const fetchedRepos = reposFetch.result; 57 | 58 | const notConfigured = fetchedRepos.all.filter( 59 | (r) => !fetchedRepos.configured.find((configured) => `${configured.id}` === `${r.id}`), 60 | ); 61 | 62 | const addProject = ( 63 | <> 64 | { 67 | if (newProject && newProject.id && newProject.repoName && newProject.repoOwner) { 68 | fetchedRepos.configured.push(newProject); 69 | } 70 | setAddProjectOpen(false); 71 | }} 72 | repos={notConfigured} 73 | /> 74 | 75 | 76 | 77 | 78 | ); 79 | 80 | const repos = reposFetch.result; 81 | if (repos.configured.length === 0) { 82 | return ( 83 | <> 84 | {addProject} 85 | 92 | 93 |

94 | Looks like you haven't set up any projects yet! 95 |

96 |

97 | Getting your first project set up is as simple as hitting that "Add Project" button up 98 | there. 99 |

100 |
101 | 102 | ); 103 | } 104 | 105 | return ( 106 | <> 107 | {addProject} 108 | 109 | {repos.configured 110 | .sort((a, b) => { 111 | const ownerCompare = a.repoOwner.localeCompare(b.repoOwner); 112 | if (ownerCompare === 0) return a.repoName.localeCompare(b.repoName); 113 | return ownerCompare; 114 | }) 115 | .map((repo) => ( 116 | 117 | 118 | 119 | 120 | {repo.repoOwner}/{repo.repoName} 121 | 122 | 123 | 124 | {projectIsMissingConfig(repo) ? ( 125 | 131 | 132 | 133 | 134 | 135 | 136 | This project has not been completely configured. Both a Requester and a 137 | Responder must be completely configured. 138 | 139 | 140 | ) : null} 141 | {repo.requester_circleCI ? ( 142 | 143 | 144 | 145 | 146 | 147 | CircleCI 148 | 149 | ) : null} 150 | {repo.requester_gitHub ? ( 151 | 152 | 153 | 154 | 155 | 156 | GitHub Actions 157 | 158 | ) : null} 159 | {repo.responder_slack ? ( 160 | 161 | 162 | 163 | 164 | 165 | 166 | Slack - {repo.responder_slack.team} - #{repo.responder_slack.channel} 167 | 168 | 169 | ) : null} 170 | 171 | Update Configuration 172 | 173 | 174 | ))} 175 | 176 | 177 | ); 178 | } 179 | -------------------------------------------------------------------------------- /src/client/components/MainAppRouter.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { BrowserRouter as Router, Route, Switch, RouteComponentProps } from 'react-router-dom'; 3 | 4 | import { MenuHeader } from './MenuHeader'; 5 | import { Dashboard } from './Dashboard'; 6 | import { ProjectConfig } from './ProjectConfig'; 7 | import { SlackOAuthResult } from './SlackOAuthResult'; 8 | 9 | function NotFoundHandler(props: RouteComponentProps) { 10 | React.useEffect(() => { 11 | props.history.replace('/'); 12 | }, [props.location.pathname]); 13 | 14 | return

Route Not Found

; 15 | } 16 | 17 | export function MainAppRouter() { 18 | return ( 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/client/components/MenuHeader.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | align-items: center; 3 | box-shadow: rgba(67, 90, 111, 0.47) 0px 0px 1px, rgba(67, 90, 111, 0.3) 0px 2px 4px -2px; 4 | height: 56px; 5 | padding: 0px 16px; 6 | background: rgb(255, 255, 255); 7 | display: flex; 8 | 9 | .logo { 10 | height: 36px; 11 | margin-right: 8px; 12 | } 13 | 14 | .productName { 15 | margin-right: 16px; 16 | font-size: 14px; 17 | font-weight: bold; 18 | color: #1F4160; 19 | user-select: none; 20 | } 21 | 22 | .item { 23 | align-items: center; 24 | color: rgb(66, 90, 112); 25 | display: inline-flex; 26 | font-size: 12px; 27 | height: 16px; 28 | letter-spacing: 0.4px; 29 | line-height: 16px; 30 | margin-right: 8px; 31 | text-transform: uppercase; 32 | border-radius: 3px; 33 | padding: 10px 12px; 34 | cursor: pointer; 35 | text-decoration: none !important; 36 | 37 | &:hover, 38 | &.active { 39 | background-color: rgba(67, 90, 111, 0.06); 40 | } 41 | 42 | &.warning { 43 | background-color: #FBE6A2; 44 | border: 1px solid #F7D154; 45 | cursor: default; 46 | user-select: none; 47 | } 48 | } 49 | 50 | .right { 51 | flex: 1; 52 | display: flex; 53 | align-items: center; 54 | height: 100%; 55 | justify-content: flex-end; 56 | 57 | >span { 58 | margin-left: 8px; 59 | font-size: 14px; 60 | } 61 | } 62 | } -------------------------------------------------------------------------------- /src/client/components/MenuHeader.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { Avatar, WarningSignIcon, Pane } from 'evergreen-ui'; 4 | import { UserState } from '../state/user'; 5 | 6 | import styles from './MenuHeader.scss'; 7 | import { CFALogo } from './icons/Logo'; 8 | import { cx } from '../utils'; 9 | import { Link, withRouter } from 'react-router-dom'; 10 | 11 | export const isPathActive = (path: string, exact = true) => { 12 | if (exact) return location.pathname === path; 13 | return location.pathname.indexOf(path) === 0; 14 | }; 15 | 16 | export function MenuHeaderInner() { 17 | const user = React.useContext(UserState); 18 | 19 | return ( 20 | 21 | 22 | CFA 23 | 24 | Dashboard 25 | 26 | 27 | Documentation 28 | 29 |
30 | 31 | CFA is open-source and does not have formal support 32 |
33 | 34 | 39 | {user ? user.username : '?'} 40 | 41 | 42 |
43 | ); 44 | } 45 | 46 | export const MenuHeader = withRouter(MenuHeaderInner); 47 | -------------------------------------------------------------------------------- /src/client/components/ProjectConfig.scss: -------------------------------------------------------------------------------- 1 | .loadingText { 2 | color: #1F4160; 3 | } 4 | 5 | .wrapper { 6 | margin: 24px auto; 7 | padding-left: 22px; 8 | padding-right: 22px; 9 | width: 100%; 10 | max-width: 800px; 11 | 12 | .configContainer { 13 | box-shadow: 0 0 0 2px rgba(0,0,0,.03); 14 | margin-bottom: 20px; 15 | background: #fff; 16 | 17 | .header { 18 | align-items: center; 19 | border-bottom: 2px solid #f3f3f3; 20 | display: flex; 21 | margin-bottom: 12px; 22 | padding: 8px 11px; 23 | font-weight: 500; 24 | color: #606060; 25 | font-size: 14px; 26 | 27 | >:first-child { 28 | flex: 1; 29 | } 30 | } 31 | 32 | .inner { 33 | display: flex; 34 | flex-direction: column; 35 | font-size: 13px; 36 | padding: 0 11px 12px; 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/client/components/ProjectConfig.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useFetch } from 'react-hooks-async'; 3 | import { RouteComponentProps } from 'react-router'; 4 | import { RefreshIcon, Pane, Spinner, Tooltip, toaster } from 'evergreen-ui'; 5 | 6 | import { FullProject } from '../../common/types'; 7 | 8 | import styles from './ProjectConfig.scss'; 9 | import { ProjectSecret } from './ProjectSecret'; 10 | import { DangerZone } from './DangerZone'; 11 | import { ResponderConfig } from './ResponderConfig'; 12 | import { RequesterConfig } from './RequesterConfig'; 13 | import { defaultFetchInit, defaultBodyReader } from '../utils'; 14 | 15 | export function ProjectConfig(props: RouteComponentProps<{ projectId: string }>) { 16 | const [refreshId, setRefreshId] = React.useState(1); 17 | const projectFetch = useFetch( 18 | `/api/project/${props.match.params.projectId}?refresh=${refreshId}`, 19 | defaultFetchInit, 20 | defaultBodyReader, 21 | ); 22 | const [project, setProject] = React.useState(null); 23 | 24 | React.useEffect(() => { 25 | if (projectFetch.error) { 26 | toaster.danger( 27 | 'Failed to load project configuration, either that project does not exist or you do not have permission to view it.', 28 | ); 29 | props.history.replace('/'); 30 | } 31 | }, [projectFetch.error]); 32 | 33 | React.useEffect(() => { 34 | if (projectFetch.result) { 35 | setProject(projectFetch.result); 36 | } 37 | }, [projectFetch.result]); 38 | React.useEffect(() => { 39 | if (project && !project.enabled) { 40 | props.history.push('/'); 41 | } 42 | }, [project, project ? project.enabled : null]); 43 | 44 | if (projectFetch.pending || projectFetch.error || !project || !project.enabled) { 45 | return ( 46 | 53 | 54 |

Loading project configuration...

55 |
56 | ); 57 | } 58 | 59 | return ( 60 | 61 | 62 | 63 | 64 | Configuration: {project.repoOwner}/{project.repoName} 65 | 66 | 67 | setRefreshId(refreshId + 1)} 69 | style={{ cursor: 'pointer' }} 70 | /> 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | ); 82 | } 83 | -------------------------------------------------------------------------------- /src/client/components/ProjectSecret.scss: -------------------------------------------------------------------------------- 1 | .inputContainer { 2 | display: flex; 3 | margin-top: 8px; 4 | margin-bottom: 16px; 5 | 6 | .input { 7 | flex: 1; 8 | margin-right: 16px; 9 | } 10 | 11 | .button { 12 | user-select: none; 13 | } 14 | } -------------------------------------------------------------------------------- /src/client/components/ProjectSecret.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Button, Heading, Pane, TextInput } from 'evergreen-ui'; 3 | 4 | import { FullProject } from '../../common/types'; 5 | 6 | import styles from './ProjectSecret.scss'; 7 | 8 | export interface Props { 9 | project: FullProject; 10 | } 11 | 12 | export function ProjectSecret({ project }: Props) { 13 | const [isVisible, setVisisble] = React.useState(false); 14 | 15 | return ( 16 | 17 | Project Secret 18 | 19 | 24 | 27 | 28 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /src/client/components/ReqResConfig.scss: -------------------------------------------------------------------------------- 1 | .configBox { 2 | border: 1px solid #e4e8ea; 3 | padding: 8px; 4 | } 5 | 6 | .tabIcon { 7 | height: 14px; 8 | position: absolute; 9 | left: 8px; 10 | } 11 | -------------------------------------------------------------------------------- /src/client/components/RequesterConfig.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Alert, Heading, Pane, Paragraph, Tab, Tablist } from 'evergreen-ui'; 3 | 4 | import { FullProject } from '../../common/types'; 5 | 6 | import styles from './ReqResConfig.scss'; 7 | import { CircleCILogo } from './icons/CircleCI'; 8 | import { CircleCIRequesterConfig } from './configurators/CircleCIRequesterConfig'; 9 | import { GitHubLogo } from './icons/GitHub'; 10 | import { GitHubActionsRequesterConfig } from './configurators/GitHubActionsRequesterConfig'; 11 | export interface Props { 12 | project: FullProject; 13 | setProject: (newProject: FullProject) => void; 14 | } 15 | 16 | enum RequesterTab { 17 | NOTHING_YET, 18 | CIRCLE_CI, 19 | GITHUB_ACTIONS, 20 | } 21 | 22 | const defaultTabForProject = (project: FullProject) => { 23 | if (project.requester_circleCI) return RequesterTab.CIRCLE_CI; 24 | if (project.requester_gitHub) return RequesterTab.GITHUB_ACTIONS; 25 | return RequesterTab.NOTHING_YET; 26 | }; 27 | 28 | export function RequesterConfig({ project, setProject }: Props) { 29 | const [activeTab, setActiveTab] = React.useState(RequesterTab.NOTHING_YET); 30 | React.useEffect(() => { 31 | setActiveTab(defaultTabForProject(project)); 32 | }, [defaultTabForProject(project)]); 33 | 34 | const [showRequesterHelp, setShowRequesterHelp] = React.useState( 35 | defaultTabForProject(project) === RequesterTab.NOTHING_YET, 36 | ); 37 | React.useEffect(() => { 38 | if (defaultTabForProject(project) === RequesterTab.NOTHING_YET && !showRequesterHelp) { 39 | setShowRequesterHelp(true); 40 | } 41 | }, [project]); 42 | 43 | return ( 44 | 45 | Requester 46 | 47 | {showRequesterHelp ? ( 48 | setShowRequesterHelp(false)} 53 | > 54 | A Requester is how your automation asks CFA for a 2FA token. This is normally your CI 55 | provider, E.g. Circle CI. 56 | 57 | ) : null} 58 | 59 | 60 | 61 | setActiveTab(RequesterTab.CIRCLE_CI)} 63 | isSelected={activeTab === RequesterTab.CIRCLE_CI} 64 | style={{ paddingLeft: 28, position: 'relative' }} 65 | > 66 | Circle CI 67 | 68 | setActiveTab(RequesterTab.GITHUB_ACTIONS)} 70 | isSelected={activeTab === RequesterTab.GITHUB_ACTIONS} 71 | style={{ paddingLeft: 28, position: 'relative' }} 72 | > 73 | GitHub Actions 74 | 75 | More Coming Soon... 76 | 77 | 78 | 79 | {activeTab === RequesterTab.NOTHING_YET ? ( 80 | No Requester has been configured, choose one to get started! 81 | ) : activeTab === RequesterTab.CIRCLE_CI ? ( 82 | 83 | ) : activeTab === RequesterTab.GITHUB_ACTIONS ? ( 84 | 85 | ) : null} 86 | 87 | 88 | ); 89 | } 90 | -------------------------------------------------------------------------------- /src/client/components/ResponderConfig.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Alert, Heading, Pane, Paragraph, Tab, Tablist } from 'evergreen-ui'; 3 | 4 | import { FullProject } from '../../common/types'; 5 | import { SlackResponderConfig } from './configurators/SlackResponderConfig'; 6 | 7 | import styles from './ReqResConfig.scss'; 8 | import { SlackLogo } from './icons/Slack'; 9 | 10 | export interface Props { 11 | project: FullProject; 12 | setProject: (newProject: FullProject) => void; 13 | } 14 | 15 | enum ResponderTab { 16 | NOTHING_YET, 17 | SLACK, 18 | } 19 | 20 | const defaultTabForProject = (project: FullProject) => { 21 | if (project.responder_slack) return ResponderTab.SLACK; 22 | return ResponderTab.NOTHING_YET; 23 | }; 24 | 25 | export function ResponderConfig({ project, setProject }: Props) { 26 | const [activeTab, setActiveTab] = React.useState(ResponderTab.NOTHING_YET); 27 | React.useEffect(() => { 28 | setActiveTab(defaultTabForProject(project)); 29 | }, [defaultTabForProject(project)]); 30 | 31 | const [showHelp, setShowHelp] = React.useState(false); 32 | React.useEffect(() => { 33 | if (defaultTabForProject(project) === ResponderTab.NOTHING_YET) { 34 | setShowHelp(true); 35 | } 36 | }, [defaultTabForProject(project)]); 37 | 38 | return ( 39 | 40 | Responder 41 | 42 | {showHelp ? ( 43 | setShowHelp(false)} 48 | > 49 | A Responder is how CFA asks a human for a 2FA token. E.g. If you choose Slack it will 50 | send a message to a channel asking you to enter a 2FA code into a Slack dialog. 51 | 52 | ) : null} 53 | 54 | 55 | 56 | setActiveTab(ResponderTab.SLACK)} 58 | isSelected={activeTab === ResponderTab.SLACK} 59 | style={{ paddingLeft: 28, position: 'relative' }} 60 | > 61 | Slack 62 | 63 | More Coming Soon... 64 | 65 | 66 | 67 | {activeTab === ResponderTab.NOTHING_YET ? ( 68 | No Responder has been configured, choose one to get started! 69 | ) : activeTab === ResponderTab.SLACK ? ( 70 | 71 | ) : null} 72 | 73 | 74 | ); 75 | } 76 | -------------------------------------------------------------------------------- /src/client/components/SlackOAuthResult.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | justify-content: center; 4 | align-items: center; 5 | margin-top: 32px; 6 | height: calc(100vh - 200px); 7 | 8 | .block { 9 | min-height: 240px; 10 | display: flex; 11 | align-items: center; 12 | box-shadow: 0 0 0 2px rgba(0, 0, 0, .1); 13 | margin-bottom: 20px; 14 | background: #fff; 15 | padding: 16px 48px; 16 | max-width: 50%; 17 | 18 | .logo { 19 | height: 70%; 20 | margin-right: 48px; 21 | width: 30%; 22 | } 23 | 24 | @media (max-width: 1340px) { 25 | max-width: 70%; 26 | } 27 | 28 | @media (max-width: 930px) { 29 | max-width: 90%; 30 | } 31 | 32 | @media (max-width: 720px) { 33 | flex-direction: column; 34 | 35 | .logo { 36 | height: auto; 37 | width: 20%; 38 | margin-bottom: 16px; 39 | margin-right: 0; 40 | } 41 | } 42 | } 43 | } -------------------------------------------------------------------------------- /src/client/components/SlackOAuthResult.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Alert, Heading, Pane, Paragraph } from 'evergreen-ui'; 3 | 4 | import { SlackLogo } from './icons/Slack'; 5 | 6 | import styles from './SlackOAuthResult.scss'; 7 | 8 | export function SlackOAuthResult() { 9 | const query = new URLSearchParams(window.location.search); 10 | const error = query.get('error'); 11 | 12 | return ( 13 |
14 | 15 | 16 | {error ? ( 17 | 18 | Failed to install the CFA Slack App 19 | 20 | It looks like for some reason we failed to either install or confirm the installation 21 | of the CFA slack app. This can happen for a number of reasons but normally it's 22 | because the install was cancelled. If this is unexpected you should just try again. 23 | The error CFA experienced is included below, it may be helpful to you. 24 | 25 | 26 | {error} 27 | 28 | 29 | ) : ( 30 | 31 | Successfully installed the CFA Slack App 32 | 33 | Now that the app has been installed you can close this tab and continue configuring 34 | your CFA project by following the steps on the previous page. 35 | 36 | 37 | )} 38 | 39 |
40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /src/client/components/__tests__/MainAppRouter.spec.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import { MainAppRouter } from '../MainAppRouter'; 4 | 5 | describe('', () => { 6 | it('Should render a router', () => { 7 | const wrapper = shallow(); 8 | expect(wrapper).toMatchSnapshot(); 9 | expect(wrapper.find('BrowserRouter')).toHaveLength(1); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/client/components/__tests__/MenuHeader.spec.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | import * as React from 'react'; 6 | import { shallow, mount } from 'enzyme'; 7 | 8 | import { MenuHeaderInner, isPathActive } from '../MenuHeader'; 9 | import { UserState } from '../../state/user'; 10 | import { BrowserRouter } from 'react-router-dom'; 11 | 12 | const fakeLocation = (path: string) => { 13 | delete (window as any).location; 14 | window.location = { 15 | pathname: path, 16 | } as any; 17 | }; 18 | 19 | describe('', () => { 20 | it('Should render a logo', () => { 21 | const wrapper = shallow(); 22 | expect(wrapper).toMatchSnapshot(); 23 | expect(wrapper.find('CFALogo')).toHaveLength(1); 24 | }); 25 | 26 | it('should handle the user not being signed in', () => { 27 | const wrapper = shallow(); 28 | const avatar = wrapper.find('Memo(ForwardRef(Avatar))'); 29 | expect(avatar).toHaveLength(1); 30 | expect(avatar.prop('name')).toBe('?'); 31 | }); 32 | 33 | it('should show the users name when they are signed in', () => { 34 | function WithUser() { 35 | return ( 36 | 37 | 38 | 39 | 40 | 41 | ); 42 | } 43 | const mounted = mount(); 44 | mounted.setProps({}); 45 | expect(mounted.find('ForwardRef(Avatar)').prop('name')).toBe('My User'); 46 | }); 47 | 48 | it('should highlight the tab for the page that is currently active', () => { 49 | fakeLocation('/'); 50 | const wrapper = shallow(); 51 | const dashbaordLink = wrapper.find('Link').at(0); 52 | expect(dashbaordLink.prop('className')).toMatchInlineSnapshot(`"item active"`); 53 | }); 54 | 55 | it('should not highlight the tab for pages that are not currently active', () => { 56 | fakeLocation('/not/the/dashboard'); 57 | const wrapper = shallow(); 58 | const dashbaordLink = wrapper.find('Link').at(0); 59 | expect(dashbaordLink.prop('className')).toMatchInlineSnapshot(`"item"`); 60 | }); 61 | }); 62 | 63 | describe('isPathActive', () => { 64 | it('should be true for exact matches', () => { 65 | fakeLocation('/abc'); 66 | expect(isPathActive('/abc', true)).toBe(true); 67 | }); 68 | 69 | it('should be false for child paths if exact is enabled', () => { 70 | fakeLocation('/abc/def'); 71 | expect(isPathActive('/abc', true)).toBe(false); 72 | }); 73 | 74 | it('should be true for exact matches even if exact is disabled', () => { 75 | fakeLocation('/abc'); 76 | expect(isPathActive('/abc', false)).toBe(true); 77 | }); 78 | 79 | it('should be true for child paths if exact is disabled', () => { 80 | fakeLocation('/abc/def'); 81 | expect(isPathActive('/abc', false)).toBe(true); 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /src/client/components/__tests__/ProjectSecret.spec.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | import * as React from 'react'; 6 | import { mount, shallow } from 'enzyme'; 7 | import { ProjectSecret } from '../ProjectSecret'; 8 | 9 | import { mockProject } from '../../../__mocks__/project'; 10 | 11 | describe('', () => { 12 | it('Should render a disabled input', () => { 13 | const wrapper = shallow(); 14 | expect(wrapper).toMatchSnapshot(); 15 | const mounted = mount(); 16 | expect(mounted.find('ForwardRef(Button)')).toHaveLength(1); 17 | }); 18 | 19 | it('should initially dispplay asterix (not the secret)', () => { 20 | const mounted = mount(); 21 | expect(mounted.find('ForwardRef(Button)').children().text()).toMatchInlineSnapshot(`"Show"`); 22 | expect(mounted.find('ForwardRef(TextInput)').prop('value')).toMatchInlineSnapshot( 23 | `"••••••••••••••••••••••••••••••••••••••••••••••••••"`, 24 | ); 25 | }); 26 | 27 | it('should toggle to display the secret when the button is clicked', () => { 28 | const mounted = mount(); 29 | mounted.find('ForwardRef(Button)').simulate('click'); 30 | expect(mounted.find('ForwardRef(Button)').children().text()).toMatchInlineSnapshot(`"Hide"`); 31 | expect(mounted.find('ForwardRef(TextInput)').prop('value')).toBe(mockProject().secret); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /src/client/components/__tests__/RequesterConfig.spec.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | jest.mock('../configurators/CircleCIRequesterConfig', () => ({ 6 | CircleCIRequesterConfig: () => null, 7 | })); 8 | 9 | import * as React from 'react'; 10 | import { act } from 'react-dom/test-utils'; 11 | import { shallow, mount } from 'enzyme'; 12 | import { RequesterConfig } from '../RequesterConfig'; 13 | import { mockProject } from '../../../__mocks__/project'; 14 | 15 | describe('', () => { 16 | it('Should render', () => { 17 | const setProject = jest.fn(); 18 | const wrapper = shallow(); 19 | expect(wrapper).toMatchSnapshot(); 20 | }); 21 | it('Should render a help box by default', () => { 22 | const setProject = jest.fn(); 23 | const mounted = shallow(); 24 | expect(mounted.find('Memo(ForwardRef(Alert))')).toHaveLength(1); 25 | }); 26 | 27 | it('Should hide the help box when it is closed', () => { 28 | const setProject = jest.fn(); 29 | const mounted = shallow(); 30 | act(() => (mounted.find('Memo(ForwardRef(Alert))').prop('onRemove') as Function)()); 31 | mounted.setProps({}); 32 | expect(mounted.find('Memo(ForwardRef(Alert))')).toHaveLength(0); 33 | }); 34 | 35 | it.skip('Should reopen the help box for a new project if it is closed', () => { 36 | const setProject = jest.fn(); 37 | const mounted = shallow(); 38 | act(() => (mounted.find('Memo(ForwardRef(Alert))').prop('onRemove') as Function)()); 39 | mounted.setProps({}); 40 | expect(mounted.find('Memo(ForwardRef(Alert))')).toHaveLength(0); 41 | mounted.setProps({ 42 | project: mockProject(), 43 | }); 44 | mounted.setProps({}); 45 | expect(mounted.find('Memo(ForwardRef(Alert))')).toHaveLength(1); 46 | }); 47 | 48 | it('should ask us to configure a requester if none is configured', () => { 49 | const setProject = jest.fn(); 50 | const project = mockProject(); 51 | const mounted = shallow(); 52 | expect( 53 | mounted.find('Memo(ForwardRef(Paragraph))').at(0).text().includes('choose one'), 54 | ).toBeTruthy(); 55 | }); 56 | 57 | it('Should show the circleci configurator when that tab is selected', () => { 58 | const setProject = jest.fn(); 59 | const mounted = shallow(); 60 | expect(mounted.find('CircleCIRequesterConfig')).toHaveLength(0); 61 | act(() => (mounted.find('Memo(ForwardRef(Tab))').at(0).prop('onSelect') as Function)()); 62 | mounted.setProps({}); 63 | expect(mounted.find('CircleCIRequesterConfig')).toHaveLength(1); 64 | }); 65 | 66 | it('Should show the circleci configurator when circleci has been configured on the provided project', () => { 67 | const setProject = jest.fn(); 68 | const project = mockProject(); 69 | project.requester_circleCI = { 70 | accessToken: 'test123', 71 | }; 72 | const mounted = mount(); 73 | expect(mounted.find('CircleCIRequesterConfig')).toHaveLength(1); 74 | }); 75 | 76 | it('Should show the github configurator when github has been configured on the provided project', () => { 77 | const setProject = jest.fn(); 78 | const project = mockProject(); 79 | project.requester_gitHub = {}; 80 | const mounted = mount(); 81 | expect(mounted.find('GitHubActionsRequesterConfig')).toHaveLength(1); 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /src/client/components/__tests__/__snapshots__/MainAppRouter.spec.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` Should render a router 1`] = ` 4 | 5 | 6 | 7 | 12 | 17 | 22 | 25 | 26 | 27 | `; 28 | -------------------------------------------------------------------------------- /src/client/components/__tests__/__snapshots__/MenuHeader.spec.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` Should render a logo 1`] = ` 4 | 7 | 10 | 13 | CFA 14 | 15 | 19 | Dashboard 20 | 21 | 26 | Documentation 27 | 28 |
31 | 35 | CFA is open-source and does not have formal support 36 |
37 | 40 | 44 | 45 | ? 46 | 47 | 48 | 49 |
50 | `; 51 | -------------------------------------------------------------------------------- /src/client/components/__tests__/__snapshots__/ProjectSecret.spec.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` Should render a disabled input 1`] = ` 4 | 5 | 6 | Project Secret 7 | 8 | 11 | 16 | 21 | Show 22 | 23 | 24 | 25 | `; 26 | -------------------------------------------------------------------------------- /src/client/components/__tests__/__snapshots__/RequesterConfig.spec.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` Should render 1`] = ` 4 | 5 | 8 | Requester 9 | 10 | 11 | 17 | A Requester is how your automation asks CFA for a 2FA token. This is normally your CI provider, E.g. Circle CI. 18 | 19 | 20 | 23 | 24 | 34 | 37 | Circle CI 38 | 39 | 49 | 52 | GitHub Actions 53 | 54 | 57 | More Coming Soon... 58 | 59 | 60 | 61 | 65 | 66 | No Requester has been configured, choose one to get started! 67 | 68 | 69 | 70 | `; 71 | -------------------------------------------------------------------------------- /src/client/components/configurators/CircleCIRequesterConfig.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Heading, Paragraph } from 'evergreen-ui'; 3 | 4 | import { FullProject } from '../../../common/types'; 5 | import { GenericAccessTokenRequesterConfig } from './GenericAccessTokenRequesterConfig'; 6 | 7 | export interface Props { 8 | project: FullProject; 9 | setProject: (newProject: FullProject) => void; 10 | } 11 | 12 | export function CircleCIRequesterConfig(props: Props) { 13 | return ( 14 | 22 | 23 | Circle CI Access Token 24 | 25 | 26 | You can generate an access token in your{' '} 27 | 32 | Circle CI Project Settings 33 | 34 | . The token must have the "All" scope. 35 | 36 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/client/components/configurators/GenericAccessTokenRequesterConfig.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Button, Pane, TextInput, toaster } from 'evergreen-ui'; 3 | 4 | import { FullProject } from '../../../common/types'; 5 | import { useAsyncTaskFetch } from 'react-hooks-async'; 6 | import { defaultBodyReader } from '../../utils'; 7 | 8 | export interface Props { 9 | project: FullProject; 10 | setProject: (newProject: FullProject) => void; 11 | originalAccessToken: string; 12 | children: React.ReactChild | React.ReactChild[]; 13 | slug: 'circleci'; 14 | requesterName: string; 15 | } 16 | 17 | export function GenericAccessTokenRequesterConfig({ 18 | project, 19 | setProject, 20 | originalAccessToken, 21 | children, 22 | slug, 23 | requesterName, 24 | }: Props) { 25 | const [accessToken, setAccesToken] = React.useState(originalAccessToken); 26 | 27 | const options = React.useMemo( 28 | () => ({ 29 | method: 'POST', 30 | headers: new Headers({ 31 | 'Content-Type': 'application/json', 32 | }), 33 | body: JSON.stringify({ accessToken }), 34 | }), 35 | [project, accessToken], 36 | ); 37 | 38 | const createRequesterTask = useAsyncTaskFetch( 39 | `/api/project/${project.id}/config/requesters/${slug}`, 40 | options, 41 | defaultBodyReader, 42 | ); 43 | 44 | React.useEffect(() => { 45 | if (createRequesterTask.error) { 46 | toaster.danger( 47 | `Failed to create the ${requesterName} Requester, please ensure the access token is correct and try again.`, 48 | ); 49 | } 50 | }, [createRequesterTask.error]); 51 | 52 | React.useEffect(() => { 53 | if (createRequesterTask.result) { 54 | toaster.success( 55 | `Successfully created the ${requesterName} Requester, the access token is valid.`, 56 | ); 57 | setProject(createRequesterTask.result); 58 | } 59 | }, [createRequesterTask.result]); 60 | 61 | const saving = createRequesterTask.started && createRequesterTask.pending; 62 | 63 | return ( 64 | 65 | 66 | {children} 67 | setAccesToken(e.currentTarget.value)} 71 | disabled={saving} 72 | /> 73 | {accessToken !== originalAccessToken && accessToken ? ( 74 | 84 | ) : null} 85 | 86 | 87 | ); 88 | } 89 | -------------------------------------------------------------------------------- /src/client/components/configurators/GitHubActionsRequesterConfig.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Alert, Button, Code, Heading, Pane, Paragraph, toaster } from 'evergreen-ui'; 3 | 4 | import { FullProject } from '../../../common/types'; 5 | import { useAsyncTaskFetch } from 'react-hooks-async'; 6 | import { defaultBodyReader } from '../../utils'; 7 | 8 | export interface Props { 9 | project: FullProject; 10 | setProject: (newProject: FullProject) => void; 11 | } 12 | 13 | export function GitHubActionsRequesterConfig({ project, setProject }: Props) { 14 | const [showInstallButton, setShowInstallButton] = React.useState(false); 15 | const options = React.useMemo( 16 | () => ({ 17 | method: 'POST', 18 | headers: new Headers({ 19 | 'Content-Type': 'application/json', 20 | }), 21 | body: JSON.stringify({}), 22 | }), 23 | [project], 24 | ); 25 | 26 | const installGitHubApp = React.useCallback(() => { 27 | window.open('https://github.com/apps/continuous-auth/installations/new', '_blank'); 28 | setShowInstallButton(false); 29 | }, []); 30 | 31 | const createRequesterTask = useAsyncTaskFetch( 32 | `/api/project/${project.id}/config/requesters/github`, 33 | options, 34 | defaultBodyReader, 35 | ); 36 | 37 | const projectSlug = `${project.repoOwner}/${project.repoName}`; 38 | 39 | React.useEffect(() => { 40 | if (createRequesterTask.error) { 41 | if (createRequesterTask.error.message.includes('412')) { 42 | toaster.notify(`Continuous Auth not installed in ${projectSlug}`); 43 | setShowInstallButton(true); 44 | } else { 45 | toaster.danger(`Failed to create the GitHub Requester, please try again later.`); 46 | } 47 | } 48 | }, [createRequesterTask.error, projectSlug]); 49 | 50 | React.useEffect(() => { 51 | if (createRequesterTask.result) { 52 | toaster.success(`Successfully created the GitHub Requester.`); 53 | setProject(createRequesterTask.result); 54 | } 55 | }, [createRequesterTask.result]); 56 | 57 | const saving = createRequesterTask.started && createRequesterTask.pending; 58 | 59 | return ( 60 | 61 | 62 | 63 | GitHub Actions Secrets 64 | 65 | {showInstallButton ? ( 66 | <> 67 | 68 | You need to install the Continuous Auth github app before we can set this project up. 69 |
70 | 73 |
74 | 75 | ) : null} 76 | {project.requester_gitHub ? ( 77 | <> 78 | 79 | ContinuousAuth is fully set up, if you're having issues with secrets you can use the 80 | "Fix" button below. 81 | 82 | 91 | 92 | ) : ( 93 | <> 94 | 95 | ContinuousAuth needs to make some secrets in GitHub Actions in order to publish. 96 | 97 | 107 | 108 | )} 109 |
110 |
111 | ); 112 | } 113 | -------------------------------------------------------------------------------- /src/client/components/configurators/SlackResponderConfig.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { 3 | Avatar, 4 | Button, 5 | Code, 6 | Heading, 7 | ListItem, 8 | OrderedList, 9 | Pane, 10 | Paragraph, 11 | Spinner, 12 | TextInput, 13 | toaster, 14 | } from 'evergreen-ui'; 15 | 16 | import { FullProject } from '../../../common/types'; 17 | import { useAsyncTaskFetch } from 'react-hooks-async'; 18 | import { defaultBodyReader } from '../../utils'; 19 | 20 | export interface Props { 21 | project: FullProject; 22 | setProject: (newProject: FullProject) => void; 23 | } 24 | 25 | const linkOptions = { 26 | method: 'POST', 27 | }; 28 | 29 | export function SlackResponderConfig({ project, setProject }: Props) { 30 | const createLinkerTask = useAsyncTaskFetch<{ linker: { id: string }; slackClientId: string }>( 31 | `/api/project/${project.id}/config/responders/slack`, 32 | linkOptions, 33 | defaultBodyReader, 34 | ); 35 | 36 | React.useEffect(() => { 37 | if (!project.responder_slack && createLinkerTask.start) { 38 | createLinkerTask.start(); 39 | } 40 | }, [project.responder_slack, createLinkerTask.start]); 41 | 42 | React.useEffect(() => { 43 | if (createLinkerTask.error) { 44 | toaster.danger('Failed to generate Slack linker code, please reload and try again.'); 45 | } 46 | }, [createLinkerTask.error]); 47 | 48 | const [usernameToMention, setUsernameToMention] = React.useState( 49 | project.responder_slack ? project.responder_slack.usernameToMention : '', 50 | ); 51 | 52 | const usernameOptions = React.useMemo( 53 | () => ({ 54 | method: 'PATCH', 55 | headers: new Headers({ 56 | 'Content-Type': 'application/json', 57 | }), 58 | body: JSON.stringify({ usernameToMention }), 59 | }), 60 | [usernameToMention], 61 | ); 62 | 63 | const updateUsernameToMentionTask = useAsyncTaskFetch( 64 | `/api/project/${project.id}/config/responders/slack`, 65 | usernameOptions, 66 | defaultBodyReader, 67 | ); 68 | 69 | React.useEffect(() => { 70 | if (updateUsernameToMentionTask.error) { 71 | toaster.danger('Failed to update the Slack Responder, please try again later.'); 72 | } 73 | }, [updateUsernameToMentionTask.error]); 74 | 75 | React.useEffect(() => { 76 | if (updateUsernameToMentionTask.result) { 77 | toaster.success( 78 | 'Successfully updated the Slack Responder with the provided username / usergroup.', 79 | ); 80 | setProject(updateUsernameToMentionTask.result); 81 | } 82 | }, [updateUsernameToMentionTask.result]); 83 | 84 | if (project.responder_slack) { 85 | const saving = updateUsernameToMentionTask.started && updateUsernameToMentionTask.pending; 86 | 87 | return ( 88 | 89 | 90 | The Slack Responder is currently configured and is pointing at the following Team / 91 | Channel. 92 | 93 | 94 | 100 | {project.responder_slack.teamName} - #{project.responder_slack.channelName} 101 | 102 | 103 | 104 | Username / Usergroup 105 | 106 | setUsernameToMention(e.currentTarget.value)} 109 | /> 110 | {usernameToMention !== project.responder_slack.usernameToMention && usernameToMention ? ( 111 | 121 | ) : null} 122 | 123 | 124 | ); 125 | } 126 | 127 | if (createLinkerTask.pending || createLinkerTask.error || !createLinkerTask.result) { 128 | return ( 129 | 130 | 131 | 132 | ); 133 | } 134 | 135 | return ( 136 | 137 | 138 | Slack has not been linked to this Project yet, follow the instructions below to link a Slack 139 | channel to this project. 140 | 141 | 142 | 143 | Install the CFA Slack App in your workspace if it isn't already installed.{' '} 144 | 153 | Add to Slack 160 | 161 | 162 | 163 | Run{' '} 164 | 165 | /cfa-link{process.env.NODE_ENV === 'production' ? '' : '-dev'}{' '} 166 | {createLinkerTask.result.linker.id} 167 | {' '} 168 | in the channel you want to link to CFA 169 | 170 | Refresh this project when you're done using the Refresh button above. 171 | 172 | 173 | ); 174 | } 175 | -------------------------------------------------------------------------------- /src/client/components/configurators/__tests__/CircleCIRequesterConfig.spec.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import { CircleCIRequesterConfig } from '../CircleCIRequesterConfig'; 4 | import { mockProject } from '../../../../__mocks__/project'; 5 | 6 | describe('', () => { 7 | it('Should render a GenericAccessTokenRequesterConfig', () => { 8 | const wrapper = shallow( 9 | null} />, 10 | ); 11 | expect(wrapper).toMatchSnapshot(); 12 | expect(wrapper.find('GenericAccessTokenRequesterConfig')).toHaveLength(1); 13 | }); 14 | 15 | it('should link users directly to the token generation page for that circleci project', () => { 16 | const wrapper = shallow( 17 | null} />, 18 | ); 19 | const link = wrapper.find('a'); 20 | expect(link).toHaveLength(1); 21 | expect(link.prop('href')).toBe( 22 | 'https://app.circleci.com/settings/project/github/my-owner/my-repo/api', 23 | ); 24 | }); 25 | 26 | it('should provide an empty original access token if the project is not configured for cirlceci', () => { 27 | const project = mockProject(); 28 | project.requester_circleCI = null; 29 | const wrapper = shallow( null} />); 30 | expect(wrapper.find('GenericAccessTokenRequesterConfig').prop('originalAccessToken')).toBe(''); 31 | }); 32 | 33 | it('should provide the current access token as the originalAccessToken if the project is configured for circleci', () => { 34 | const project = mockProject(); 35 | project.requester_circleCI = { 36 | accessToken: 'my-access-token', 37 | }; 38 | const wrapper = shallow( null} />); 39 | expect(wrapper.find('GenericAccessTokenRequesterConfig').prop('originalAccessToken')).toBe( 40 | 'my-access-token', 41 | ); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/client/components/configurators/__tests__/GenericAccessTokenRequesterConfig.spec.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import { GenericAccessTokenRequesterConfig } from '../GenericAccessTokenRequesterConfig'; 4 | import { mockProject } from '../../../../__mocks__/project'; 5 | 6 | describe('', () => { 7 | beforeEach(() => { 8 | (global as any).Headers = function (o) { 9 | return o; 10 | } as any; 11 | }); 12 | afterEach(() => { 13 | delete (global as any).Headers; 14 | }); 15 | 16 | it('Should render a TextInput for the access token', () => { 17 | const wrapper = shallow( 18 | null} 21 | originalAccessToken="" 22 | slug="circleci" 23 | requesterName="My Requester" 24 | > 25 |

Children

26 |
, 27 | ); 28 | expect(wrapper).toMatchSnapshot(); 29 | expect(wrapper.find('Memo(ForwardRef(TextInput))')).toHaveLength(1); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /src/client/components/configurators/__tests__/__snapshots__/CircleCIRequesterConfig.spec.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` Should render a GenericAccessTokenRequesterConfig 1`] = ` 4 | 23 | 27 | Circle CI Access Token 28 | 29 | 32 | You can generate an access token in your 33 | 34 | 39 | Circle CI Project Settings 40 | 41 | . The token must have the "All" scope. 42 | 43 | 44 | `; 45 | -------------------------------------------------------------------------------- /src/client/components/configurators/__tests__/__snapshots__/GenericAccessTokenRequesterConfig.spec.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` Should render a TextInput for the access token 1`] = ` 4 | 5 | 6 |

7 | Children 8 |

9 | 15 |
16 |
17 | `; 18 | -------------------------------------------------------------------------------- /src/client/components/icons/CircleCI.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { Props } from './icon-props'; 4 | 5 | export function CircleCILogo(props: Props) { 6 | return ( 7 | 14 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/client/components/icons/GitHub.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { Props } from './icon-props'; 4 | 5 | export function GitHubLogo(props: Props) { 6 | return ( 7 | 8 | 15 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/client/components/icons/Logo.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { Props } from './icon-props'; 4 | 5 | export function CFALogo(props: Props) { 6 | return ( 7 | 8 | 9 | 13 | 17 | 21 | 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /src/client/components/icons/Rocket.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { Props } from './icon-props'; 4 | 5 | export function Rocket(props: Props) { 6 | return ( 7 | 15 | 19 | 23 | 27 | 31 | 32 | 33 | 34 | 35 | 39 | 43 | 47 | 51 | 55 | 56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /src/client/components/icons/Slack.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { Props } from './icon-props'; 4 | 5 | export function SlackLogo(props: Props) { 6 | return ( 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /src/client/components/icons/__tests__/CircleCI.spec.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import { CircleCILogo } from '../CircleCI'; 4 | 5 | describe('CircleCI Icon', () => { 6 | it('Should render with a className', () => { 7 | const wrapper = shallow(); 8 | expect(wrapper).toMatchSnapshot(); 9 | expect(wrapper.prop('className')).toBe('test_class_name'); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/client/components/icons/__tests__/GitHub.spec.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import { GitHubLogo } from '../GitHub'; 4 | 5 | describe('GitHubLogo Icon', () => { 6 | it('Should render with a className', () => { 7 | const wrapper = shallow(); 8 | expect(wrapper).toMatchSnapshot(); 9 | expect(wrapper.prop('className')).toBe('test_class_name'); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/client/components/icons/__tests__/Logo.spec.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import { CFALogo } from '../Logo'; 4 | 5 | describe('CFALogo Icon', () => { 6 | it('Should render with a className', () => { 7 | const wrapper = shallow(); 8 | expect(wrapper).toMatchSnapshot(); 9 | expect(wrapper.prop('className')).toBe('test_class_name'); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/client/components/icons/__tests__/Rocket.spec.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import { Rocket } from '../Rocket'; 4 | 5 | describe('Rocket Icon', () => { 6 | it('Should render with a className', () => { 7 | const wrapper = shallow(); 8 | expect(wrapper).toMatchSnapshot(); 9 | expect(wrapper.prop('className')).toBe('test_class_name'); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/client/components/icons/__tests__/Slack.spec.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import { SlackLogo } from '../Slack'; 4 | 5 | describe('SlackLogo Icon', () => { 6 | it('Should render with a className', () => { 7 | const wrapper = shallow(); 8 | expect(wrapper).toMatchSnapshot(); 9 | expect(wrapper.prop('className')).toBe('test_class_name'); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/client/components/icons/__tests__/__snapshots__/CircleCI.spec.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`CircleCI Icon Should render with a className 1`] = ` 4 | 11 | 15 | 16 | `; 17 | -------------------------------------------------------------------------------- /src/client/components/icons/__tests__/__snapshots__/GitHub.spec.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`GitHubLogo Icon Should render with a className 1`] = ` 4 | 10 | 17 | 18 | `; 19 | -------------------------------------------------------------------------------- /src/client/components/icons/__tests__/__snapshots__/Logo.spec.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`CFALogo Icon Should render with a className 1`] = ` 4 | 9 | 12 | 16 | 20 | 24 | 25 | 26 | `; 27 | -------------------------------------------------------------------------------- /src/client/components/icons/__tests__/__snapshots__/Rocket.spec.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Rocket Icon Should render with a className 1`] = ` 4 | 12 | 16 | 20 | 24 | 28 | 31 | 34 | 37 | 38 | 42 | 46 | 50 | 54 | 58 | 59 | `; 60 | -------------------------------------------------------------------------------- /src/client/components/icons/__tests__/__snapshots__/Slack.spec.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`SlackLogo Icon Should render with a className 1`] = ` 4 | 9 | 10 | 13 | 16 | 19 | 22 | 23 | 26 | 29 | 32 | 33 | 36 | 39 | 42 | 43 | 46 | 49 | 52 | 53 | 54 | 55 | 56 | `; 57 | -------------------------------------------------------------------------------- /src/client/components/icons/icon-props.ts: -------------------------------------------------------------------------------- 1 | export interface Props { 2 | className: string; 3 | } 4 | -------------------------------------------------------------------------------- /src/client/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as ReactDOM from 'react-dom'; 3 | import * as Sentry from '@sentry/browser'; 4 | 5 | import 'normalize.css'; 6 | import './base.css'; 7 | 8 | import { App } from './components/App'; 9 | 10 | if (process.env.SENTRY_FE_DSN) { 11 | Sentry.init({ 12 | dsn: process.env.SENTRY_FE_DSN, 13 | environment: location.host.includes('staging') ? 'staging' : 'production', 14 | }); 15 | } 16 | 17 | ReactDOM.render(, document.querySelector('#app')); 18 | -------------------------------------------------------------------------------- /src/client/polyfill.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/continuousauth/web/1cfead31c74a1ec77d2e8494c0bc7ad11c94915a/src/client/polyfill.ts -------------------------------------------------------------------------------- /src/client/state/user.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | import * as React from 'react'; 3 | 4 | import { User } from '../../common/types'; 5 | 6 | export const UserState = React.createContext(null); 7 | -------------------------------------------------------------------------------- /src/client/template.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | CFA 7 | 8 | 9 | 10 | 11 | 12 |
13 |
14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/client/utils.ts: -------------------------------------------------------------------------------- 1 | import { SimpleProject } from '../common/types'; 2 | 3 | export const cx = (...args: (string | null | undefined)[]) => args.filter((a) => a).join(' '); 4 | 5 | export const projectHasAnyConfig = (project: SimpleProject): boolean => { 6 | return Boolean(project.requester_circleCI || project.requester_gitHub || project.responder_slack); 7 | }; 8 | 9 | export const defaultBodyReader = (body: any) => body.json(); 10 | export const defaultFetchInit = {}; 11 | -------------------------------------------------------------------------------- /src/common/__tests__/types.spec.ts: -------------------------------------------------------------------------------- 1 | import { projectIsMissingConfig } from '../types'; 2 | 3 | describe('projectIsMissingConfig', () => { 4 | it('should return false for configured project', () => { 5 | expect( 6 | projectIsMissingConfig({ 7 | responder_slack: {}, 8 | requester_circleCI: {}, 9 | requester_gitHub: null, 10 | }), 11 | ).toBe(false); 12 | }); 13 | 14 | it('should return true if no requester is configured', () => { 15 | expect( 16 | projectIsMissingConfig({ 17 | responder_slack: {}, 18 | requester_circleCI: null, 19 | requester_gitHub: null, 20 | }), 21 | ).toBe(true); 22 | }); 23 | 24 | it('should return true if no responder is configured', () => { 25 | expect( 26 | projectIsMissingConfig({ 27 | responder_slack: null, 28 | requester_circleCI: {}, 29 | requester_gitHub: null, 30 | }), 31 | ).toBe(true); 32 | }); 33 | 34 | it('should return true if nothing is configured', () => { 35 | expect( 36 | projectIsMissingConfig({ 37 | responder_slack: null, 38 | requester_circleCI: null, 39 | requester_gitHub: null, 40 | }), 41 | ).toBe(true); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/common/types.ts: -------------------------------------------------------------------------------- 1 | export interface User { 2 | id: string; 3 | displayName: string; 4 | username: string; 5 | profileUrl: string; 6 | } 7 | 8 | export interface SimpleRepo { 9 | id: string; 10 | repoName: string; 11 | repoOwner: string; 12 | defaultBranch: string; 13 | } 14 | 15 | export interface SimpleProject extends SimpleRepo { 16 | requester_circleCI: boolean; 17 | requester_gitHub: boolean; 18 | responder_slack: { 19 | team: string; 20 | channel: string; 21 | } | null; 22 | } 23 | 24 | export interface FullProject extends SimpleRepo { 25 | secret: string; 26 | enabled: boolean; 27 | requester_circleCI: { 28 | accessToken: string; 29 | } | null; 30 | requester_gitHub: {} | null; 31 | responder_slack: { 32 | teamName: string; 33 | channelName: string; 34 | teamIcon: string; 35 | usernameToMention: string; 36 | } | null; 37 | } 38 | 39 | export interface ReposResponse { 40 | all: SimpleRepo[]; 41 | configured: SimpleProject[]; 42 | } 43 | 44 | type Any = { 45 | [P in keyof T]: any; 46 | }; 47 | 48 | export const projectIsMissingConfig = ( 49 | project: Any< 50 | Pick< 51 | FullProject, 52 | Exclude< 53 | keyof FullProject, 54 | 'secret' | 'enabled' | 'id' | 'repoName' | 'repoOwner' | 'defaultBranch' 55 | > 56 | > 57 | >, 58 | ) => { 59 | const hasRequester: boolean = !!project.requester_circleCI || !!project.requester_gitHub; 60 | const hasResponder: boolean = !!project.responder_slack; 61 | return !hasRequester || !hasResponder; 62 | }; 63 | -------------------------------------------------------------------------------- /src/server/api/auth/index.ts: -------------------------------------------------------------------------------- 1 | import * as express from 'express'; 2 | import * as passport from 'passport'; 3 | import { Strategy } from 'passport-github'; 4 | import { ExpressRequest } from '../../helpers/_middleware'; 5 | 6 | passport.use( 7 | 'github', 8 | new Strategy( 9 | { 10 | clientID: process.env.GITHUB_CLIENT_ID!, 11 | clientSecret: process.env.GITHUB_CLIENT_SECRET!, 12 | callbackURL: 13 | process.env.NODE_ENV !== 'production' 14 | ? 'http://localhost:3000/api/auth/callback' 15 | : 'https://continuousauth.dev/api/auth/callback', 16 | scope: ['repo'], 17 | }, 18 | (accessToken, refreshToken, profile, cb) => { 19 | cb(null, { 20 | accessToken, 21 | profile: { 22 | ...profile, 23 | username: profile.username || '', 24 | }, 25 | }); 26 | }, 27 | ), 28 | ); 29 | 30 | passport.serializeUser((u, cb) => cb(null, JSON.stringify(u))); 31 | passport.deserializeUser((u, cb) => cb(null, JSON.parse(u as any))); 32 | 33 | export function authRoutes() { 34 | const router = express(); 35 | 36 | router.get('/me', (req: ExpressRequest, res) => { 37 | if (req.user) return res.json(req.user.profile); 38 | res.status(404).json({ error: 'Not Signed In' }); 39 | }); 40 | 41 | router.get('/login', passport.authenticate('github')); 42 | router.get('/callback', (req, res, next) => { 43 | passport.authenticate('github', (err, user) => { 44 | if (err) return res.redirect('/api/auth/login'); 45 | delete user.profile._raw; 46 | delete user.profile._json; 47 | req.login(user, (err) => { 48 | if (err) return res.redirect('/api/auth/login'); 49 | res.redirect('/'); 50 | }); 51 | })(req, res, next); 52 | }); 53 | 54 | return router; 55 | } 56 | -------------------------------------------------------------------------------- /src/server/api/index.ts: -------------------------------------------------------------------------------- 1 | import * as express from 'express'; 2 | import { authRoutes } from './auth'; 3 | import { repoRoutes } from './repo'; 4 | import { projectRoutes } from './project'; 5 | import { requireLogin } from '../helpers/_middleware'; 6 | import { requestRoutes } from './request'; 7 | 8 | export function apiRoutes() { 9 | const router = express(); 10 | 11 | router.use('/auth', authRoutes()); 12 | router.use('/project', requireLogin, projectRoutes()); 13 | router.use('/repos', requireLogin, repoRoutes()); 14 | router.use('/request', requestRoutes()); 15 | 16 | return router; 17 | } 18 | -------------------------------------------------------------------------------- /src/server/api/project/__tests__/_safe.spec.ts: -------------------------------------------------------------------------------- 1 | import { generateNewSecret, sanitizeProject } from '../_safe'; 2 | 3 | describe('generateNewSecret', () => { 4 | it('generates a secret of the given length', () => { 5 | expect(generateNewSecret(123)).toHaveLength(123); 6 | }); 7 | 8 | it('will not generate a secret longer than 256', () => { 9 | expect(() => generateNewSecret(500)).toThrow(); 10 | }); 11 | }); 12 | 13 | describe('sanitizeProject', () => { 14 | it('removes all bad properties', () => { 15 | expect(sanitizeProject({} as any)).toStrictEqual({}); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/server/api/project/_safe.ts: -------------------------------------------------------------------------------- 1 | import * as crypto from 'crypto'; 2 | import * as express from 'express'; 3 | 4 | import { Project } from '../../db/models'; 5 | import { ExpressRequest, hasAdminAccessToTargetRepo } from '../../helpers/_middleware'; 6 | 7 | export const generateNewSecret = (size: number) => { 8 | if (size > 256) throw new Error('size must be <= 256'); 9 | return crypto.randomBytes(256).toString('hex').slice(0, size); 10 | }; 11 | 12 | export const sanitizeProject = (project: Project) => project; 13 | 14 | export const getProjectFromIdAndCheckPermissions = async ( 15 | id: string, 16 | req: ExpressRequest, 17 | res: express.Response, 18 | ) => { 19 | const project = await Project.findOne({ 20 | where: { 21 | id: id, 22 | enabled: true, 23 | }, 24 | include: Project.allIncludes, 25 | }); 26 | // Project not existing and user not having permission should look identical 27 | // so that folks can't sniff which projects are active on CFA 28 | if (!project) { 29 | res.status(401).json({ 30 | error: 'Current user is not an admin of the target repository', 31 | }); 32 | return null; 33 | } 34 | 35 | if (!(await hasAdminAccessToTargetRepo(req, project.id))) { 36 | res.status(401).json({ 37 | error: 'Current user is not an admin of the target repository', 38 | }); 39 | return null; 40 | } 41 | 42 | return project; 43 | }; 44 | -------------------------------------------------------------------------------- /src/server/api/project/index.ts: -------------------------------------------------------------------------------- 1 | import * as debug from 'debug'; 2 | import * as express from 'express'; 3 | import * as Joi from 'joi'; 4 | import { Octokit, RestEndpointMethodTypes } from '@octokit/rest'; 5 | 6 | import { createA } from '../../helpers/a'; 7 | import { validate, hasAdminAccessToTargetRepo } from '../../helpers/_middleware'; 8 | import { Project, withTransaction, OTPRequest } from '../../db/models'; 9 | import { configRoutes, updateCircleEnvVars } from './config'; 10 | import { sanitizeProject, generateNewSecret, getProjectFromIdAndCheckPermissions } from './_safe'; 11 | 12 | const d = debug('cfa:server:api:auth'); 13 | const a = createA(d); 14 | 15 | export function projectRoutes() { 16 | const router = express(); 17 | 18 | router.post( 19 | '/', 20 | validate( 21 | { 22 | a, 23 | body: { 24 | repoId: Joi.number().integer().required(), 25 | }, 26 | }, 27 | async (req, res) => { 28 | if (!(await hasAdminAccessToTargetRepo(req, `${req.body.repoId}`))) { 29 | return res.status(401).json({ 30 | error: 'Current user is not an admin of the target repository', 31 | }); 32 | } 33 | 34 | const github = new Octokit({ 35 | auth: req.user!.accessToken, 36 | }); 37 | 38 | const repoResponse = await github.request('GET /repositories/:id', { 39 | id: req.body.repoId, 40 | }); 41 | const repo: RestEndpointMethodTypes['repos']['get']['response']['data'] = repoResponse.data; 42 | 43 | const existingProject = await Project.findByPk(req.body.repoId); 44 | if (existingProject) { 45 | existingProject.repoName = repo.name; 46 | existingProject.repoOwner = repo.owner.login; 47 | // If the project existed but was disabled let's enable it and 48 | // generate a new secret so that the old info is invalid. 49 | if (!existingProject.enabled) { 50 | existingProject.enabled = true; 51 | existingProject.secret = generateNewSecret(256); 52 | } 53 | await existingProject.save(); 54 | return res.status(200).json(existingProject); 55 | } 56 | 57 | const project = await Project.create({ 58 | id: `${req.body.repoId}`, 59 | repoName: repo.name, 60 | repoOwner: repo.owner.login, 61 | secret: generateNewSecret(256), 62 | defaultBranch: repo.default_branch, 63 | enabled: true, 64 | }); 65 | res.status(201).json(project); 66 | }, 67 | ), 68 | ); 69 | 70 | router.get( 71 | '/:id', 72 | validate( 73 | { 74 | a, 75 | params: { 76 | id: Joi.number().integer().required(), 77 | }, 78 | }, 79 | async (req, res) => { 80 | const project = await Project.findOne({ 81 | where: { 82 | id: req.params.id, 83 | enabled: true, 84 | }, 85 | include: Project.allIncludes, 86 | }); 87 | // Project not existing and user not having permission should look identical 88 | // so that folks can't sniff which projects are active on CFA 89 | if (!project) { 90 | return res.status(401).json({ 91 | error: 'Current user is not an admin of the target repository', 92 | }); 93 | } 94 | 95 | if (!(await hasAdminAccessToTargetRepo(req, project.id))) { 96 | return res.status(401).json({ 97 | error: 'Current user is not an admin of the target repository', 98 | }); 99 | } 100 | 101 | res.json(sanitizeProject(project)); 102 | }, 103 | ), 104 | ); 105 | 106 | router.get( 107 | '/:id/log', 108 | validate( 109 | { 110 | a, 111 | params: { 112 | id: Joi.number().integer().required(), 113 | }, 114 | }, 115 | async (req, res) => { 116 | const project = await getProjectFromIdAndCheckPermissions(req.params.id, req, res); 117 | if (!project) return; 118 | 119 | res.json( 120 | ( 121 | await OTPRequest.findAll({ 122 | where: { 123 | projectId: project.id, 124 | }, 125 | }) 126 | ) 127 | .sort((a, b) => b.requested.getTime() - a.requested.getTime()) 128 | .map((req) => { 129 | const simpleReq: Partial> = req.get(); 130 | delete simpleReq.proof; 131 | delete simpleReq.requestMetadata; 132 | delete simpleReq.responseMetadata; 133 | return simpleReq; 134 | }), 135 | ); 136 | }, 137 | ), 138 | ); 139 | 140 | router.patch( 141 | '/:id/secret', 142 | validate( 143 | { 144 | a, 145 | params: { 146 | id: Joi.number().integer().required(), 147 | }, 148 | }, 149 | async (req, res) => { 150 | const project = await getProjectFromIdAndCheckPermissions(req.params.id, req, res); 151 | if (!project) return; 152 | 153 | project.secret = generateNewSecret(256); 154 | await project.save(); 155 | 156 | if (project.requester_circleCI) { 157 | await updateCircleEnvVars(project, project.requester_circleCI.accessToken); 158 | } 159 | 160 | res.json(sanitizeProject(project)); 161 | }, 162 | ), 163 | ); 164 | 165 | router.delete( 166 | '/:id', 167 | validate( 168 | { 169 | a, 170 | params: { 171 | id: Joi.number().integer().required(), 172 | }, 173 | }, 174 | async (req, res) => { 175 | const project = await getProjectFromIdAndCheckPermissions(req.params.id, req, res); 176 | if (!project) return; 177 | 178 | await withTransaction(async (t) => { 179 | project.enabled = false; 180 | await project.resetAllResponders(t); 181 | await project.resetAllRequesters(t); 182 | await project.save({ transaction: t }); 183 | }); 184 | 185 | res.json(sanitizeProject(project)); 186 | }, 187 | ), 188 | ); 189 | 190 | router.use(configRoutes()); 191 | 192 | return router; 193 | } 194 | -------------------------------------------------------------------------------- /src/server/api/repo/index.ts: -------------------------------------------------------------------------------- 1 | import * as debug from 'debug'; 2 | import * as express from 'express'; 3 | import { Octokit, RestEndpointMethodTypes } from '@octokit/rest'; 4 | 5 | import { Project } from '../../db/models'; 6 | import { createA } from '../../helpers/a'; 7 | import { SimpleProject, SimpleRepo } from '../../../common/types'; 8 | import { Op } from 'sequelize'; 9 | 10 | const d = debug('cfa:server:api:repo'); 11 | const a = createA(d); 12 | 13 | declare module 'express-session' { 14 | interface SessionData { 15 | cachedRepos: SimpleRepo[]; 16 | } 17 | } 18 | 19 | export function repoRoutes() { 20 | const router = express(); 21 | 22 | router.get( 23 | '/', 24 | a(async (req, res) => { 25 | if (!req.user?.accessToken) { 26 | return res.status(403).json({ error: 'No Auth' }); 27 | } 28 | 29 | // TODO: Should we really be storing this on session, is there a better 30 | // place to store them in a cache? 31 | let reposWithAdmin = req.session.cachedRepos; 32 | if (!reposWithAdmin) { 33 | const github = new Octokit({ 34 | auth: req.user.accessToken, 35 | }); 36 | 37 | const allRepos: RestEndpointMethodTypes['repos']['listForAuthenticatedUser']['response']['data'] = 38 | await github.paginate( 39 | github.repos.listForAuthenticatedUser.endpoint.merge({ 40 | per_page: 100, 41 | visibility: 'public', 42 | }), 43 | ); 44 | 45 | reposWithAdmin = allRepos 46 | .filter((r) => r.permissions?.admin) 47 | .map((r) => ({ 48 | id: `${r.id}`, 49 | repoName: r.name, 50 | repoOwner: r.owner.login, 51 | defaultBranch: r.default_branch, 52 | })); 53 | req.session!.cachedRepos = reposWithAdmin; 54 | } 55 | 56 | const configured = await Project.findAll({ 57 | where: { 58 | id: { 59 | [Op.in]: reposWithAdmin.map((r) => r.id), 60 | }, 61 | enabled: true, 62 | }, 63 | include: Project.allIncludes, 64 | }); 65 | 66 | // Due to the use of a cache above to avoid long GitHub API requests 67 | // we CAN NOT guarantee that the user here has the "admin" level permission 68 | // still. As such no important information should be returned in the 69 | // SimpleProject. The secret should be stripped, and all request and respond 70 | // configs should be stripped to tiny amounts of information and no API keys 71 | // or ID's. 72 | const configuredMapped: SimpleProject[] = await Promise.all( 73 | configured.map(async (p) => { 74 | const updatedRepo = reposWithAdmin!.find((r) => r.id === p.id)!; 75 | let updated = false; 76 | if (updatedRepo.defaultBranch && p.defaultBranch !== updatedRepo.defaultBranch) { 77 | p.defaultBranch = updatedRepo.defaultBranch; 78 | updated = true; 79 | } 80 | if (updatedRepo.repoName !== p.repoName) { 81 | p.repoName = updatedRepo.repoName; 82 | updated = true; 83 | } 84 | if (updated) { 85 | await p.save(); 86 | } 87 | return { 88 | id: p.id, 89 | repoName: p.repoName, 90 | repoOwner: p.repoOwner, 91 | defaultBranch: p.defaultBranch, 92 | requester_circleCI: !!p.requester_circleCI, 93 | requester_gitHub: !!p.requester_gitHub, 94 | responder_slack: p.responder_slack 95 | ? { 96 | team: p.responder_slack.teamName, 97 | channel: p.responder_slack.channelName, 98 | } 99 | : null, 100 | }; 101 | }), 102 | ); 103 | res.json({ 104 | all: reposWithAdmin, 105 | configured: configuredMapped, 106 | }); 107 | }), 108 | ); 109 | 110 | return router; 111 | } 112 | -------------------------------------------------------------------------------- /src/server/api/request/__tests__/__snapshots__/requester.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`requester endpoint creator with request auth bypassed creating a request with an existing project should create an OTPRequest for a project that is configured and has valid metadata 1`] = ` 4 | { 5 | "errorReason": null, 6 | "errored": null, 7 | "projectId": 123, 8 | "requestMetadata": { 9 | "metadata": 321, 10 | }, 11 | "responded": null, 12 | "response": null, 13 | "responseMetadata": {}, 14 | "state": "requested", 15 | "userThatResponded": null, 16 | "validated": null, 17 | } 18 | `; 19 | -------------------------------------------------------------------------------- /src/server/api/request/__tests__/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { requestRoutes } from '..'; 2 | 3 | describe('requestRoutes', () => { 4 | it('should not throw', () => { 5 | expect(requestRoutes).not.toThrow(); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /src/server/api/request/index.ts: -------------------------------------------------------------------------------- 1 | import * as express from 'express'; 2 | 3 | import { createRequesterRoutes } from './requester'; 4 | import { CircleCIRequester } from '../../requesters/CircleCIRequester'; 5 | import { GitHubActionsRequester } from '../../requesters/GitHubActionsRequester'; 6 | 7 | export function requestRoutes() { 8 | const router = express(); 9 | 10 | router.use(createRequesterRoutes(new CircleCIRequester())); 11 | router.use(createRequesterRoutes(new GitHubActionsRequester())); 12 | 13 | return router; 14 | } 15 | -------------------------------------------------------------------------------- /src/server/api/request/requester.ts: -------------------------------------------------------------------------------- 1 | import * as debug from 'debug'; 2 | import * as express from 'express'; 3 | import * as Joi from 'joi'; 4 | import * as jwt from 'jsonwebtoken'; 5 | 6 | import { createA } from '../../helpers/a'; 7 | import { getGitHubAppInstallationToken } from '../../helpers/auth'; 8 | import { validate } from '../../helpers/_middleware'; 9 | import { Project, OTPRequest } from '../../db/models'; 10 | import { getResponderFor } from '../../responders'; 11 | import { Requester } from '../../requesters/Requester'; 12 | import { projectIsMissingConfig } from '../../../common/types'; 13 | import { getSignatureValidatedOIDCClaims } from '../../helpers/oidc'; 14 | 15 | const d = debug('cfa:api:request:requester'); 16 | const a = createA(d); 17 | 18 | const getRequestReadyProject = (projectId: string) => 19 | Project.findOne({ 20 | where: { 21 | id: projectId, 22 | enabled: true, 23 | }, 24 | include: Project.allIncludes, 25 | }); 26 | 27 | const getFullRequest = async (projectId: string, requestId: string) => { 28 | return OTPRequest.findOne({ 29 | where: { 30 | id: requestId, 31 | projectId, 32 | }, 33 | include: [ 34 | { 35 | model: Project, 36 | include: Project.allIncludes, 37 | }, 38 | ], 39 | }); 40 | }; 41 | 42 | export function createRequesterRoutes(requester: Requester) { 43 | const router = express(); 44 | 45 | // Middleware to verify the requst is authorized 46 | router.use( 47 | `/:projectId/${requester.slug}`, 48 | validate( 49 | { 50 | a, 51 | params: { 52 | projectId: Joi.number().integer().required(), 53 | }, 54 | }, 55 | async (req, res, next) => { 56 | if ((global as any).__bypassRequesterAuthentication) return next(); 57 | const authHeader = req.header('Authorization'); 58 | if (!authHeader) 59 | return res.status(403).json({ 60 | error: 'Missing Authorization header', 61 | }); 62 | 63 | const project = await Project.findOne({ 64 | where: { 65 | id: req.params.projectId, 66 | enabled: true, 67 | }, 68 | include: Project.allIncludes, 69 | }); 70 | 71 | const badAuthHeader = 72 | !project || 73 | !authHeader.toLowerCase().startsWith('bearer ') || 74 | authHeader.substr('bearer '.length) !== project.secret; 75 | if (!project || badAuthHeader) 76 | return res.status(404).json({ 77 | error: 'Project not found, disabled or you are not authorizad to access it', 78 | }); 79 | 80 | if (projectIsMissingConfig(project)) 81 | return res.status(400).json({ 82 | error: 'Project not completely configured', 83 | }); 84 | 85 | next(); 86 | }, 87 | ), 88 | ); 89 | 90 | router.post(`/:projectId/${requester.slug}/test`, (req, res) => res.json({ ok: true })); 91 | 92 | router.post( 93 | `/:projectId/${requester.slug}/credentials`, 94 | validate( 95 | { 96 | a, 97 | params: { 98 | projectId: Joi.number().integer().required(), 99 | }, 100 | body: { 101 | token: Joi.string().required(), 102 | }, 103 | }, 104 | async (req, res) => { 105 | const project = await getRequestReadyProject(req.params.projectId); 106 | if (!project) return res.status(404).json({ error: 'Could not find project' }); 107 | 108 | const config = requester.getConfigForProject(project); 109 | if (!config) 110 | return res.status(422).json({ error: 'Project is not configured to use this requester' }); 111 | 112 | let claims: jwt.Jwt | null; 113 | try { 114 | claims = await getSignatureValidatedOIDCClaims( 115 | requester, 116 | project, 117 | config, 118 | req.body.token, 119 | ); 120 | } catch (err) { 121 | if (typeof err === 'string') { 122 | return res.status(422).json({ error: err }); 123 | } else { 124 | throw err; 125 | } 126 | } 127 | 128 | if (!claims) return res.status(422).json({ error: 'Invalid OIDC token provided' }); 129 | 130 | if ( 131 | !(await requester.doOpenIDConnectClaimsMatchProject( 132 | claims.payload as jwt.JwtPayload, 133 | project, 134 | config, 135 | )) 136 | ) { 137 | return res.status(422).json({ error: 'Provided OIDC token does not match project' }); 138 | } 139 | 140 | let githubToken: string; 141 | try { 142 | githubToken = await getGitHubAppInstallationToken(project, { 143 | metadata: 'read', 144 | issues: 'write', 145 | pull_requests: 'write', 146 | contents: 'write', 147 | }); 148 | } catch (err) { 149 | console.error(err); 150 | return res.status(422).json({ error: 'Failed to obtain access token for project' }); 151 | } 152 | 153 | return res.json({ 154 | GITHUB_TOKEN: githubToken, 155 | }); 156 | }, 157 | ), 158 | ); 159 | 160 | router.post( 161 | `/:projectId/${requester.slug}`, 162 | validate( 163 | { 164 | a, 165 | params: { 166 | projectId: Joi.number().integer().required(), 167 | }, 168 | }, 169 | async (req, res) => { 170 | const project = await getRequestReadyProject(req.params.projectId); 171 | if (!project) return res.status(404).json({ error: 'Could not find project' }); 172 | 173 | const config = requester.getConfigForProject(project); 174 | if (!config) 175 | return res.status(422).json({ error: 'Project is not configured to use this requester' }); 176 | 177 | const requestMetadata = await requester.metadataForInitialRequest(req, res); 178 | // Do not send a response here as metadataForInitialRequest will send the response 179 | if (!requestMetadata) return; 180 | 181 | const newRequest = await OTPRequest.create( 182 | { 183 | projectId: project.id, 184 | state: 'requested', 185 | requested: new Date(), 186 | requestMetadata, 187 | responseMetadata: {}, 188 | proof: OTPRequest.generateProof(), 189 | }, 190 | { 191 | returning: true, 192 | }, 193 | ); 194 | // Fetch with includes 195 | const completeRequest = (await getFullRequest(`${project.id}`, newRequest.id))!; 196 | const request = await requester.isOTPRequestValidForRequester(completeRequest); 197 | if (!request) return res.status(422).json({ error: 'CFA Requester is misconfigured' }); 198 | 199 | const allowedState = await requester.validateActiveRequest(request, config); 200 | if (!allowedState.ok) { 201 | request.state = 'error'; 202 | request.errored = new Date(); 203 | request.errorReason = allowedState.error; 204 | await request.save(); 205 | return res.status(400).json({ 206 | error: 'Invalid build provided, check the CFA dashboard for reasoning.', 207 | }); 208 | } 209 | 210 | res.json(request); 211 | }, 212 | ), 213 | ); 214 | 215 | router.post( 216 | `/:projectId/${requester.slug}/:requestId/validate`, 217 | validate( 218 | { 219 | a, 220 | params: { 221 | projectId: Joi.number().integer().required(), 222 | requestId: Joi.string().uuid({ version: 'uuidv4' }).required(), 223 | }, 224 | }, 225 | async (req, res) => { 226 | const unknownRequest = await getFullRequest(req.params.projectId, req.params.requestId); 227 | if (!unknownRequest) { 228 | return res.status(404).json({ 229 | error: 'That request does not exist or is invalid', 230 | }); 231 | } 232 | 233 | const request = await requester.isOTPRequestValidForRequester(unknownRequest); 234 | if (!request) 235 | return res.status(422).json({ error: 'Project is not configured to use this requester' }); 236 | 237 | const config = requester.getConfigForProject(request.project); 238 | 239 | if (!config) 240 | return res 241 | .status(422) 242 | .json({ error: 'Project is missing the required configuration to use this requester' }); 243 | 244 | if ((config as any).then) 245 | return res.status(500).json({ 246 | error: 'getConfigForProject returned a promise-like thing and that is not OK', 247 | }); 248 | 249 | if (request.state !== 'requested') { 250 | return res.status(400).json({ 251 | error: `Expected the request to be in state "requested" but was in state "${request.state}"`, 252 | }); 253 | } 254 | 255 | const allowedState = await requester.validateActiveRequest(request, config); 256 | if (!allowedState.ok) { 257 | request.state = 'error'; 258 | request.errored = new Date(); 259 | request.errorReason = allowedState.error; 260 | await request.save(); 261 | return res.status(400).json({ 262 | error: 'Invalid build provided, check the CFA dashboard for reasoning.', 263 | }); 264 | } 265 | 266 | if ( 267 | allowedState.needsLogBasedProof && 268 | !(await requester.validateProofForRequest(request, config)) 269 | ) { 270 | request.state = 'error'; 271 | request.errored = new Date(); 272 | request.errorReason = 273 | 'Failed to validate build. Could not find the proof in the build logs in an adaquete time period.'; 274 | await request.save(); 275 | return res.status(403).json({ 276 | error: 'Failed to validate the build, check the CFA dashboard for reasoning.', 277 | }); 278 | } 279 | 280 | request.state = 'validated'; 281 | request.validated = new Date(); 282 | await request.save(); 283 | 284 | // Fetch just the request with no additional info **before** the responder adds its 285 | // metadata 286 | const newRequest = (await OTPRequest.findByPk(request.id))!; 287 | 288 | await getResponderFor(request.project).requestOtp( 289 | request, 290 | await requester.getRequestInformationToPassOn(request), 291 | ); 292 | 293 | res.json(newRequest); 294 | }, 295 | ), 296 | ); 297 | 298 | router.post( 299 | `/:projectId/${requester.slug}/:requestId`, 300 | validate( 301 | { 302 | a, 303 | params: { 304 | projectId: Joi.number().integer().required(), 305 | requestId: Joi.string().uuid({ version: 'uuidv4' }).required(), 306 | }, 307 | }, 308 | async (req, res) => { 309 | const request = await OTPRequest.findOne({ 310 | where: { 311 | id: req.params.requestId, 312 | projectId: req.params.projectId, 313 | }, 314 | }); 315 | if (!request) 316 | return res 317 | .status(404) 318 | .json({ error: 'That request does not exist or you do not have permission to see it' }); 319 | if (request.state !== 'responded') 320 | return res.status(204).json({ error: 'That request does not have a response yet' }); 321 | res.json(request); 322 | }, 323 | ), 324 | ); 325 | 326 | return router; 327 | } 328 | -------------------------------------------------------------------------------- /src/server/db/models.ts: -------------------------------------------------------------------------------- 1 | import * as crypto from 'crypto'; 2 | import { 3 | Table, 4 | Column, 5 | Model, 6 | PrimaryKey, 7 | AllowNull, 8 | Sequelize, 9 | DataType, 10 | BelongsTo, 11 | Default, 12 | ForeignKey, 13 | Unique, 14 | } from 'sequelize-typescript'; 15 | import { 16 | CreationOptional, 17 | InferAttributes, 18 | InferCreationAttributes, 19 | QueryInterface, 20 | Transaction, 21 | } from 'sequelize'; 22 | import * as url from 'url'; 23 | 24 | const tableOptions = { freezeTableName: true, timestamps: false }; 25 | 26 | @Table(tableOptions) 27 | export class Project extends Model, InferCreationAttributes> { 28 | /** 29 | * Project ID maps to GitHub repository id 30 | */ 31 | @PrimaryKey 32 | @Column(DataType.BIGINT) 33 | id: string; 34 | 35 | /** 36 | * Owner login (first half of repo slug) 37 | */ 38 | @AllowNull(false) 39 | @Column(DataType.STRING) 40 | repoOwner: string; 41 | 42 | /** 43 | * Name of GH repository (second half of repo slug) 44 | */ 45 | @AllowNull(false) 46 | @Column(DataType.STRING) 47 | repoName: string; 48 | 49 | /** 50 | * When false this project has been "deleted" and should be completely 51 | * ignored. No incoming our outgoing signals and it should not appear in 52 | * the UI until it is "created" again. 53 | */ 54 | @AllowNull(false) 55 | @Default(true) 56 | @Column(DataType.BOOLEAN) 57 | enabled: CreationOptional; 58 | 59 | @AllowNull(false) 60 | @Column(DataType.STRING({ length: 256 })) 61 | secret: string; 62 | 63 | @AllowNull(false) 64 | @Column(DataType.TEXT) 65 | defaultBranch: string; 66 | 67 | @BelongsTo(() => CircleCIRequesterConfig, 'requester_circleCI_id') 68 | requester_circleCI: CircleCIRequesterConfig | null; 69 | requester_circleCI_id: string | null; 70 | 71 | @BelongsTo(() => GitHubActionsRequesterConfig, 'requester_gitHub_id') 72 | requester_gitHub: GitHubActionsRequesterConfig | null; 73 | requester_gitHub_id: string | null; 74 | 75 | @BelongsTo(() => SlackResponderConfig, 'responder_slack_id') 76 | responder_slack: SlackResponderConfig | null; 77 | responder_slack_id: string | null; 78 | 79 | public async resetAllRequesters(t: Transaction) { 80 | this.requester_circleCI_id = null; 81 | this.requester_gitHub_id = null; 82 | await this.save({ transaction: t }); 83 | if (this.requester_circleCI) { 84 | await this.requester_circleCI.destroy({ transaction: t }); 85 | } 86 | if (this.requester_gitHub) { 87 | await this.requester_gitHub.destroy({ transaction: t }); 88 | } 89 | } 90 | 91 | public async resetAllResponders(t: Transaction) { 92 | this.responder_slack_id = null; 93 | await this.save({ transaction: t }); 94 | if (this.responder_slack) { 95 | await this.responder_slack.destroy({ transaction: t }); 96 | } 97 | } 98 | 99 | static get allIncludes() { 100 | return [CircleCIRequesterConfig, GitHubActionsRequesterConfig, SlackResponderConfig]; 101 | } 102 | } 103 | 104 | @Table(tableOptions) 105 | export class GitHubActionsRequesterConfig extends Model< 106 | InferAttributes, 107 | InferCreationAttributes 108 | > { 109 | @PrimaryKey 110 | @Default(DataType.UUIDV4) 111 | @Column(DataType.UUID) 112 | id: CreationOptional; 113 | } 114 | 115 | @Table(tableOptions) 116 | export class CircleCIRequesterConfig extends Model< 117 | InferAttributes, 118 | InferCreationAttributes 119 | > { 120 | @PrimaryKey 121 | @Default(DataType.UUIDV4) 122 | @Column(DataType.UUID) 123 | id: CreationOptional; 124 | 125 | @AllowNull(false) 126 | @Column(DataType.STRING) 127 | accessToken: string; 128 | } 129 | 130 | @Table(tableOptions) 131 | export class SlackResponderConfig extends Model< 132 | InferAttributes, 133 | InferCreationAttributes 134 | > { 135 | @PrimaryKey 136 | @Default(DataType.UUIDV4) 137 | @Column(DataType.UUID) 138 | id: CreationOptional; 139 | 140 | @AllowNull(false) 141 | @Column(DataType.STRING) 142 | teamName: string; 143 | 144 | @AllowNull(false) 145 | @Column(DataType.STRING) 146 | teamId: string; 147 | 148 | @AllowNull(false) 149 | @Column(DataType.TEXT) 150 | teamIcon: string; 151 | 152 | @AllowNull(false) 153 | @Column(DataType.STRING) 154 | channelName: string; 155 | 156 | @AllowNull(false) 157 | @Column(DataType.STRING) 158 | channelId: string; 159 | 160 | @AllowNull(false) 161 | @Column(DataType.STRING) 162 | enterpriseId: string; 163 | 164 | @AllowNull(false) 165 | @Column(DataType.STRING) 166 | usernameToMention: string; 167 | } 168 | 169 | /** 170 | * Used as a middle-table to create a SlackResponderConfig 171 | */ 172 | @Table(tableOptions) 173 | export class SlackResponderLinker extends Model< 174 | InferAttributes, 175 | InferCreationAttributes 176 | > { 177 | @PrimaryKey 178 | @Default(DataType.UUIDV4) 179 | @Column(DataType.UUID) 180 | id: CreationOptional; 181 | 182 | @AllowNull(false) 183 | @ForeignKey(() => Project) 184 | @Column(DataType.BIGINT) 185 | projectId: string; 186 | 187 | @BelongsTo(() => Project, 'projectId') 188 | project: CreationOptional; 189 | } 190 | 191 | @Table(tableOptions) 192 | export class SlackInstall extends Model< 193 | InferAttributes, 194 | InferCreationAttributes 195 | > { 196 | @PrimaryKey 197 | @Default(DataType.UUIDV4) 198 | @Column(DataType.UUID) 199 | id: CreationOptional; 200 | 201 | @AllowNull(false) 202 | @Column(DataType.STRING) 203 | botToken: string; 204 | 205 | @AllowNull(false) 206 | @Column(DataType.STRING) 207 | botId: string; 208 | 209 | @AllowNull(false) 210 | @Column(DataType.STRING) 211 | botUserId: string; 212 | 213 | @AllowNull(false) 214 | @Unique('slack_team') 215 | @Column(DataType.STRING) 216 | teamId: string; 217 | 218 | @AllowNull(false) 219 | @Unique('slack_team') 220 | @Column(DataType.STRING) 221 | enterpriseId: string; 222 | } 223 | 224 | @Table(tableOptions) 225 | export class OTPRequest extends Model< 226 | InferAttributes>, 227 | InferCreationAttributes> 228 | > { 229 | static generateProof() { 230 | return crypto.randomBytes(2048).toString('hex').toLowerCase(); 231 | } 232 | @PrimaryKey 233 | @Default(DataType.UUIDV4) 234 | @Column(DataType.UUID) 235 | id: CreationOptional; 236 | 237 | /** 238 | * The project the OTP request is for 239 | */ 240 | @AllowNull(false) 241 | @ForeignKey(() => Project) 242 | @Column(DataType.BIGINT) 243 | projectId: string; 244 | 245 | @AllowNull(false) 246 | @Column(DataType.ENUM({ values: ['requested', 'validated', 'responded', 'error'] })) 247 | state: 'requested' | 'validated' | 'responded' | 'error'; 248 | 249 | @AllowNull(false) 250 | @Column(DataType.TEXT) 251 | proof: string; 252 | 253 | /** 254 | * The human-provided response from a Responder 255 | */ 256 | @AllowNull(true) 257 | @Column(DataType.TEXT) 258 | response: string | null; 259 | 260 | @AllowNull(true) 261 | @Column(DataType.TEXT) 262 | errorReason: string | null; 263 | 264 | /** 265 | * The time this request was, well... requested 266 | */ 267 | @AllowNull(false) 268 | @Column(DataType.DATE) 269 | requested: Date; 270 | 271 | /** 272 | * The time this request was validated by CFA 273 | */ 274 | @AllowNull(true) 275 | @Column(DataType.DATE) 276 | validated: Date | null; 277 | 278 | /** 279 | * The time this request was responded to by a Responder 280 | */ 281 | @AllowNull(true) 282 | @Column(DataType.DATE) 283 | responded: Date | null; 284 | 285 | @AllowNull(true) 286 | @Column(DataType.TEXT) 287 | userThatResponded: string | null; 288 | 289 | /** 290 | * The time this request encountered an error (hopefully never but hey) 291 | */ 292 | @AllowNull(true) 293 | @Column(DataType.DATE) 294 | errored: Date | null; 295 | 296 | @AllowNull(false) 297 | @Column(DataType.JSON) 298 | requestMetadata: Req; 299 | 300 | @AllowNull(false) 301 | @Column(DataType.JSON) 302 | responseMetadata: Res; 303 | 304 | @BelongsTo(() => Project, 'projectId') 305 | project: CreationOptional; 306 | } 307 | 308 | const migrationFns: ((t: Transaction, qI: QueryInterface) => Promise)[] = [ 309 | async function addUserThatRespondedAttribute(t: Transaction, queryInterface: QueryInterface) { 310 | const table: any = await queryInterface.describeTable(OTPRequest.getTableName()); 311 | if (!table.userThatResponded) { 312 | await queryInterface.addColumn( 313 | OTPRequest.getTableName() as string, 314 | 'userThatResponded', 315 | { 316 | type: DataType.TEXT, 317 | allowNull: true, 318 | }, 319 | { 320 | transaction: t, 321 | }, 322 | ); 323 | } 324 | }, 325 | async function addProjectDefaultBranchAttribute(t: Transaction, queryInterface: QueryInterface) { 326 | const table: any = await queryInterface.describeTable(Project.getTableName()); 327 | if (!table.defaultBranch) { 328 | await queryInterface.addColumn( 329 | Project.getTableName() as string, 330 | 'defaultBranch', 331 | { 332 | type: DataType.TEXT, 333 | allowNull: false, 334 | defaultValue: 'master', 335 | }, 336 | { 337 | transaction: t, 338 | }, 339 | ); 340 | } 341 | }, 342 | async function addGitHubActionsRequester(t: Transaction, queryInterface: QueryInterface) { 343 | const table: any = await queryInterface.describeTable(Project.getTableName()); 344 | if (!table.requester_gitHub_id) { 345 | await queryInterface.addColumn( 346 | Project.getTableName() as string, 347 | 'requester_gitHub_id', 348 | { 349 | type: DataType.UUID, 350 | allowNull: true, 351 | defaultValue: null, 352 | }, 353 | { 354 | transaction: t, 355 | }, 356 | ); 357 | } 358 | }, 359 | ]; 360 | 361 | const initializeInstance = async (sequelize: Sequelize) => { 362 | sequelize.addModels([ 363 | Project, 364 | CircleCIRequesterConfig, 365 | GitHubActionsRequesterConfig, 366 | SlackResponderConfig, 367 | SlackResponderLinker, 368 | OTPRequest, 369 | SlackInstall, 370 | ]); 371 | 372 | await sequelize.sync(); 373 | 374 | for (const migrationFn of migrationFns) { 375 | await sequelize.transaction(async (t) => { 376 | await migrationFn(t, sequelize.getQueryInterface()); 377 | }); 378 | } 379 | }; 380 | 381 | const create = async () => { 382 | const parsed = url.parse(process.env.DATABASE_URL!); 383 | const sequelize = new Sequelize({ 384 | dialect: 'postgres', 385 | database: parsed.pathname!.slice(1), 386 | username: parsed.auth!.split(':')[0], 387 | password: parsed.auth!.split(':')[1], 388 | host: parsed.hostname!, 389 | port: parseInt(parsed.port!, 10), 390 | ssl: process.env.NO_DB_SSL ? false : true, 391 | pool: { 392 | max: 20, 393 | min: 0, 394 | idle: 10000, 395 | }, 396 | dialectOptions: { 397 | ssl: process.env.NO_DB_SSL 398 | ? false 399 | : { 400 | rejectUnauthorized: false, 401 | }, 402 | }, 403 | }); 404 | await initializeInstance(sequelize); 405 | 406 | return sequelize; 407 | }; 408 | 409 | let instance: Sequelize; 410 | export const getSequelizeInstance = async () => { 411 | if (!instance) instance = await create(); 412 | return instance; 413 | }; 414 | 415 | export const __overrideSequelizeInstanceForTesting = async (_instance: Sequelize) => { 416 | instance = _instance; 417 | await initializeInstance(instance); 418 | }; 419 | 420 | export const withTransaction = async (fn: (t: Transaction) => Promise) => { 421 | const instance = await getSequelizeInstance(); 422 | return instance.transaction(async (t) => { 423 | return await fn(t); 424 | }); 425 | }; 426 | -------------------------------------------------------------------------------- /src/server/helpers/__tests__/a.spec.ts: -------------------------------------------------------------------------------- 1 | import * as debug from 'debug'; 2 | import { Request } from 'jest-express/lib/request'; 3 | import { Response } from 'jest-express/lib/response'; 4 | 5 | import { createA } from '../a'; 6 | 7 | describe('createA', () => { 8 | it('should return a function', () => { 9 | expect(typeof createA(null as any)).toBe('function'); 10 | }); 11 | 12 | describe('a', () => { 13 | let d: debug.Debugger; 14 | let a: ReturnType; 15 | 16 | beforeEach(() => { 17 | d = debug('cfa:test'); 18 | a = createA(d); 19 | }); 20 | 21 | it('should be a function', () => { 22 | expect(typeof a).toBe('function'); 23 | }); 24 | 25 | it('should return a function', () => { 26 | expect(typeof a(null as any)).toBe('function'); 27 | }); 28 | 29 | it('should do nothing if the handler exits cleanly', async () => { 30 | const fakeReq = new Request(); 31 | const fakeRes = new Response(); 32 | await a(async (req, res) => { 33 | res.json(123); 34 | })(fakeReq as any, fakeRes as any, null as any); 35 | expect(fakeRes.status).not.toBeCalled(); 36 | expect(fakeRes.json).toBeCalledWith(123); 37 | }); 38 | 39 | it('should send a 500 if the handle fails', async () => { 40 | const fakeReq = new Request(); 41 | const fakeRes = new Response(); 42 | await a(async () => { 43 | throw 'whoops'; 44 | })(fakeReq as any, fakeRes as any, null as any); 45 | expect(fakeRes.status).toHaveBeenCalledWith(500); 46 | expect(fakeRes.json).toHaveBeenCalled(); 47 | }); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /src/server/helpers/__tests__/middleware.spec.ts: -------------------------------------------------------------------------------- 1 | let octokitRequest: jest.Mock; 2 | 3 | jest.mock('@octokit/rest', () => { 4 | class FakeOctokit { 5 | request = octokitRequest; 6 | } 7 | return { 8 | Octokit: FakeOctokit, 9 | }; 10 | }); 11 | 12 | import * as bodyParser from 'body-parser'; 13 | import * as express from 'express'; 14 | import * as Joi from 'joi'; 15 | import * as request from 'supertest'; 16 | import { createA } from '../a'; 17 | import { validate, requireLogin, hasAdminAccessToTargetRepo, ExpressRequest } from '../_middleware'; 18 | import { Request } from 'jest-express/lib/request'; 19 | import { Response } from 'jest-express/lib/response'; 20 | 21 | const successHandler = (req, res) => res.status(321).json({ very: 'Good' }); 22 | 23 | describe('validate middleware', () => { 24 | let debug: jest.Mock; 25 | let a: ReturnType; 26 | let router: express.Router; 27 | 28 | beforeEach(() => { 29 | debug = jest.fn(); 30 | a = createA(debug as any); 31 | router = express(); 32 | router.use(bodyParser.json()); 33 | }); 34 | 35 | it('should log errors with the provided "a"', async () => { 36 | router.use( 37 | validate( 38 | { 39 | a, 40 | }, 41 | () => { 42 | throw new Error('whoops'); 43 | }, 44 | ), 45 | ); 46 | const response = await request(router).post('/'); 47 | expect(debug).toHaveBeenCalled(); 48 | expect(response.status).toBe(500); 49 | }); 50 | 51 | it('should fail with 400 if the body does not match provided requirements', async () => { 52 | router.use( 53 | validate( 54 | { 55 | a, 56 | body: { 57 | foo: Joi.string().required(), 58 | }, 59 | }, 60 | successHandler, 61 | ), 62 | ); 63 | const response = await request(router).post('/').send({ bad: true }); 64 | expect(response.status).toBe(400); 65 | expect(response.body).toMatchInlineSnapshot(` 66 | { 67 | "error": "Body Validation Error", 68 | "message": "child "foo" fails because ["foo" is required]", 69 | } 70 | `); 71 | }); 72 | 73 | it('should fail with 400 if the query does not match provided requirements', async () => { 74 | router.use( 75 | validate( 76 | { 77 | a, 78 | query: { 79 | foo: Joi.string().required(), 80 | }, 81 | }, 82 | successHandler, 83 | ), 84 | ); 85 | const response = await request(router).post('/foo?a=1'); 86 | expect(response.status).toBe(400); 87 | expect(response.body).toMatchInlineSnapshot(` 88 | { 89 | "error": "Query Validation Error", 90 | "message": "child "foo" fails because ["foo" is required]", 91 | } 92 | `); 93 | }); 94 | 95 | it('should fail with 400 if the params do not match provided requirements', async () => { 96 | router.use( 97 | '/:foo', 98 | validate( 99 | { 100 | a, 101 | params: { 102 | foo: Joi.number().required(), 103 | }, 104 | }, 105 | successHandler, 106 | ), 107 | ); 108 | const response = await request(router).post('/not-a-number'); 109 | expect(response.status).toBe(400); 110 | expect(response.body).toMatchInlineSnapshot(` 111 | { 112 | "error": "Params Validation Error", 113 | "message": "child "foo" fails because ["foo" must be a number]", 114 | } 115 | `); 116 | }); 117 | 118 | it('should should passthrough to the handler if all validation succeeds', async () => { 119 | router.use( 120 | '/:thing', 121 | validate( 122 | { 123 | a, 124 | body: { 125 | foo: Joi.string().required(), 126 | }, 127 | query: { 128 | bar: Joi.string().required(), 129 | }, 130 | params: { 131 | thing: Joi.string().required(), 132 | }, 133 | }, 134 | successHandler, 135 | ), 136 | ); 137 | const response = await request(router).post('/foo?bar=1').send({ foo: 'hey' }); 138 | expect(response.status).toBe(321); 139 | expect(response.body).toMatchInlineSnapshot(` 140 | { 141 | "very": "Good", 142 | } 143 | `); 144 | }); 145 | }); 146 | 147 | describe('requireLogin middleware', () => { 148 | it('should pass through if the user is signed in', () => { 149 | const req = new Request(); 150 | (req as any).user = {}; 151 | const res = new Response(); 152 | const next = jest.fn(); 153 | requireLogin(req as any, res as any, next); 154 | expect(next).toHaveBeenCalled(); 155 | }); 156 | 157 | it('should return a 403 if the user is not signed in', () => { 158 | const req = new Request(); 159 | const res = new Response(); 160 | req.method = 'post'; 161 | const next = jest.fn(); 162 | requireLogin(req as any, res as any, next); 163 | expect(next).not.toHaveBeenCalled(); 164 | expect(res.status).toHaveBeenCalledWith(403); 165 | }); 166 | }); 167 | 168 | describe('hasAdminAccessToTargetRepo middleware', () => { 169 | let req: Request; 170 | let _req: ExpressRequest; 171 | beforeEach(() => { 172 | req = new Request(); 173 | _req = req as any; 174 | 175 | octokitRequest = jest.fn(); 176 | }); 177 | 178 | it('should return false if the repo provided is falsey', async () => { 179 | const value = await hasAdminAccessToTargetRepo(_req, '0'); 180 | expect(value).toBe(false); 181 | }); 182 | 183 | it('should return false if the request is not from an authenticated user', async () => { 184 | const value = await hasAdminAccessToTargetRepo(_req, '1'); 185 | expect(value).toBe(false); 186 | }); 187 | 188 | it('should return false if github returns a non-ok status code', async () => { 189 | (req as any).user = { 190 | profile: { 191 | username: 'my-user', 192 | }, 193 | }; 194 | octokitRequest.mockImplementation((url, params) => { 195 | expect(url).toMatchInlineSnapshot( 196 | `"GET /repositories/:id/collaborators/:username/permission"`, 197 | ); 198 | expect(params).toStrictEqual({ 199 | id: 1, 200 | username: 'my-user', 201 | }); 202 | return { 203 | status: 404, 204 | }; 205 | }); 206 | const value = await hasAdminAccessToTargetRepo(_req, '1'); 207 | expect(value).toBe(false); 208 | }); 209 | 210 | it('should return false if the github request fails', async () => { 211 | (req as any).user = { 212 | profile: { 213 | username: 'my-user', 214 | }, 215 | }; 216 | octokitRequest.mockImplementation((url, params) => { 217 | throw new Error('wut'); 218 | }); 219 | const value = await hasAdminAccessToTargetRepo(_req, '1'); 220 | expect(value).toBe(false); 221 | }); 222 | 223 | it('should return false if github returns a a non-admin permission level', async () => { 224 | (req as any).user = { 225 | profile: { 226 | username: 'my-user', 227 | }, 228 | }; 229 | octokitRequest.mockImplementation((url, params) => { 230 | return { 231 | status: 200, 232 | data: { 233 | permission: 'write', 234 | }, 235 | }; 236 | }); 237 | const value = await hasAdminAccessToTargetRepo(_req, '1'); 238 | expect(value).toBe(false); 239 | }); 240 | 241 | it('should return true if github returns the admin permission level', async () => { 242 | (req as any).user = { 243 | profile: { 244 | username: 'my-user', 245 | }, 246 | }; 247 | octokitRequest.mockImplementation((url, params) => { 248 | return { 249 | status: 200, 250 | data: { 251 | permission: 'admin', 252 | }, 253 | }; 254 | }); 255 | const value = await hasAdminAccessToTargetRepo(_req, '1'); 256 | expect(value).toBe(true); 257 | }); 258 | }); 259 | -------------------------------------------------------------------------------- /src/server/helpers/_middleware.ts: -------------------------------------------------------------------------------- 1 | import { Octokit } from '@octokit/rest'; 2 | import * as debug from 'debug'; 3 | import * as express from 'express'; 4 | import * as Joi from 'joi'; 5 | 6 | import { createA } from './a'; 7 | import './_joi_extract'; 8 | import { User as CFAUser } from '../../common/types'; 9 | 10 | const d = debug('cfa:server:helpers'); 11 | 12 | export interface ExpressRequest 13 | extends Omit { 14 | ctx: {} & CTX; 15 | body: unknown; 16 | query: unknown; 17 | params: unknown; 18 | } 19 | 20 | declare global { 21 | namespace Express { 22 | interface User { 23 | accessToken: string; 24 | profile: CFAUser; 25 | } 26 | } 27 | } 28 | 29 | export const requireLogin = (req: ExpressRequest, res: express.Response, next: Function) => { 30 | if (!req.user) { 31 | if (req.method.toLowerCase() !== 'get') { 32 | d(`Unauthenticated user attempted to access: ${req.originalUrl}`); 33 | } 34 | res.setHeader('x-cfa-403-reason', 'no-auth'); 35 | return res.status(403).json({ error: 'Forbidden' }); 36 | } 37 | next(); 38 | }; 39 | 40 | export const hasAdminAccessToTargetRepo = async ( 41 | req: ExpressRequest, 42 | repoId: string, 43 | ): Promise => { 44 | if (!repoId) return false; 45 | if (!req.user) return false; 46 | 47 | const github = new Octokit({ 48 | auth: req.user.accessToken, 49 | }); 50 | 51 | try { 52 | const body = await github.request('GET /repositories/:id/collaborators/:username/permission', { 53 | id: repoId, 54 | username: req.user.profile.username, 55 | }); 56 | 57 | if (body.status !== 200) return false; 58 | if (body.data.permission === 'admin') return true; 59 | } catch (err) { 60 | d('failed to request permission level from github', err); 61 | return false; 62 | } 63 | 64 | return false; 65 | }; 66 | 67 | export interface ValidationOptionsObject { 68 | a: ReturnType; 69 | query?: Joi.SchemaMap; 70 | body?: Joi.SchemaMap; 71 | params?: Joi.SchemaMap; 72 | } 73 | 74 | export type ValidatedObject = S extends undefined 75 | ? {} 76 | : Joi.extractType; 77 | 78 | export type AllStrings = { [P in keyof T]: string }; 79 | 80 | export type ValidatedRequest = Pick< 81 | ExpressRequest, 82 | Exclude 83 | > & { 84 | body: ValidatedObject>; 85 | query: AllStrings>>; 86 | params: AllStrings>>; 87 | [Symbol.asyncIterator](): AsyncIterableIterator; 88 | }; 89 | 90 | export const validate = 91 | ( 92 | options: V, 93 | handler: (req: ValidatedRequest, res: express.Response, next: express.NextFunction) => void, 94 | ): express.RequestHandler => 95 | (req: ExpressRequest, res, next) => { 96 | if (options.body) { 97 | const result = Joi.validate(req.body, options.body); 98 | if (result.error) { 99 | return res.status(400).json({ 100 | error: 'Body Validation Error', 101 | message: result.error.message, 102 | }); 103 | } 104 | } 105 | 106 | if (options.query) { 107 | const result = Joi.validate(req.query, options.query); 108 | if (result.error) { 109 | return res.status(400).json({ 110 | error: 'Query Validation Error', 111 | message: result.error.message, 112 | }); 113 | } 114 | } 115 | 116 | if (options.params) { 117 | const result = Joi.validate(req.params, Joi.object(options.params).unknown(true)); 118 | if (result.error) { 119 | return res.status(400).json({ 120 | error: 'Params Validation Error', 121 | message: result.error.message, 122 | }); 123 | } 124 | } 125 | 126 | return options.a(handler)(req as any, res, next); 127 | }; 128 | -------------------------------------------------------------------------------- /src/server/helpers/a.ts: -------------------------------------------------------------------------------- 1 | import * as debug from 'debug'; 2 | import * as express from 'express'; 3 | import { ExpressRequest } from './_middleware'; 4 | 5 | export const createA = 6 | (d: debug.IDebugger) => 7 | (handler: (req: express.Request, res: express.Response, next: express.NextFunction) => any) => 8 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { 9 | try { 10 | await Promise.resolve(handler(req, res, next)); 11 | } catch (err) { 12 | d(`Unhandled error: ${req.originalUrl}`); 13 | d(err); 14 | res.status(500).json({ error: 'Something went wrong...' }); 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /src/server/helpers/auth.ts: -------------------------------------------------------------------------------- 1 | import { StrategyOptions, createAppAuth } from '@octokit/auth-app'; 2 | import { Octokit } from '@octokit/rest'; 3 | 4 | import { Project } from '../db/models'; 5 | import { Permissions } from '@octokit/auth-app/dist-types/types'; 6 | 7 | export const getGitHubAppInstallationToken = async (project: Project, permissions: Permissions) => { 8 | const appCredentials = { 9 | appId: process.env.GITHUB_APP_ID!, 10 | privateKey: process.env.GITHUB_PRIVATE_KEY!, 11 | }; 12 | 13 | const appOctokit = new Octokit({ 14 | authStrategy: createAppAuth, 15 | auth: { 16 | ...appCredentials, 17 | }, 18 | }); 19 | 20 | const installation = await appOctokit.apps.getRepoInstallation({ 21 | owner: project.repoOwner, 22 | repo: project.repoName, 23 | }); 24 | 25 | const authOptions = { 26 | type: 'installation', 27 | ...appCredentials, 28 | installationId: installation.data.id, 29 | repositoryNames: [project.repoName], 30 | permissions, 31 | }; 32 | const { token } = await createAppAuth(authOptions)(authOptions); 33 | return token; 34 | }; 35 | -------------------------------------------------------------------------------- /src/server/helpers/oidc.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import * as jwt from 'jsonwebtoken'; 3 | import * as jwkToPem from 'jwk-to-pem'; 4 | 5 | import { Project } from '../db/models'; 6 | import { Requester } from '../requesters/Requester'; 7 | import { Issuer } from 'openid-client'; 8 | 9 | export const getSignatureValidatedOIDCClaims = async ( 10 | requester: Requester, 11 | project: Project, 12 | config: R, 13 | token: string, 14 | ): Promise => { 15 | const discoveryUrl = await requester.getOpenIDConnectDiscoveryURL(project, config); 16 | if (!discoveryUrl) throw 'Project is not eligible for OIDC credential exchange'; 17 | const issuer = await Issuer.discover(discoveryUrl); 18 | 19 | if (!issuer.metadata.jwks_uri) 20 | throw 'Project is not eligible for JWKS backed OIDC credential exchange'; 21 | const jwks = await axios.get(issuer.metadata.jwks_uri); 22 | 23 | if (jwks.status !== 200) throw 'Project is not eligible for JWKS backed OIDC credential exchange'; 24 | 25 | let claims = jwt.decode(token, { complete: true }) as jwt.Jwt | null; 26 | if (!claims) throw 'Invalid OIDC token provided'; 27 | const key = jwks.data.keys.find((key) => key.kid === claims!.header.kid); 28 | 29 | if (!key) throw 'Invalid kid found in the token provided'; 30 | 31 | const pem = jwkToPem(key); 32 | try { 33 | claims = jwt.verify(token, pem, { complete: true, algorithms: [key.alg] }) as jwt.Jwt | null; 34 | } catch { 35 | throw 'Could not verify the provided token against the OIDC provider'; 36 | } 37 | return claims; 38 | }; 39 | -------------------------------------------------------------------------------- /src/server/index.ts: -------------------------------------------------------------------------------- 1 | import { app } from './server'; 2 | 3 | import * as Sentry from '@sentry/node'; 4 | import * as bodyParser from 'body-parser'; 5 | import * as compression from 'compression'; 6 | import * as debug from 'debug'; 7 | import * as express from 'express'; 8 | import * as session from 'express-session'; 9 | import RedisStore from 'connect-redis'; 10 | import * as path from 'path'; 11 | import * as redis from 'redis'; 12 | 13 | import { apiRoutes } from './api'; 14 | import passport = require('passport'); 15 | import { getSequelizeInstance } from './db/models'; 16 | 17 | const d = debug('cfa:server'); 18 | 19 | app.use(compression()); 20 | 21 | const REDIS_URL = process.env.REDIS_URL!; 22 | 23 | const redisClient = redis.createClient({ 24 | url: REDIS_URL, 25 | socket: process.env.NO_DB_SSL 26 | ? undefined 27 | : { 28 | tls: true, 29 | rejectUnauthorized: false, 30 | }, 31 | }); 32 | redisClient.connect().catch((err) => { 33 | console.error(err); 34 | process.exit(1); 35 | }); 36 | redisClient.unref(); 37 | redisClient.on('error', console.error); 38 | 39 | app.use( 40 | '/api', 41 | bodyParser.json(), 42 | session({ 43 | store: new RedisStore({ 44 | client: redisClient, 45 | }), 46 | secret: process.env.SESSION_SECRET!, 47 | resave: false, 48 | saveUninitialized: true, 49 | name: 'cfa.session', 50 | }), 51 | passport.initialize(), 52 | passport.session(), 53 | apiRoutes(), 54 | ); 55 | 56 | // Static hosting 57 | const staticRoot = path.resolve(__dirname, '../../public_out'); 58 | app.use( 59 | express.static(staticRoot, { 60 | index: false, 61 | }), 62 | ); 63 | app.use( 64 | require('express-history-api-fallback')('index.html', { 65 | root: staticRoot, 66 | }), 67 | ); 68 | 69 | if (process.mainModule === module) { 70 | const port = process.env.PORT || 3001; 71 | d('booting CFA'); 72 | getSequelizeInstance() 73 | .then(() => { 74 | if (process.env.SENTRY_DSN) { 75 | app.use(Sentry.Handlers.errorHandler()); 76 | } 77 | 78 | app.listen(port, () => { 79 | d('CFA server running on port:', port); 80 | }); 81 | }) 82 | .catch((err) => { 83 | d('Failed to connect to DB', err); 84 | process.exit(1); 85 | }); 86 | } 87 | -------------------------------------------------------------------------------- /src/server/requesters/CircleCIRequester.ts: -------------------------------------------------------------------------------- 1 | import { Octokit } from '@octokit/rest'; 2 | import axios from 'axios'; 3 | import { Request, Response } from 'express'; 4 | import * as Joi from 'joi'; 5 | import type * as jwt from 'jsonwebtoken'; 6 | 7 | import { Requester, AllowedState } from './Requester'; 8 | import { Project, CircleCIRequesterConfig, OTPRequest } from '../db/models'; 9 | import { getGitHubAppInstallationToken } from '../helpers/auth'; 10 | import { RequestInformation } from '../responders/Responder'; 11 | 12 | export type CircleCIOTPRequestMetadata = { 13 | buildNumber: number; 14 | }; 15 | 16 | export const getAxiosForConfig = (config: CircleCIRequesterConfig) => 17 | axios.create({ 18 | baseURL: 'https://circleci.com/api/v1.1', 19 | auth: { 20 | username: config.accessToken, 21 | password: '', 22 | }, 23 | validateStatus: () => true, 24 | }); 25 | 26 | // Unauthenticated 27 | export const getAxiosForConfigV2 = (config: CircleCIRequesterConfig) => 28 | axios.create({ 29 | baseURL: 'https://circleci.com/api/v2', 30 | validateStatus: () => true, 31 | }); 32 | 33 | const validateMetadataObject = (object: any) => { 34 | return Joi.validate(object, { 35 | buildNumber: Joi.number().min(1).integer().required(), 36 | }); 37 | }; 38 | 39 | export class CircleCIRequester 40 | implements Requester 41 | { 42 | readonly slug = 'circleci'; 43 | 44 | getConfigForProject(project: Project) { 45 | return project.requester_circleCI || null; 46 | } 47 | 48 | async getOpenIDConnectDiscoveryURL(project: Project, config: CircleCIRequesterConfig) { 49 | const projectResponse = await getAxiosForConfigV2(config).get( 50 | `/project/gh/${project.repoOwner}/${project.repoName}`, 51 | ); 52 | 53 | if (projectResponse.status !== 200) { 54 | return null; 55 | } 56 | 57 | const orgId = projectResponse.data.organization_id; 58 | return `https://oidc.circleci.com/org/${orgId}`; 59 | } 60 | 61 | async doOpenIDConnectClaimsMatchProject( 62 | claims: jwt.JwtPayload, 63 | project: Project, 64 | config: CircleCIRequesterConfig, 65 | ) { 66 | const projectResponse = await getAxiosForConfigV2(config).get( 67 | `/project/gh/${project.repoOwner}/${project.repoName}`, 68 | ); 69 | 70 | if (projectResponse.status !== 200) { 71 | return false; 72 | } 73 | 74 | return projectResponse.data.id === claims['oidc.circleci.com/project-id']; 75 | } 76 | 77 | async metadataForInitialRequest( 78 | req: Request, 79 | res: Response, 80 | ): Promise { 81 | const result = validateMetadataObject(req.body); 82 | if (result.error) { 83 | res.status(400).json({ 84 | error: 'Request Validation Error', 85 | message: result.error.message, 86 | }); 87 | return null; 88 | } 89 | 90 | return { 91 | buildNumber: result.value.buildNumber, 92 | }; 93 | } 94 | 95 | async validateActiveRequest( 96 | request: OTPRequest, 97 | config: CircleCIRequesterConfig, 98 | ): Promise { 99 | const { project } = request; 100 | const buildResponse = await getAxiosForConfig(config).get( 101 | `/project/github/${project.repoOwner}/${project.repoName}/${request.requestMetadata.buildNumber}`, 102 | ); 103 | // Build clearly does not exist 104 | if (buildResponse.status !== 200) 105 | return { 106 | ok: false, 107 | error: 'CircleCI build does not exist', 108 | }; 109 | 110 | const build = buildResponse.data; 111 | 112 | // Must be on the default branch 113 | if (build.vcs_tag) { 114 | const token = await getGitHubAppInstallationToken(project, { 115 | metadata: 'read', 116 | contents: 'read', 117 | }); 118 | const github = new Octokit({ auth: token }); 119 | 120 | const comparison = await github.repos.compareCommitsWithBasehead({ 121 | owner: project.repoOwner, 122 | repo: project.repoName, 123 | basehead: `${build.vcs_tag}...${project.defaultBranch}`, 124 | }); 125 | 126 | if ( 127 | comparison.status !== 200 || 128 | !(comparison.data.behind_by === 0 && comparison.data.ahead_by >= 0) 129 | ) { 130 | return { 131 | ok: false, 132 | error: 'CircleCI build is for a tag not on the default branch', 133 | }; 134 | } 135 | } else if (build.branch !== project.defaultBranch) { 136 | return { 137 | ok: false, 138 | error: 'CircleCI build is not for the default branch', 139 | }; 140 | } 141 | 142 | // Trigger must be GitHub 143 | if (build.why !== 'github') 144 | return { 145 | ok: false, 146 | error: 'CircleCI build was triggered manually, not by GitHub', 147 | }; 148 | 149 | // Build must be currently running 150 | if (build.status !== 'running') 151 | return { 152 | ok: false, 153 | error: 'CircleCI build is not running', 154 | }; 155 | 156 | // SSH must be disabled for safety 157 | if (!build.ssh_disabled) 158 | return { 159 | ok: false, 160 | error: 'CircleCI build had SSH enabled, this is not allowed', 161 | }; 162 | 163 | return { 164 | ok: true, 165 | needsLogBasedProof: true, 166 | }; 167 | } 168 | 169 | async validateProofForRequest( 170 | request: OTPRequest, 171 | config: CircleCIRequesterConfig, 172 | ): Promise { 173 | const { project, proof } = request; 174 | const { buildNumber } = request.requestMetadata; 175 | 176 | async function attemptToValidateProof(attempts: number): Promise { 177 | if (attempts <= 0) return false; 178 | 179 | const again = async () => { 180 | await new Promise((r) => setTimeout(r, 5000)); 181 | return attemptToValidateProof(attempts - 1); 182 | }; 183 | 184 | const buildUrl = `/project/github/${project.repoOwner}/${project.repoName}/${buildNumber}`; 185 | const buildResponse = await getAxiosForConfig(config).get(buildUrl, { 186 | validateStatus: () => true, 187 | }); 188 | // Build clearly does not exist 189 | if (buildResponse.status !== 200) return again(); 190 | 191 | const build = buildResponse.data; 192 | if (!build.steps || !build.steps.length) return again(); 193 | 194 | const finalStep = build.steps[build.steps.length - 1]; 195 | if (!finalStep || !finalStep.actions.length) return again(); 196 | 197 | const finalAction = finalStep.actions[finalStep.actions.length - 1]; 198 | const outputResponse = await getAxiosForConfig(config).get( 199 | `${buildUrl}/output/${finalAction.step}/${finalAction.index}`, 200 | { 201 | validateStatus: () => true, 202 | }, 203 | ); 204 | // Output clearly does not exist 205 | if (outputResponse.status !== 200) return again(); 206 | 207 | const outputData = outputResponse.data; 208 | if (!outputData.length) return again(); 209 | 210 | const output = outputData[0].message.trim(); 211 | if (new RegExp(`Proof:(\r?\n)${proof}$`).test(output)) return true; 212 | 213 | return again(); 214 | } 215 | 216 | return attemptToValidateProof(3); 217 | } 218 | 219 | async isOTPRequestValidForRequester( 220 | request: OTPRequest, 221 | ): Promise | null> { 222 | const result = validateMetadataObject(request.requestMetadata); 223 | if (result.error) return null; 224 | return request as any; 225 | } 226 | 227 | async getRequestInformationToPassOn( 228 | request: OTPRequest, 229 | ): Promise { 230 | const { project } = request; 231 | 232 | return { 233 | description: `Circle CI Build for ${project.repoOwner}/${project.repoName}#${request.requestMetadata.buildNumber}`, 234 | url: `https://circleci.com/gh/${project.repoOwner}/${project.repoName}/${request.requestMetadata.buildNumber}`, 235 | }; 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /src/server/requesters/GitHubActionsRequester.ts: -------------------------------------------------------------------------------- 1 | import { Octokit } from '@octokit/rest'; 2 | import axios from 'axios'; 3 | import { Request, Response } from 'express'; 4 | import * as Joi from 'joi'; 5 | import * as jwt from 'jsonwebtoken'; 6 | 7 | import { Requester, AllowedState } from './Requester'; 8 | import { Project, GitHubActionsRequesterConfig, OTPRequest } from '../db/models'; 9 | import { getGitHubAppInstallationToken } from '../helpers/auth'; 10 | import { RequestInformation } from '../responders/Responder'; 11 | import { CFA_RELEASE_GITHUB_ENVIRONMENT_NAME } from '../api/project/config'; 12 | import { getSignatureValidatedOIDCClaims } from '../helpers/oidc'; 13 | 14 | type GitHubActionsOTPRequestMetadata = { 15 | oidcToken: string; 16 | buildUrl: string; 17 | }; 18 | 19 | const validateMetadataObject = (object: any) => { 20 | return Joi.validate(object, { 21 | oidcToken: Joi.string().min(1).required(), 22 | buildUrl: Joi.string() 23 | .uri({ 24 | scheme: 'https', 25 | }) 26 | .required(), 27 | }); 28 | }; 29 | 30 | export class GitHubActionsRequester 31 | implements Requester 32 | { 33 | readonly slug = 'github'; 34 | 35 | getConfigForProject(project: Project) { 36 | return project.requester_gitHub || null; 37 | } 38 | 39 | async getOpenIDConnectDiscoveryURL(project: Project, config: GitHubActionsRequesterConfig) { 40 | return 'https://token.actions.githubusercontent.com'; 41 | } 42 | 43 | async doOpenIDConnectClaimsMatchProject( 44 | claims: jwt.JwtPayload, 45 | project: Project, 46 | config: GitHubActionsRequesterConfig, 47 | ) { 48 | const internal = await this.doOpenIDConnectClaimsMatchProjectInternal(claims, project); 49 | if (!internal.ok) { 50 | console.error( 51 | `Failed to match OIDC claims to project(${project.repoOwner}/${project.repoName}):`, 52 | claims, 53 | internal, 54 | ); 55 | } 56 | return internal.ok; 57 | } 58 | 59 | private async doOpenIDConnectClaimsMatchProjectInternal( 60 | claims: jwt.JwtPayload, 61 | project: Project, 62 | ): Promise { 63 | if (claims.aud !== 'continuousauth.dev') { 64 | return { ok: false, error: 'Token audience is not correct' }; 65 | } 66 | // Wrong repository 67 | if (claims.repository_id !== project.id) 68 | return { ok: false, error: 'GitHub Actions build is for incorrect repository id' }; 69 | 70 | // Wrong repository name (probably out of date) 71 | if (claims.repository_owner !== project.repoOwner) 72 | return { ok: false, error: 'GitHub Actions build is for incorrect repository owner' }; 73 | if (claims.repository !== `${project.repoOwner}/${project.repoName}`) 74 | return { ok: false, error: 'GitHub Actions build is for incorrect repository' }; 75 | 76 | // Must be running int he right environment 77 | if ( 78 | claims.sub !== 79 | `repo:${project.repoOwner}/${project.repoName}:environment:${CFA_RELEASE_GITHUB_ENVIRONMENT_NAME}` 80 | ) 81 | return { ok: false, error: 'GitHub Actions build is for incorrect environment' }; 82 | 83 | // Must be on the default branch 84 | if (claims.ref.startsWith('refs/tags/')) { 85 | const token = await getGitHubAppInstallationToken(project, { 86 | metadata: 'read', 87 | contents: 'read', 88 | }); 89 | const github = new Octokit({ auth: token }); 90 | 91 | const comparison = await github.repos.compareCommitsWithBasehead({ 92 | owner: project.repoOwner, 93 | repo: project.repoName, 94 | // Use sha instead of ref here to ensure no malicious race between job start and 95 | // ref re-point 96 | basehead: `${claims.sha}...${project.defaultBranch}`, 97 | }); 98 | 99 | if ( 100 | comparison.status !== 200 || 101 | !(comparison.data.behind_by === 0 && comparison.data.ahead_by >= 0) 102 | ) { 103 | return { 104 | ok: false, 105 | error: 'GitHub Actions build is for a tag not on the default branch', 106 | }; 107 | } 108 | } else if (claims.ref !== `refs/heads/${project.defaultBranch}`) { 109 | return { 110 | ok: false, 111 | error: 'GitHub Actions build is not for the default branch', 112 | }; 113 | } 114 | 115 | // Trigger must be GitHub 116 | // Check event_name must be push 117 | // Some repos use workflow_dispatch, those cases can be handled during migration 118 | // as it requires more though 119 | if (claims.event_name !== 'push') 120 | return { 121 | ok: false, 122 | error: 'GitHub Actions build was triggered by not-a-push', 123 | }; 124 | 125 | // Build must be currently running 126 | // Hit API using claims.run_id, run_number and run_attempt 127 | const token = await getGitHubAppInstallationToken(project, { 128 | metadata: 'read', 129 | contents: 'read', 130 | }); 131 | const github = new Octokit({ auth: token }); 132 | let isStillRunning = false; 133 | try { 134 | const workflowRunAttempt = await github.actions.getWorkflowRunAttempt({ 135 | owner: project.repoOwner, 136 | repo: project.repoName, 137 | run_id: claims.run_id, 138 | attempt_number: claims.run_attempt, 139 | }); 140 | isStillRunning = workflowRunAttempt.data.status === 'in_progress'; 141 | } catch { 142 | isStillRunning = false; 143 | } 144 | if (!isStillRunning) 145 | return { 146 | ok: false, 147 | error: 'GitHub Actions build is not running', 148 | }; 149 | 150 | // SSH must be disabled for safety 151 | // We should be able to check this when GitHub releases actions ssh 152 | // for now just checking we're running on github infra is enough 153 | if (claims.runner_environment !== 'github-hosted') 154 | return { 155 | ok: false, 156 | error: 'GitHub Actions build could have SSH enabled, this is not allowed', 157 | }; 158 | 159 | return { ok: true, needsLogBasedProof: false }; 160 | } 161 | 162 | async metadataForInitialRequest( 163 | req: Request, 164 | res: Response, 165 | ): Promise { 166 | const result = validateMetadataObject(req.body); 167 | if (result.error) { 168 | res.status(400).json({ 169 | error: 'Request Validation Error', 170 | message: result.error.message, 171 | }); 172 | return null; 173 | } 174 | 175 | return { 176 | oidcToken: result.value.oidcToken, 177 | buildUrl: result.value.buildUrl, 178 | }; 179 | } 180 | 181 | async validateActiveRequest( 182 | request: OTPRequest, 183 | config: GitHubActionsRequesterConfig, 184 | ): Promise { 185 | const { project } = request; 186 | 187 | // validate and parse claims from request 188 | let claims: jwt.Jwt | null; 189 | try { 190 | claims = await getSignatureValidatedOIDCClaims( 191 | this, 192 | project, 193 | config, 194 | request.requestMetadata.oidcToken, 195 | ); 196 | } catch (err) { 197 | if (typeof err === 'string') { 198 | return { 199 | ok: false, 200 | error: err, 201 | }; 202 | } 203 | claims = null; 204 | } 205 | 206 | if (!claims) { 207 | return { 208 | ok: false, 209 | error: 'Failed to validate OIDC token', 210 | }; 211 | } 212 | 213 | const claimsEvaluation = await this.doOpenIDConnectClaimsMatchProjectInternal( 214 | claims.payload as jwt.JwtPayload, 215 | project, 216 | ); 217 | if (!claimsEvaluation.ok) { 218 | return { 219 | ok: false, 220 | error: claimsEvaluation.error, 221 | }; 222 | } 223 | 224 | return { 225 | ok: true, 226 | needsLogBasedProof: false, 227 | }; 228 | } 229 | 230 | async validateProofForRequest( 231 | request: OTPRequest, 232 | config: GitHubActionsRequesterConfig, 233 | ): Promise { 234 | // Not needed, default closed 235 | return false; 236 | } 237 | 238 | async isOTPRequestValidForRequester( 239 | request: OTPRequest, 240 | ): Promise | null> { 241 | const result = validateMetadataObject(request.requestMetadata); 242 | if (result.error) return null; 243 | return request as any; 244 | } 245 | 246 | async getRequestInformationToPassOn( 247 | request: OTPRequest, 248 | ): Promise { 249 | const { project } = request; 250 | 251 | return { 252 | description: `GitHub Actions Build for ${project.repoOwner}/${project.repoName}`, 253 | url: request.requestMetadata.buildUrl, 254 | }; 255 | } 256 | } 257 | -------------------------------------------------------------------------------- /src/server/requesters/Requester.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import type * as jwt from 'jsonwebtoken'; 3 | 4 | import { Project, OTPRequest } from '../db/models'; 5 | import { RequestInformation } from '../responders/Responder'; 6 | 7 | export type AllowedState = 8 | | { 9 | ok: true; 10 | needsLogBasedProof: boolean; 11 | } 12 | | { 13 | ok: false; 14 | error: string; 15 | }; 16 | 17 | export interface Requester { 18 | /** 19 | * The url slug to host this requester on. 20 | * 21 | * E.g. "circleci" would generate the /:projectId/circleci/* routes 22 | */ 23 | readonly slug: string; 24 | /** 25 | * Should return the config instance from the project that this requester 26 | * uses. If it does not exist return null to indicate incompatibility 27 | * with the incoming request. 28 | * 29 | * @param project The project the request is being created for 30 | */ 31 | getConfigForProject(project: Project): RequesterConfig | null; 32 | /** 33 | * The initial "create otp request" API call will come with metadata that 34 | * the requester should acquire, validate and return if it's ok. 35 | * 36 | * If the metadata is missing, invalid or incorrect the requester should 37 | * return null and send a 400 status back with information on the issue 38 | * with the request. 39 | * 40 | * @param req Incoming Request 41 | * @param res Outgoing Response 42 | */ 43 | metadataForInitialRequest(req: Request, res: Response): Promise; 44 | /** 45 | * This method should ensure that based on the metadata for this OTPRequest 46 | * the request is valid, active and not being spoofed. i.e. the CI build is 47 | * still running, on the default_branch branch, etc. 48 | * 49 | * Check the existing implementations for a better idea of what this method should do 50 | * 51 | * @param request 52 | */ 53 | validateActiveRequest( 54 | request: OTPRequest, 55 | config: RequesterConfig, 56 | ): Promise; 57 | /** 58 | * This method should ensure that the active request has proved itself, normally for 59 | * CI builds this means checking that the CI build has logged the proof property of 60 | * the OTP Request 61 | * 62 | * @param request 63 | */ 64 | validateProofForRequest( 65 | request: OTPRequest, 66 | config: RequesterConfig, 67 | ): Promise; 68 | /** 69 | * This should run a JOI validator on the request.requestMetadata property, if it's invalid 70 | * return false, otherwise return true. 71 | * 72 | * This is designed to handle the requester/responder for a project being modified with an 73 | * OTP request being inflight. 74 | * 75 | * @param request 76 | */ 77 | isOTPRequestValidForRequester( 78 | request: OTPRequest, 79 | ): Promise | null>; 80 | /** 81 | * This return the url/description that the responder uses to ask for an OTP, normally the URL 82 | * of the CI build. 83 | * 84 | * @param request 85 | */ 86 | getRequestInformationToPassOn( 87 | request: OTPRequest, 88 | ): Promise; 89 | /** 90 | * This returns the OIDC discovery URL for the given provider. 91 | * 92 | * For providers that don't support OIDC return null and no behavior will change. 93 | */ 94 | getOpenIDConnectDiscoveryURL(project: Project, config: RequesterConfig): Promise; 95 | /** 96 | * This should validate the given JWT claims against the expected project. 97 | */ 98 | doOpenIDConnectClaimsMatchProject( 99 | claims: jwt.JwtPayload, 100 | project: Project, 101 | config: RequesterConfig, 102 | ): Promise; 103 | } 104 | -------------------------------------------------------------------------------- /src/server/responders/Responder.ts: -------------------------------------------------------------------------------- 1 | import { Project, OTPRequest } from '../db/models'; 2 | 3 | export type RequestInformation = { 4 | description: string; 5 | url: string; 6 | }; 7 | 8 | export abstract class Responder { 9 | constructor(protected project: Project) {} 10 | 11 | abstract requestOtp( 12 | request: OTPRequest, 13 | info: RequestInformation | null, 14 | ): Promise; 15 | } 16 | -------------------------------------------------------------------------------- /src/server/responders/SlackResponder.ts: -------------------------------------------------------------------------------- 1 | import * as debug from 'debug'; 2 | import * as Joi from 'joi'; 3 | import * as uuid from 'uuid'; 4 | 5 | import { Responder, RequestInformation } from './Responder'; 6 | import { 7 | OTPRequest, 8 | Project, 9 | SlackResponderConfig, 10 | SlackResponderLinker, 11 | withTransaction, 12 | } from '../db/models'; 13 | import { bolt, authorizeTeam } from '../server'; 14 | import { 15 | SlackActionMiddlewareArgs, 16 | InteractiveMessage, 17 | ButtonClick, 18 | Context, 19 | DialogSubmitAction, 20 | } from '@slack/bolt'; 21 | 22 | const d = debug('cfa:responder:slack'); 23 | 24 | type SlackResponderMetadata = { 25 | request_ts: string; 26 | messageText: string; 27 | attachments: any[]; 28 | }; 29 | 30 | export class SlackResponder extends Responder { 31 | async requestOtp( 32 | request: OTPRequest, 33 | info: RequestInformation | null, 34 | ) { 35 | const config = this.project.responder_slack; 36 | if (!config) return; 37 | 38 | const boltAuth = await authorizeTeam({ 39 | teamId: config.teamId, 40 | enterpriseId: config.enterpriseId, 41 | }); 42 | 43 | if (!boltAuth) { 44 | d( 45 | `attempted to request OTP from {${config.enterpriseId}/${config.teamId}} but failed to obtain credentials`, 46 | ); 47 | return; 48 | } 49 | 50 | const messageText = this.getOtpText(request.project, config); 51 | const attachments = info ? [this.buildAttachment(info)] : []; 52 | const message = await bolt.client.chat.postMessage({ 53 | token: boltAuth.botToken, 54 | channel: config.channelId, 55 | text: messageText, 56 | parse: 'full', 57 | attachments: [ 58 | ...attachments, 59 | { 60 | text: 'Submit OTP Token & Confirm Release', 61 | fallback: 'You are unable to confirm the release', 62 | callback_id: `otp-token/${uuid.v4()}`, 63 | color: '#00B8D9', 64 | actions: [ 65 | { 66 | name: request.id, 67 | text: 'Enter OTP Token', 68 | style: 'danger', 69 | type: 'button', 70 | value: 'open-otp-dialog', 71 | }, 72 | ], 73 | }, 74 | ], 75 | }); 76 | 77 | if (message.ok) { 78 | request.responseMetadata = { 79 | request_ts: (message as any).ts, 80 | messageText, 81 | attachments, 82 | }; 83 | await request.save(); 84 | } else { 85 | d('failed to send OTP request message', message.error); 86 | } 87 | } 88 | 89 | private getOtpText = (project: Project, config: SlackResponderConfig) => 90 | `:warning: Attention on deck, @${config.usernameToMention}! The CFA system ` + 91 | `needs a 2FA OTP token to publish a new release of \`${project.repoOwner}/${project.repoName}\`.`; 92 | 93 | private buildAttachment = (info: RequestInformation) => ({ 94 | fallback: `Request: ${info.url}`, 95 | color: '#6554C0', 96 | pretext: 'The request source is linked below', 97 | title: info.description, 98 | title_link: info.url, 99 | text: 'This request has been validated by CFA and now just requires a OTP code.', 100 | footer: 'CFA Auth', 101 | ts: `${Math.floor(Date.now() / 1000)}`, 102 | }); 103 | } 104 | 105 | /** 106 | * Handle link command 107 | */ 108 | bolt.command( 109 | process.env.NODE_ENV === 'production' ? '/cfa-link' : '/cfa-link-dev', 110 | async ({ context, respond, ack, payload }) => { 111 | await ack(); 112 | 113 | const linkerId = payload.text; 114 | if (!linkerId) 115 | return await respond({ 116 | response_type: 'ephemeral', 117 | text: 'Missing required argument "link-id", please ensure you followed the instructions on CFA exactly.', 118 | }); 119 | 120 | const result = Joi.validate(linkerId, Joi.string().uuid({ version: 'uuidv4' }).required()); 121 | if (result.error) { 122 | return await respond({ 123 | response_type: 'ephemeral', 124 | text: `The linker ID \`${linkerId}\` provided is invalid, please head back to CFA and try again.`, 125 | }); 126 | } 127 | 128 | const linker = await SlackResponderLinker.findByPk(linkerId, { 129 | include: [Project], 130 | }); 131 | if (!linker) 132 | return await respond({ 133 | response_type: 'ephemeral', 134 | text: 'The linker ID provided has either already been used or does not exist, please head back to CFA and try again.', 135 | }); 136 | 137 | const info = await bolt.client.team.info({ 138 | token: context.botToken, 139 | }); 140 | if (!info.ok) { 141 | console.error('Failed to link team', info.error); 142 | return respond({ 143 | response_type: 'ephemeral', 144 | text: 'An internal error occurred while trying to link this Slack team to CFA. Please try again later.', 145 | }); 146 | } 147 | 148 | await withTransaction(async (t) => { 149 | const config = await SlackResponderConfig.create( 150 | { 151 | teamName: (info as any).team.name, 152 | teamId: payload.team_id, 153 | channelName: payload.channel_name, 154 | channelId: payload.channel_id, 155 | usernameToMention: payload.user_name, 156 | teamIcon: (info as any).team.icon.image_68, 157 | enterpriseId: payload.enterprise_id || '', 158 | }, 159 | { 160 | transaction: t, 161 | returning: true, 162 | }, 163 | ); 164 | payload.team_id; 165 | await linker.project.resetAllResponders(t); 166 | linker.project.responder_slack_id = config.id; 167 | await linker.project.save({ transaction: t }); 168 | await linker.destroy({ transaction: t }); 169 | }); 170 | 171 | respond({ 172 | response_type: 'ephemeral', 173 | text: `Successfully linked this channel to \`${linker.project.repoOwner}/${linker.project.repoName}\``, 174 | }); 175 | }, 176 | ); 177 | 178 | /** 179 | * Handle the "Open Dialog" button 180 | */ 181 | bolt.action( 182 | { 183 | type: 'interactive_message', 184 | callback_id: /^otp-token\//g, 185 | }, 186 | async ({ context, action, ack, body, next }) => { 187 | if ( 188 | action && 189 | action.type === 'button' && 190 | action.value === 'open-otp-dialog' && 191 | 'trigger_id' in body && 192 | 'name' in action 193 | ) { 194 | await ack(); 195 | 196 | await bolt.client.dialog.open({ 197 | token: context.botToken, 198 | trigger_id: body.trigger_id, 199 | dialog: { 200 | title: 'Enter 2FA OTP', 201 | callback_id: `otp:${action.name}`, 202 | elements: [ 203 | { 204 | type: 'text', 205 | label: 'OTP', 206 | name: 'otp', 207 | }, 208 | ], 209 | }, 210 | }); 211 | } else { 212 | await next(); 213 | } 214 | }, 215 | ); 216 | 217 | /** 218 | * Handle dialog submission 219 | */ 220 | bolt.action( 221 | { 222 | type: 'dialog_submission', 223 | callback_id: /^otp:/g, 224 | }, 225 | async ({ action, ack, body, context, next, respond }) => { 226 | if ( 227 | action && 228 | action.type === 'dialog_submission' && 229 | action.callback_id && 230 | /^otp:.+$/.test(action.callback_id) 231 | ) { 232 | await ack(); 233 | const requestId = action.callback_id.slice(4); 234 | 235 | const result = Joi.validate(requestId, Joi.string().uuid({ version: 'uuidv4' }).required()); 236 | 237 | if (result.error) { 238 | return await respond({ 239 | response_type: 'ephemeral', 240 | text: ':red_circle: CFA experienced an unexpected error while processing your response, please try again later.', 241 | }); 242 | } 243 | 244 | const request: OTPRequest | null = await OTPRequest.findByPk(requestId); 245 | if (!request) { 246 | return await respond({ 247 | response_type: 'ephemeral', 248 | text: ':red_circle: CFA experienced an unexpected error while finding your request, please try again later.', 249 | }); 250 | } 251 | 252 | if (request.state !== 'validated') { 253 | return await respond({ 254 | response_type: 'ephemeral', 255 | text: ':red_circle: This OTP request is in an invalid state and can not be responded to.', 256 | }); 257 | } 258 | 259 | if (request.responseMetadata && request.responseMetadata.request_ts) { 260 | const messageTs = request.responseMetadata.request_ts; 261 | 262 | await bolt.client.chat.update({ 263 | token: context.botToken, 264 | channel: body.channel.id, 265 | ts: messageTs, 266 | text: request.responseMetadata.messageText, 267 | parse: 'full', 268 | attachments: [ 269 | ...request.responseMetadata.attachments, 270 | { 271 | fallback: `OTP provided by:`, 272 | pretext: `We've received an OTP and will transmit to the requester shortly.`, 273 | title: `OTP provided by: @${body.user.name}`, 274 | color: '#36B37E', 275 | footer: 'CFA Auth', 276 | ts: `${Math.floor(Date.now() / 1000)}`, 277 | }, 278 | ], 279 | }); 280 | 281 | request.state = 'responded'; 282 | request.responded = new Date(); 283 | request.response = body.submission.otp; 284 | request.userThatResponded = body.user.name; 285 | await request.save(); 286 | } else { 287 | request.state = 'error'; 288 | request.errored = new Date(); 289 | request.errorReason = 'Invalid responseMetadata on the backend'; 290 | await request.save(); 291 | return await respond({ 292 | response_type: 'ephemeral', 293 | text: ':red_circle: CFA experienced an unexpected error while updating your request, please try again later.', 294 | }); 295 | } 296 | } else { 297 | await next(); 298 | } 299 | }, 300 | ); 301 | -------------------------------------------------------------------------------- /src/server/responders/index.ts: -------------------------------------------------------------------------------- 1 | import { Project } from '../db/models'; 2 | 3 | import { Responder } from './Responder'; 4 | import { SlackResponder } from './SlackResponder'; 5 | 6 | export function getResponderFor(project: Project): Responder { 7 | if (project.responder_slack) { 8 | return new SlackResponder(project) as Responder; 9 | } 10 | throw new Error(`Attempted to get responder for project ${project.id} but it does not have one`); 11 | } 12 | -------------------------------------------------------------------------------- /src/server/server.ts: -------------------------------------------------------------------------------- 1 | import * as Sentry from '@sentry/node'; 2 | import { App, ExpressReceiver } from '@slack/bolt'; 3 | import * as debug from 'debug'; 4 | import * as dotenv from 'dotenv-safe'; 5 | import * as express from 'express'; 6 | import * as morgan from 'morgan'; 7 | 8 | import { createA } from './helpers/a'; 9 | import { SlackInstall, withTransaction } from './db/models'; 10 | 11 | const d = debug('cfa:server:core'); 12 | const a = createA(d); 13 | 14 | dotenv.config(); 15 | if (process.env.SENTRY_DSN) { 16 | Sentry.init({ dsn: process.env.SENTRY_DSN, environment: process.env.CFA_ENV || 'development' }); 17 | } 18 | 19 | const receiverOpts = { 20 | signingSecret: process.env.SLACK_SIGNING_SECRET!, 21 | endpoints: { 22 | commands: '/commands', 23 | events: '/events', 24 | interactive: '/interactive', 25 | }, 26 | }; 27 | const boltReceiver = new ExpressReceiver(receiverOpts); 28 | 29 | const boltApp = boltReceiver.app; 30 | 31 | export const app = express(); 32 | if (process.env.SENTRY_DSN) { 33 | app.use(Sentry.Handlers.requestHandler()); 34 | } 35 | 36 | export const bolt = new App({ 37 | receiver: boltReceiver, 38 | authorize: authorizeTeam, 39 | }); 40 | 41 | app.use(morgan('dev')); 42 | app.use('/api/services/slack', boltApp); 43 | // TODO: Make responses pretty AF 44 | app.get( 45 | '/api/services/slack/oauth', 46 | a(async (req, res) => { 47 | if (!req.query.code) 48 | return res.redirect( 49 | '/oauth_result/slack?error=Failed to obtain user token, please try again', 50 | ); 51 | 52 | let access: any; 53 | try { 54 | const accessResult = await bolt.client.oauth.access({ 55 | code: req.query.code as string, 56 | client_id: process.env.SLACK_CLIENT_ID!, 57 | client_secret: process.env.SLACK_CLIENT_SECRET!, 58 | }); 59 | if (!accessResult.ok) { 60 | throw accessResult.error; 61 | } 62 | access = accessResult; 63 | } catch (err) { 64 | d('failed to auth user:', err); 65 | return res.redirect( 66 | '/oauth_result/slack?error=Failed to authenticate user, please try again', 67 | ); 68 | } 69 | 70 | const { bot, team_id } = access; 71 | const { bot_access_token, bot_user_id } = bot; 72 | 73 | const info: any = await bolt.client.team.info({ 74 | token: bot_access_token, 75 | }); 76 | if (!info.ok) { 77 | d('failed to load team info:', info.error); 78 | return res.redirect('/oauth_result/slack?error=Failed to get team info'); 79 | } 80 | const { enterprise_id } = info.team; 81 | 82 | const botInfo: any = await bolt.client.users.info({ 83 | token: bot_access_token, 84 | user: bot_user_id, 85 | }); 86 | if (!botInfo.ok) { 87 | d('failed to load bot info:', botInfo.error); 88 | return res.redirect('/oauth_result/slack?error=Failed to get bot installation info'); 89 | } 90 | 91 | if (!botInfo.user.profile.bot_id) { 92 | d('failed to find bot id:', bot_user_id); 93 | return res.redirect('/oauth_result/slack?error=Failed to get bot installation info'); 94 | } 95 | 96 | await withTransaction(async (t) => { 97 | const existingInstall = await SlackInstall.findOne({ 98 | where: { 99 | teamId: team_id, 100 | enterpriseId: enterprise_id || '', 101 | }, 102 | transaction: t, 103 | }); 104 | if (existingInstall) { 105 | await existingInstall.destroy({ transaction: t }); 106 | } 107 | 108 | const install = new SlackInstall({ 109 | teamId: team_id, 110 | enterpriseId: enterprise_id || '', 111 | botToken: bot_access_token, 112 | botId: botInfo.user.profile.bot_id, 113 | botUserId: bot_user_id, 114 | }); 115 | 116 | await install.save({ transaction: t }); 117 | }); 118 | 119 | res.redirect('/oauth_result/slack'); 120 | }), 121 | ); 122 | 123 | export async function authorizeTeam(opts: { teamId?: string; enterpriseId?: string }): Promise<{ 124 | botToken: string; 125 | botId: string; 126 | botUserId: string; 127 | }> { 128 | if (!opts.teamId && !opts.enterpriseId) throw new Error('Not installed'); 129 | const install = await SlackInstall.findOne({ 130 | where: { 131 | teamId: opts.teamId || '', 132 | enterpriseId: opts.enterpriseId || '', 133 | }, 134 | }); 135 | if (!install) throw new Error('Not installed'); 136 | return { 137 | botToken: install.botToken, 138 | botId: install.botId, 139 | botUserId: install.botUserId, 140 | }; 141 | } 142 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es2018", 5 | "outDir": "lib", 6 | "lib": [ 7 | "es6", 8 | "es2016.array.include", 9 | "dom" 10 | ], 11 | "sourceMap": true, 12 | "rootDir": "src", 13 | "experimentalDecorators": true, 14 | "allowJs": true, 15 | "strictNullChecks": true, 16 | "allowSyntheticDefaultImports": true, 17 | "jsx": "react", 18 | "incremental": true 19 | }, 20 | "exclude": [ 21 | "node_modules", 22 | "spec", 23 | "lib", 24 | "working", 25 | "jest.config.js", 26 | "coverage", 27 | "webpack.*.js", 28 | "postcss*", 29 | "jest.setup.js" 30 | ] 31 | } -------------------------------------------------------------------------------- /tsconfig.public.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "target": "es5", 6 | "outDir": "lib", 7 | "sourceMap": true, 8 | "rootDir": ".", 9 | "noEmit": false 10 | }, 11 | "exclude": [ 12 | "node_modules", 13 | "src" 14 | ] 15 | } -------------------------------------------------------------------------------- /typings/ambiend.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace Express { 2 | interface Request { 3 | ctx: any; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | 4 | const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer'); 5 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 6 | 7 | const rules = require('./webpack.rules'); 8 | 9 | const SERVER_PORT = process.env.PORT || 3001; 10 | 11 | if (!process.env.SENTRY_FE_DSN) { 12 | process.env.SENTRY_FE_DSN = 'missing_dsn'; 13 | } 14 | 15 | module.exports = { 16 | mode: 'development', 17 | entry: [ 18 | ...(process.env.NODE_ENV === 'production' ? [] : ['react-hot-loader/patch']), 19 | './src/client/polyfill.ts', 20 | './src/client/index.tsx', 21 | ], 22 | devtool: process.env.WEBPACK_DEVTOOL || 'eval-source-map', 23 | output: { 24 | publicPath: '/', 25 | path: path.join(__dirname, 'public_out'), 26 | filename: '[name].js', 27 | crossOriginLoading: 'anonymous', 28 | }, 29 | resolve: { 30 | extensions: ['.ts', '.tsx', '.js', '.jsx'], 31 | }, 32 | module: { 33 | rules, 34 | }, 35 | devServer: { 36 | static: './public_out', 37 | hot: true, 38 | historyApiFallback: true, 39 | port: 3000, 40 | proxy: { 41 | '/api': `http://localhost:${SERVER_PORT}`, 42 | }, 43 | allowedHosts: 'all', 44 | compress: true, 45 | }, 46 | plugins: [ 47 | new webpack.NoEmitOnErrorsPlugin(), 48 | new webpack.HotModuleReplacementPlugin(), 49 | new HtmlWebpackPlugin({ 50 | template: './src/client/template.ejs', 51 | filename: 'index.html', 52 | templateParameters: {}, 53 | }), 54 | new webpack.IgnorePlugin({ 55 | resourceRegExp: /^\.\/locale$/, 56 | }), 57 | new webpack.IgnorePlugin({ 58 | resourceRegExp: /moment$/, 59 | }), 60 | new webpack.IgnorePlugin({ 61 | resourceRegExp: /react-dom\/client$/, 62 | // contextRegExp: /(app\/react|@storybook\/react)/, 63 | }), 64 | new webpack.EnvironmentPlugin(['SENTRY_FE_DSN']), 65 | ], 66 | }; 67 | 68 | if (process.env.ANALYZE) { 69 | module.exports.plugins.push(new BundleAnalyzerPlugin()); 70 | } 71 | -------------------------------------------------------------------------------- /webpack.production.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | 3 | const CSPPlugin = require('csp-html-webpack-plugin'); 4 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 5 | const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin'); 6 | const { SubresourceIntegrityPlugin } = require('webpack-subresource-integrity'); 7 | 8 | const config = require('./webpack.config'); 9 | 10 | // Hash all JS assets 11 | config.output.filename = '[name].[contenthash].min.js'; 12 | 13 | // Remove devServer config 14 | delete config.devServer; 15 | 16 | // Remove NoEmitOnErrors, HotModuleReplacement and Dashboard plugins 17 | config.plugins.shift(); 18 | config.plugins.shift(); 19 | 20 | // Remove source mapping 21 | config.devtool = 'source-map'; 22 | 23 | // Add production plugins 24 | config.plugins.unshift( 25 | new webpack.DefinePlugin({ 26 | 'process.env': { 27 | NODE_ENV: '"production"', 28 | }, 29 | }), 30 | new MiniCssExtractPlugin({ 31 | filename: '[name].[contenthash].css', 32 | chunkFilename: 'chunk.[id].[contenthash].css', 33 | // allChunks: true, 34 | }), 35 | new OptimizeCssAssetsPlugin()); 36 | 37 | config.plugins.push( 38 | new CSPPlugin({ 39 | 'base-uri': "'self'", 40 | 'object-src': "'none'", 41 | 'script-src': ["'unsafe-inline'", "'self'"], 42 | 'style-src': ["'unsafe-inline'", "'self'", "https://fonts.googleapis.com"] 43 | }, { 44 | enabled: true, 45 | hashingMethod: 'sha256', 46 | hashEnabled: { 47 | 'script-src': true, 48 | 'style-src': false 49 | }, 50 | nonceEnabled: { 51 | 'script-src': true, 52 | 'style-src': false 53 | } 54 | }), 55 | new SubresourceIntegrityPlugin({ 56 | hashFuncNames: ['sha256', 'sha384'], 57 | enabled: true, 58 | }), 59 | ); 60 | 61 | config.mode = 'production'; 62 | 63 | module.exports = config; -------------------------------------------------------------------------------- /webpack.rules.js: -------------------------------------------------------------------------------- 1 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 2 | const path = require('path'); 3 | 4 | const isProd = process.env.NODE_ENV === 'production'; 5 | const baseCacheDir = path.resolve( 6 | __dirname, 7 | 'node_modules', 8 | '.build-cache', 9 | isProd ? 'prod' : 'dev', 10 | ); 11 | 12 | const envSpecificCSSLoader = () => 13 | isProd 14 | ? MiniCssExtractPlugin.loader 15 | : { 16 | loader: 'style-loader', 17 | options: { 18 | // sourceMap: true, 19 | }, 20 | }; 21 | 22 | module.exports = [ 23 | { 24 | test: /\.tsx?$/, 25 | exclude: /(node_modules|bower_components|public_out\/)/, 26 | use: [ 27 | { 28 | loader: 'cache-loader', 29 | options: { 30 | cacheDirectory: path.resolve(baseCacheDir, 'ts'), 31 | }, 32 | }, 33 | { 34 | loader: 'ts-loader', 35 | options: { 36 | configFile: 'tsconfig.public.json', 37 | transpileOnly: true, 38 | }, 39 | }, 40 | ], 41 | }, 42 | { 43 | test: /\.svg(\?v=\d+\.\d+\.\d+)?$/, 44 | exclude: /(node_modules|bower_components)/, 45 | loader: 'url-loader', 46 | options: { 47 | limit: 10000, 48 | mimetype: 'image/svg+xml', 49 | }, 50 | }, 51 | { 52 | test: /\.png/, 53 | exclude: /(node_modules|bower_components)/, 54 | loader: 'url-loader', 55 | options: { 56 | limit: 10000, 57 | mimetype: 'image/png', 58 | }, 59 | }, 60 | { 61 | test: /\.css$/, 62 | use: [envSpecificCSSLoader(), 'css-loader'], 63 | }, 64 | { 65 | test: /\.scss$/, 66 | exclude: /[/\\](node_modules|bower_components|public_out\/)[/\\]/, 67 | use: [ 68 | { 69 | loader: 'cache-loader', 70 | options: { 71 | cacheDirectory: path.resolve(baseCacheDir, 'scss'), 72 | }, 73 | }, 74 | envSpecificCSSLoader(), 75 | { 76 | loader: 'css-loader', 77 | options: { 78 | modules: true, 79 | importLoaders: 1, 80 | sourceMap: true, 81 | // modules: { 82 | // localIdentName: isProd ? undefined : '[path]___[name]__[local]___[hash:base64:5]' 83 | // } 84 | }, 85 | }, 86 | 'postcss-loader', 87 | 'sass-loader', 88 | ], 89 | }, 90 | ]; 91 | --------------------------------------------------------------------------------