diff --git a/package.json b/package.json index f36134e..d5c0525 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "prettier:check": "prettier --check \"src/**/*.{ts,tsx}\"", "prettier:write": "prettier --write \"src/**/*.{ts,tsx}\"", "start": "npm run start:prod", - "start:dev:server": "cross-env DEBUG=cfa* REDIS_URL=redis://localhost NO_DB_SSL=true DATABASE_URL=postgresql://cfa-user:cfa-pass@localhost:5433/cfa node lib/server", + "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", "start:prod": "cross-env DEBUG=cfa* node lib/server", "test": "rm -rf lib && jest --coverage", "test:ci": "jest --ci --coverage --reporters=default --reporters=jest-junit", @@ -37,6 +37,7 @@ "@types/jest": "^29.0.0", "@types/joi": "^14.3.4", "@types/jwk-to-pem": "^2.0.1", + "@types/libsodium-wrappers": "^0.7.14", "@types/morgan": "^1.7.37", "@types/node": "^20.0.0", "@types/passport": "^1.0.0", @@ -106,6 +107,7 @@ "joi": "^14.3.1", "jsonwebtoken": "^9.0.2", "jwk-to-pem": "^2.0.5", + "libsodium-wrappers": "^0.7.13", "morgan": "^1.9.1", "openid-client": "^5.1.10", "passport": "^0.6.0", diff --git a/src/__mocks__/project.ts b/src/__mocks__/project.ts index 74f9be9..6a802b4 100644 --- a/src/__mocks__/project.ts +++ b/src/__mocks__/project.ts @@ -4,6 +4,7 @@ export const mockProject = (): FullProject => ({ secret: 'my_secret', enabled: true, requester_circleCI: null, + requester_gitHub: null, responder_slack: null, id: '123', repoName: 'my-repo', diff --git a/src/client/__tests__/utils.spec.ts b/src/client/__tests__/utils.spec.ts index 53172d2..6ac83a9 100644 --- a/src/client/__tests__/utils.spec.ts +++ b/src/client/__tests__/utils.spec.ts @@ -17,6 +17,10 @@ describe('projectHasAnyConfig', () => { expect(projectHasAnyConfig({ requester_circleCI: true } as any)).toBe(true); }); + it('should return true if the project has github configured', () => { + expect(projectHasAnyConfig({ requester_gitHub: true } as any)).toBe(true); + }); + it('should return true if the project has slack configured', () => { expect(projectHasAnyConfig({ responder_slack: true } as any)).toBe(true); }); diff --git a/src/client/components/Dashboard.tsx b/src/client/components/Dashboard.tsx index 900a8d6..82e2ff7 100644 --- a/src/client/components/Dashboard.tsx +++ b/src/client/components/Dashboard.tsx @@ -21,6 +21,7 @@ import { CircleCILogo } from './icons/CircleCI'; import { SlackLogo } from './icons/Slack'; import { Link } from 'react-router-dom'; import { cx, projectHasAnyConfig, defaultFetchInit, defaultBodyReader } from '../utils'; +import { GitHubLogo } from './icons/GitHub'; export function Dashboard() { const reposFetch = useFetch('/api/repos', defaultFetchInit, defaultBodyReader); @@ -146,6 +147,15 @@ export function Dashboard() { CircleCI ) : null} + {repo.requester_gitHub ? ( + + + + + + GitHub Actions + + ) : null} {repo.responder_slack ? ( diff --git a/src/client/components/MenuHeader.tsx b/src/client/components/MenuHeader.tsx index 2308094..38d77e3 100644 --- a/src/client/components/MenuHeader.tsx +++ b/src/client/components/MenuHeader.tsx @@ -34,7 +34,6 @@ export function MenuHeaderInner() { {user ? user.username : '?'} diff --git a/src/client/components/RequesterConfig.tsx b/src/client/components/RequesterConfig.tsx index 7511559..560a127 100644 --- a/src/client/components/RequesterConfig.tsx +++ b/src/client/components/RequesterConfig.tsx @@ -6,6 +6,8 @@ import { FullProject } from '../../common/types'; import styles from './ReqResConfig.scss'; import { CircleCILogo } from './icons/CircleCI'; import { CircleCIRequesterConfig } from './configurators/CircleCIRequesterConfig'; +import { GitHubLogo } from './icons/GitHub'; +import { GitHubActionsRequesterConfig } from './configurators/GitHubActionsRequesterConfig'; export interface Props { project: FullProject; setProject: (newProject: FullProject) => void; @@ -14,10 +16,12 @@ export interface Props { enum RequesterTab { NOTHING_YET, CIRCLE_CI, + GITHUB_ACTIONS, } const defaultTabForProject = (project: FullProject) => { if (project.requester_circleCI) return RequesterTab.CIRCLE_CI; + if (project.requester_gitHub) return RequesterTab.GITHUB_ACTIONS; return RequesterTab.NOTHING_YET; }; @@ -61,6 +65,13 @@ export function RequesterConfig({ project, setProject }: Props) { > Circle CI + setActiveTab(RequesterTab.GITHUB_ACTIONS)} + isSelected={activeTab === RequesterTab.GITHUB_ACTIONS} + style={{ paddingLeft: 28, position: 'relative' }} + > + GitHub Actions + More Coming Soon... @@ -69,6 +80,8 @@ export function RequesterConfig({ project, setProject }: Props) { No Requester has been configured, choose one to get started! ) : activeTab === RequesterTab.CIRCLE_CI ? ( + ) : activeTab === RequesterTab.GITHUB_ACTIONS ? ( + ) : null} diff --git a/src/client/components/__tests__/RequesterConfig.spec.tsx b/src/client/components/__tests__/RequesterConfig.spec.tsx index 4b3d4a9..92c7c8c 100644 --- a/src/client/components/__tests__/RequesterConfig.spec.tsx +++ b/src/client/components/__tests__/RequesterConfig.spec.tsx @@ -72,4 +72,12 @@ describe('', () => { const mounted = mount(); expect(mounted.find('CircleCIRequesterConfig')).toHaveLength(1); }); + + it('Should show the github configurator when github has been configured on the provided project', () => { + const setProject = jest.fn(); + const project = mockProject(); + project.requester_gitHub = {}; + const mounted = mount(); + expect(mounted.find('GitHubActionsRequesterConfig')).toHaveLength(1); + }); }); diff --git a/src/client/components/__tests__/__snapshots__/MenuHeader.spec.tsx.snap b/src/client/components/__tests__/__snapshots__/MenuHeader.spec.tsx.snap index d178d7a..0b56e97 100644 --- a/src/client/components/__tests__/__snapshots__/MenuHeader.spec.tsx.snap +++ b/src/client/components/__tests__/__snapshots__/MenuHeader.spec.tsx.snap @@ -38,7 +38,6 @@ exports[` Should render a logo 1`] = ` className="right" > diff --git a/src/client/components/__tests__/__snapshots__/RequesterConfig.spec.tsx.snap b/src/client/components/__tests__/__snapshots__/RequesterConfig.spec.tsx.snap index b490bc3..8f0397f 100644 --- a/src/client/components/__tests__/__snapshots__/RequesterConfig.spec.tsx.snap +++ b/src/client/components/__tests__/__snapshots__/RequesterConfig.spec.tsx.snap @@ -36,6 +36,21 @@ exports[` Should render 1`] = ` /> Circle CI + + + GitHub Actions + diff --git a/src/client/components/configurators/GitHubActionsRequesterConfig.tsx b/src/client/components/configurators/GitHubActionsRequesterConfig.tsx new file mode 100644 index 0000000..f025e0a --- /dev/null +++ b/src/client/components/configurators/GitHubActionsRequesterConfig.tsx @@ -0,0 +1,112 @@ +import * as React from 'react'; +import { Alert, Button, Code, Heading, Pane, Paragraph, toaster } from 'evergreen-ui'; + +import { FullProject } from '../../../common/types'; +import { useAsyncTaskFetch } from 'react-hooks-async'; +import { defaultBodyReader } from '../../utils'; + +export interface Props { + project: FullProject; + setProject: (newProject: FullProject) => void; +} + +export function GitHubActionsRequesterConfig({ project, setProject }: Props) { + const [showInstallButton, setShowInstallButton] = React.useState(false); + const options = React.useMemo( + () => ({ + method: 'POST', + headers: new Headers({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({}), + }), + [project], + ); + + const installGitHubApp = React.useCallback(() => { + window.open('https://github.com/apps/continuous-auth/installations/new', '_blank'); + setShowInstallButton(false); + }, []); + + const createRequesterTask = useAsyncTaskFetch( + `/api/project/${project.id}/config/requesters/github`, + options, + defaultBodyReader, + ); + + const projectSlug = `${project.repoOwner}/${project.repoName}`; + + React.useEffect(() => { + if (createRequesterTask.error) { + if (createRequesterTask.error.message.includes('412')) { + toaster.notify(`Continuous Auth not installed in ${projectSlug}`); + setShowInstallButton(true); + } else { + toaster.danger(`Failed to create the GitHub Requester, please try again later.`); + } + } + }, [createRequesterTask.error, projectSlug]); + + React.useEffect(() => { + if (createRequesterTask.result) { + toaster.success(`Successfully created the GitHub Requester.`); + setProject(createRequesterTask.result); + } + }, [createRequesterTask.result]); + + const saving = createRequesterTask.started && createRequesterTask.pending; + + return ( + + + + GitHub Actions Secrets + + {showInstallButton ? ( + <> + + You need to install the Continuous Auth github app before we can set this project up. +
+ +
+ + ) : null} + {project.requester_gitHub ? ( + <> + + ContinuousAuth is fully set up, if you're having issues with secrets you can use the + "Fix" button below. + + + + ) : ( + <> + + ContinuousAuth needs to make some secrets in GitHub Actions in order to publish. + + + + )} +
+
+ ); +} diff --git a/src/client/components/configurators/__tests__/__snapshots__/CircleCIRequesterConfig.spec.tsx.snap b/src/client/components/configurators/__tests__/__snapshots__/CircleCIRequesterConfig.spec.tsx.snap index 9eab19c..c24584c 100644 --- a/src/client/components/configurators/__tests__/__snapshots__/CircleCIRequesterConfig.spec.tsx.snap +++ b/src/client/components/configurators/__tests__/__snapshots__/CircleCIRequesterConfig.spec.tsx.snap @@ -11,6 +11,7 @@ exports[` Should render a GenericAccessTokenRequester "repoName": "my-repo", "repoOwner": "my-owner", "requester_circleCI": null, + "requester_gitHub": null, "responder_slack": null, "secret": "my_secret", } diff --git a/src/client/components/icons/GitHub.tsx b/src/client/components/icons/GitHub.tsx new file mode 100644 index 0000000..d83aa38 --- /dev/null +++ b/src/client/components/icons/GitHub.tsx @@ -0,0 +1,17 @@ +import * as React from 'react'; + +import { Props } from './icon-props'; + +export function GitHubLogo(props: Props) { + return ( + + + + ); +} diff --git a/src/client/components/icons/__tests__/GitHub.spec.tsx b/src/client/components/icons/__tests__/GitHub.spec.tsx new file mode 100644 index 0000000..b57fef5 --- /dev/null +++ b/src/client/components/icons/__tests__/GitHub.spec.tsx @@ -0,0 +1,11 @@ +import * as React from 'react'; +import { shallow } from 'enzyme'; +import { GitHubLogo } from '../GitHub'; + +describe('GitHubLogo Icon', () => { + it('Should render with a className', () => { + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + expect(wrapper.prop('className')).toBe('test_class_name'); + }); +}); diff --git a/src/client/components/icons/__tests__/__snapshots__/GitHub.spec.tsx.snap b/src/client/components/icons/__tests__/__snapshots__/GitHub.spec.tsx.snap new file mode 100644 index 0000000..718da17 --- /dev/null +++ b/src/client/components/icons/__tests__/__snapshots__/GitHub.spec.tsx.snap @@ -0,0 +1,18 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`GitHubLogo Icon Should render with a className 1`] = ` + + + +`; diff --git a/src/client/utils.ts b/src/client/utils.ts index e658886..c2e0f17 100644 --- a/src/client/utils.ts +++ b/src/client/utils.ts @@ -3,7 +3,7 @@ import { SimpleProject } from '../common/types'; export const cx = (...args: (string | null | undefined)[]) => args.filter((a) => a).join(' '); export const projectHasAnyConfig = (project: SimpleProject): boolean => { - return Boolean(project.requester_circleCI || project.responder_slack); + return Boolean(project.requester_circleCI || project.requester_gitHub || project.responder_slack); }; export const defaultBodyReader = (body: any) => body.json(); diff --git a/src/common/__tests__/types.spec.ts b/src/common/__tests__/types.spec.ts index 4a34dfe..4f6a971 100644 --- a/src/common/__tests__/types.spec.ts +++ b/src/common/__tests__/types.spec.ts @@ -6,6 +6,7 @@ describe('projectIsMissingConfig', () => { projectIsMissingConfig({ responder_slack: {}, requester_circleCI: {}, + requester_gitHub: null, }), ).toBe(false); }); @@ -15,6 +16,7 @@ describe('projectIsMissingConfig', () => { projectIsMissingConfig({ responder_slack: {}, requester_circleCI: null, + requester_gitHub: null, }), ).toBe(true); }); @@ -24,6 +26,7 @@ describe('projectIsMissingConfig', () => { projectIsMissingConfig({ responder_slack: null, requester_circleCI: {}, + requester_gitHub: null, }), ).toBe(true); }); @@ -33,6 +36,7 @@ describe('projectIsMissingConfig', () => { projectIsMissingConfig({ responder_slack: null, requester_circleCI: null, + requester_gitHub: null, }), ).toBe(true); }); diff --git a/src/common/types.ts b/src/common/types.ts index 82b00da..3c3bd53 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -14,6 +14,7 @@ export interface SimpleRepo { export interface SimpleProject extends SimpleRepo { requester_circleCI: boolean; + requester_gitHub: boolean; responder_slack: { team: string; channel: string; @@ -26,6 +27,7 @@ export interface FullProject extends SimpleRepo { requester_circleCI: { accessToken: string; } | null; + requester_gitHub: {} | null; responder_slack: { teamName: string; channelName: string; @@ -54,7 +56,7 @@ export const projectIsMissingConfig = ( > >, ) => { - const hasRequester: boolean = !!project.requester_circleCI; + const hasRequester: boolean = !!project.requester_circleCI || !!project.requester_gitHub; const hasResponder: boolean = !!project.responder_slack; return !hasRequester || !hasResponder; }; diff --git a/src/server/api/project/config.ts b/src/server/api/project/config.ts index e747d9a..4a535f9 100644 --- a/src/server/api/project/config.ts +++ b/src/server/api/project/config.ts @@ -2,6 +2,7 @@ import axios from 'axios'; import * as debug from 'debug'; import * as express from 'express'; import * as Joi from 'joi'; +import * as sodium from 'libsodium-wrappers'; import { validate } from '../../helpers/_middleware'; import { createA } from '../../helpers/a'; @@ -10,8 +11,11 @@ import { SlackResponderLinker, withTransaction, CircleCIRequesterConfig, + GitHubActionsRequesterConfig, } from '../../db/models'; import { getProjectFromIdAndCheckPermissions } from './_safe'; +import { getGitHubAppInstallationToken } from '../../helpers/auth'; +import { Octokit } from '@octokit/rest'; const d = debug('cfa:server:api:project:config'); const a = createA(d); @@ -52,6 +56,114 @@ export async function updateCircleEnvVars(project: Project, accessToken: string) }); } +enum GitHubActionsEnvironmentResult { + SUCCESS, + APP_NOT_INSTALLED, + UNKNOWN_ERROR, +} + +export const CFA_RELEASE_GITHUB_ENVIRONMENT_NAME = 'npm'; + +export async function updateGitHubActionsEnvironment( + project: Project, +): Promise { + try { + let token: string; + try { + token = await getGitHubAppInstallationToken(project, { + metadata: 'read', + administration: 'write', + environments: 'write', + }); + } catch { + // Assume an error here means that the app is not installed + return GitHubActionsEnvironmentResult.APP_NOT_INSTALLED; + } + const github = new Octokit({ auth: token }); + + const allEnvs = await github.repos.getAllEnvironments({ + owner: project.repoOwner, + repo: project.repoName, + }); + const cfaReleaseEnv = allEnvs.data.environments?.find( + (e) => e.name === CFA_RELEASE_GITHUB_ENVIRONMENT_NAME, + ); + if (!cfaReleaseEnv) { + await github.repos.createOrUpdateEnvironment({ + owner: project.repoOwner, + repo: project.repoName, + environment_name: CFA_RELEASE_GITHUB_ENVIRONMENT_NAME, + reviewers: null, + deployment_branch_policy: { + protected_branches: false, + custom_branch_policies: true, + }, + }); + + await github.repos.createDeploymentBranchPolicy({ + owner: project.repoOwner, + repo: project.repoName, + environment_name: CFA_RELEASE_GITHUB_ENVIRONMENT_NAME, + name: project.defaultBranch, + type: 'branch', + }); + } + + const publicKey = await github.actions.getEnvironmentPublicKey({ + repository_id: parseInt(project.id, 10), + environment_name: CFA_RELEASE_GITHUB_ENVIRONMENT_NAME, + }); + + await sodium.ready; + const sodiumKey = sodium.from_base64(publicKey.data.key, sodium.base64_variants.ORIGINAL); + const sodiumSecret = sodium.from_string(project.secret); + const sodiumProjectId = sodium.from_string(project.id); + + await github.actions.createOrUpdateEnvironmentSecret({ + repository_id: parseInt(project.id, 10), + environment_name: CFA_RELEASE_GITHUB_ENVIRONMENT_NAME, + key_id: publicKey.data.key_id, + secret_name: 'CFA_SECRET', + encrypted_value: sodium.to_base64( + sodium.crypto_box_seal(sodiumSecret, sodiumKey), + sodium.base64_variants.ORIGINAL, + ), + }); + + await github.actions.createOrUpdateEnvironmentSecret({ + repository_id: parseInt(project.id, 10), + environment_name: CFA_RELEASE_GITHUB_ENVIRONMENT_NAME, + key_id: publicKey.data.key_id, + secret_name: 'CFA_PROJECT_ID', + encrypted_value: sodium.to_base64( + sodium.crypto_box_seal(sodiumProjectId, sodiumKey), + sodium.base64_variants.ORIGINAL, + ), + }); + + const npmTokenToUse = process.env[`npm_token_credential_${project.repoOwner}`]; + if (npmTokenToUse) { + const sodiumNpmToken = sodium.from_string(npmTokenToUse); + + await github.actions.createOrUpdateEnvironmentSecret({ + repository_id: parseInt(project.id, 10), + environment_name: CFA_RELEASE_GITHUB_ENVIRONMENT_NAME, + key_id: publicKey.data.key_id, + secret_name: 'NPM_TOKEN', + encrypted_value: sodium.to_base64( + sodium.crypto_box_seal(sodiumNpmToken, sodiumKey), + sodium.base64_variants.ORIGINAL, + ), + }); + } + + return GitHubActionsEnvironmentResult.SUCCESS; + } catch (err) { + console.error('Unknown error occurred updating github actions environment', err); + return GitHubActionsEnvironmentResult.UNKNOWN_ERROR; + } +} + export function configRoutes() { const router = express(); @@ -114,6 +226,48 @@ export function configRoutes() { ), ); + router.post( + '/:id/config/requesters/github', + validate( + { + a, + params: { + id: Joi.number().integer().required(), + }, + body: {}, + }, + async (req, res) => { + const project = await getProjectFromIdAndCheckPermissions(req.params.id, req, res); + if (!project) return; + + const result = await updateGitHubActionsEnvironment(project); + if (result === GitHubActionsEnvironmentResult.APP_NOT_INSTALLED) { + return res.status(412).json({ error: 'App not installed' }); + } else if (result === GitHubActionsEnvironmentResult.UNKNOWN_ERROR) { + return res.status(500).json({ error: 'Unknown Error' }); + } + + const newProject = await withTransaction(async (t) => { + const config = await GitHubActionsRequesterConfig.create( + {}, + { + returning: true, + }, + ); + await project.resetAllRequesters(t); + project.requester_gitHub_id = config.id; + await project.save({ transaction: t }); + return await Project.findByPk(project.id, { + include: Project.allIncludes, + transaction: t, + }); + }); + + res.json(newProject); + }, + ), + ); + router.post( '/:id/config/responders/slack', validate( diff --git a/src/server/api/repo/index.ts b/src/server/api/repo/index.ts index 717532b..0d7ed31 100644 --- a/src/server/api/repo/index.ts +++ b/src/server/api/repo/index.ts @@ -90,6 +90,7 @@ export function repoRoutes() { repoOwner: p.repoOwner, defaultBranch: p.defaultBranch, requester_circleCI: !!p.requester_circleCI, + requester_gitHub: !!p.requester_gitHub, responder_slack: p.responder_slack ? { team: p.responder_slack.teamName, diff --git a/src/server/api/request/index.ts b/src/server/api/request/index.ts index b8926f1..77f4e46 100644 --- a/src/server/api/request/index.ts +++ b/src/server/api/request/index.ts @@ -2,11 +2,13 @@ import * as express from 'express'; import { createRequesterRoutes } from './requester'; import { CircleCIRequester } from '../../requesters/CircleCIRequester'; +import { GitHubActionsRequester } from '../../requesters/GitHubActionsRequester'; export function requestRoutes() { const router = express(); router.use(createRequesterRoutes(new CircleCIRequester())); + router.use(createRequesterRoutes(new GitHubActionsRequester())); return router; } diff --git a/src/server/api/request/requester.ts b/src/server/api/request/requester.ts index a99f331..ef9cd12 100644 --- a/src/server/api/request/requester.ts +++ b/src/server/api/request/requester.ts @@ -1,9 +1,6 @@ -import axios from 'axios'; import * as debug from 'debug'; import * as express from 'express'; import * as Joi from 'joi'; -import { Issuer } from 'openid-client'; -import * as jwkToPem from 'jwk-to-pem'; import * as jwt from 'jsonwebtoken'; import { createA } from '../../helpers/a'; @@ -13,6 +10,7 @@ import { Project, OTPRequest } from '../../db/models'; import { getResponderFor } from '../../responders'; import { Requester } from '../../requesters/Requester'; import { projectIsMissingConfig } from '../../../common/types'; +import { getSignatureValidatedOIDCClaims } from '../../helpers/oidc'; const d = debug('cfa:api:request:requester'); const a = createA(d); @@ -111,40 +109,20 @@ export function createRequesterRoutes(requester: Requester) { if (!config) return res.status(422).json({ error: 'Project is not configured to use this requester' }); - const disoveryUrl = await requester.getOpenIDConnectDiscoveryURL(project, config); - if (!disoveryUrl) - return res - .status(422) - .json({ error: 'Project is not eligible for OIDC credential exchange' }); - const issuer = await Issuer.discover(disoveryUrl); - - if (!issuer.metadata.jwks_uri) - return res - .status(422) - .json({ error: 'Project is not eligible for JWKS backed OIDC credential exchange' }); - const jwks = await axios.get(issuer.metadata.jwks_uri); - - if (jwks.status !== 200) - return res - .status(422) - .json({ error: 'Project is not eligible for JWKS backed OIDC credential exchange' }); - - let claims = jwt.decode(req.body.token, { complete: true }) as jwt.Jwt | null; - if (!claims) return res.status(422).json({ error: 'Invalid OIDC token provided' }); - const key = jwks.data.keys.find((key) => key.kid === claims!.header.kid); - - if (!key) return res.status(422).json({ error: 'Invalid kid found in the token provided' }); - - const pem = jwkToPem(key); + let claims: jwt.Jwt | null; try { - claims = jwt.verify(req.body.token, pem, { - complete: true, - algorithms: [key.alg], - }) as jwt.Jwt | null; - } catch { - return res - .status(422) - .json({ error: 'Could not verify the provided token against the OIDC provider' }); + claims = await getSignatureValidatedOIDCClaims( + requester, + project, + config, + req.body.token, + ); + } catch (err) { + if (typeof err === 'string') { + return res.status(422).json({ error: err }); + } else { + throw err; + } } if (!claims) return res.status(422).json({ error: 'Invalid OIDC token provided' }); @@ -161,7 +139,12 @@ export function createRequesterRoutes(requester: Requester) { let githubToken: string; try { - githubToken = await getGitHubAppInstallationToken(project); + githubToken = await getGitHubAppInstallationToken(project, { + metadata: 'read', + issues: 'write', + pull_requests: 'write', + contents: 'write', + }); } catch (err) { console.error(err); return res.status(422).json({ error: 'Failed to obtain access token for project' }); @@ -280,7 +263,10 @@ export function createRequesterRoutes(requester: Requester) { }); } - if (!(await requester.validateProofForRequest(request, config))) { + if ( + allowedState.needsLogBasedProof && + !(await requester.validateProofForRequest(request, config)) + ) { request.state = 'error'; request.errored = new Date(); request.errorReason = diff --git a/src/server/db/models.ts b/src/server/db/models.ts index 739a090..19f69df 100644 --- a/src/server/db/models.ts +++ b/src/server/db/models.ts @@ -68,16 +68,24 @@ export class Project extends Model, InferCreationAttrib requester_circleCI: CircleCIRequesterConfig | null; requester_circleCI_id: string | null; + @BelongsTo(() => GitHubActionsRequesterConfig, 'requester_gitHub_id') + requester_gitHub: GitHubActionsRequesterConfig | null; + requester_gitHub_id: string | null; + @BelongsTo(() => SlackResponderConfig, 'responder_slack_id') responder_slack: SlackResponderConfig | null; responder_slack_id: string | null; public async resetAllRequesters(t: Transaction) { this.requester_circleCI_id = null; + this.requester_gitHub_id = null; await this.save({ transaction: t }); if (this.requester_circleCI) { await this.requester_circleCI.destroy({ transaction: t }); } + if (this.requester_gitHub) { + await this.requester_gitHub.destroy({ transaction: t }); + } } public async resetAllResponders(t: Transaction) { @@ -89,10 +97,21 @@ export class Project extends Model, InferCreationAttrib } static get allIncludes() { - return [CircleCIRequesterConfig, SlackResponderConfig]; + return [CircleCIRequesterConfig, GitHubActionsRequesterConfig, SlackResponderConfig]; } } +@Table(tableOptions) +export class GitHubActionsRequesterConfig extends Model< + InferAttributes, + InferCreationAttributes +> { + @PrimaryKey + @Default(DataType.UUIDV4) + @Column(DataType.UUID) + id: CreationOptional; +} + @Table(tableOptions) export class CircleCIRequesterConfig extends Model< InferAttributes, @@ -320,12 +339,30 @@ const migrationFns: ((t: Transaction, qI: QueryInterface) => Promise)[] = ); } }, + async function addGitHubActionsRequester(t: Transaction, queryInterface: QueryInterface) { + const table: any = await queryInterface.describeTable(Project.getTableName()); + if (!table.requester_gitHub_id) { + await queryInterface.addColumn( + Project.getTableName() as string, + 'requester_gitHub_id', + { + type: DataType.UUID, + allowNull: true, + defaultValue: null, + }, + { + transaction: t, + }, + ); + } + }, ]; const initializeInstance = async (sequelize: Sequelize) => { sequelize.addModels([ Project, CircleCIRequesterConfig, + GitHubActionsRequesterConfig, SlackResponderConfig, SlackResponderLinker, OTPRequest, diff --git a/src/server/helpers/auth.ts b/src/server/helpers/auth.ts index 0e02663..d10b2ed 100644 --- a/src/server/helpers/auth.ts +++ b/src/server/helpers/auth.ts @@ -1,9 +1,10 @@ -import { createAppAuth } from '@octokit/auth-app'; +import { StrategyOptions, createAppAuth } from '@octokit/auth-app'; import { Octokit } from '@octokit/rest'; import { Project } from '../db/models'; +import { Permissions } from '@octokit/auth-app/dist-types/types'; -export const getGitHubAppInstallationToken = async (project: Project) => { +export const getGitHubAppInstallationToken = async (project: Project, permissions: Permissions) => { const appCredentials = { appId: process.env.GITHUB_APP_ID!, privateKey: process.env.GITHUB_PRIVATE_KEY!, @@ -26,6 +27,7 @@ export const getGitHubAppInstallationToken = async (project: Project) => { ...appCredentials, installationId: installation.data.id, repositoryNames: [project.repoName], + permissions, }; const { token } = await createAppAuth(authOptions)(authOptions); return token; diff --git a/src/server/helpers/oidc.ts b/src/server/helpers/oidc.ts new file mode 100644 index 0000000..228c43b --- /dev/null +++ b/src/server/helpers/oidc.ts @@ -0,0 +1,38 @@ +import axios from 'axios'; +import * as jwt from 'jsonwebtoken'; +import * as jwkToPem from 'jwk-to-pem'; + +import { Project } from '../db/models'; +import { Requester } from '../requesters/Requester'; +import { Issuer } from 'openid-client'; + +export const getSignatureValidatedOIDCClaims = async ( + requester: Requester, + project: Project, + config: R, + token: string, +): Promise => { + const discoveryUrl = await requester.getOpenIDConnectDiscoveryURL(project, config); + if (!discoveryUrl) throw 'Project is not eligible for OIDC credential exchange'; + const issuer = await Issuer.discover(discoveryUrl); + + if (!issuer.metadata.jwks_uri) + throw 'Project is not eligible for JWKS backed OIDC credential exchange'; + const jwks = await axios.get(issuer.metadata.jwks_uri); + + if (jwks.status !== 200) throw 'Project is not eligible for JWKS backed OIDC credential exchange'; + + let claims = jwt.decode(token, { complete: true }) as jwt.Jwt | null; + if (!claims) throw 'Invalid OIDC token provided'; + const key = jwks.data.keys.find((key) => key.kid === claims!.header.kid); + + if (!key) throw 'Invalid kid found in the token provided'; + + const pem = jwkToPem(key); + try { + claims = jwt.verify(token, pem, { complete: true, algorithms: [key.alg] }) as jwt.Jwt | null; + } catch { + throw 'Could not verify the provided token against the OIDC provider'; + } + return claims; +}; diff --git a/src/server/index.ts b/src/server/index.ts index c6b4c95..57ecc53 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -22,10 +22,12 @@ const REDIS_URL = process.env.REDIS_URL!; const redisClient = redis.createClient({ url: REDIS_URL, - socket: { - tls: true, - rejectUnauthorized: false, - }, + socket: process.env.NO_DB_SSL + ? undefined + : { + tls: true, + rejectUnauthorized: false, + }, }); redisClient.connect().catch((err) => { console.error(err); diff --git a/src/server/requesters/CircleCIRequester.ts b/src/server/requesters/CircleCIRequester.ts index 6e7da04..20542f3 100644 --- a/src/server/requesters/CircleCIRequester.ts +++ b/src/server/requesters/CircleCIRequester.ts @@ -111,7 +111,10 @@ export class CircleCIRequester // Must be on the default branch if (build.vcs_tag) { - const token = await getGitHubAppInstallationToken(project); + const token = await getGitHubAppInstallationToken(project, { + metadata: 'read', + contents: 'read', + }); const github = new Octokit({ auth: token }); const comparison = await github.repos.compareCommitsWithBasehead({ @@ -159,6 +162,7 @@ export class CircleCIRequester return { ok: true, + needsLogBasedProof: true, }; } diff --git a/src/server/requesters/GitHubActionsRequester.ts b/src/server/requesters/GitHubActionsRequester.ts new file mode 100644 index 0000000..064576c --- /dev/null +++ b/src/server/requesters/GitHubActionsRequester.ts @@ -0,0 +1,253 @@ +import { Octokit } from '@octokit/rest'; +import axios from 'axios'; +import { Request, Response } from 'express'; +import * as Joi from 'joi'; +import * as jwt from 'jsonwebtoken'; + +import { Requester, AllowedState } from './Requester'; +import { Project, GitHubActionsRequesterConfig, OTPRequest } from '../db/models'; +import { getGitHubAppInstallationToken } from '../helpers/auth'; +import { RequestInformation } from '../responders/Responder'; +import { CFA_RELEASE_GITHUB_ENVIRONMENT_NAME } from '../api/project/config'; +import { getSignatureValidatedOIDCClaims } from '../helpers/oidc'; + +type GitHubActionsOTPRequestMetadata = { + oidcToken: string; + buildUrl: string; +}; + +const validateMetadataObject = (object: any) => { + return Joi.validate(object, { + oidcToken: Joi.string().min(1).required(), + buildUrl: Joi.string() + .uri({ + scheme: 'https:', + }) + .required(), + }); +}; + +export class GitHubActionsRequester + implements Requester +{ + readonly slug = 'github'; + + getConfigForProject(project: Project) { + return project.requester_gitHub || null; + } + + async getOpenIDConnectDiscoveryURL(project: Project, config: GitHubActionsRequesterConfig) { + return 'https://token.actions.githubusercontent.com'; + } + + async doOpenIDConnectClaimsMatchProject( + claims: jwt.JwtPayload, + project: Project, + config: GitHubActionsRequesterConfig, + ) { + const internal = await this.doOpenIDConnectClaimsMatchProjectInternal(claims, project); + if (!internal.ok) { + console.error( + `Failed to match OIDC claims to project(${project.repoOwner}/${project.repoName}):`, + claims, + internal, + ); + } + return internal.ok; + } + + private async doOpenIDConnectClaimsMatchProjectInternal( + claims: jwt.JwtPayload, + project: Project, + ): Promise { + if (claims.aud !== 'continuousauth.dev') { + return { ok: false, error: 'Token audience is not correct' }; + } + // Wrong repository + if (claims.repository_id !== project.id) + return { ok: false, error: 'GitHub Actions build is for incorrect repository id' }; + + // Wrong repository name (probably out of date) + if (claims.repository_owner !== project.repoOwner) + return { ok: false, error: 'GitHub Actions build is for incorrect repository owner' }; + if (claims.repository !== `${project.repoOwner}/${project.repoName}`) + return { ok: false, error: 'GitHub Actions build is for incorrect repository' }; + + // Must be running int he right environment + if ( + claims.sub !== + `repo:${project.repoOwner}/${project.repoName}:environment:${CFA_RELEASE_GITHUB_ENVIRONMENT_NAME}` + ) + return { ok: false, error: 'GitHub Actions build is for incorrect environment' }; + + // Must be on the default branch + if (claims.ref.startsWith('refs/tags/')) { + const token = await getGitHubAppInstallationToken(project, { + metadata: 'read', + contents: 'read', + }); + const github = new Octokit({ auth: token }); + + const comparison = await github.repos.compareCommitsWithBasehead({ + owner: project.repoOwner, + repo: project.repoName, + // Use sha instead of ref here to ensure no malicious race between job start and + // ref re-point + basehead: `${claims.sha}...${project.defaultBranch}`, + }); + + if ( + comparison.status !== 200 || + !(comparison.data.behind_by === 0 && comparison.data.ahead_by >= 0) + ) { + return { + ok: false, + error: 'GitHub Actions build is for a tag not on the default branch', + }; + } + } else if (claims.ref !== `refs/heads/${project.defaultBranch}`) { + return { + ok: false, + error: 'GitHub Actions build is not for the default branch', + }; + } + + // Trigger must be GitHub + // Check event_name must be push + // Some repos use workflow_dispatch, those cases can be handled during migration + // as it requires more though + if (claims.event_name !== 'push') + return { + ok: false, + error: 'GitHub Actions build was triggered by not-a-push', + }; + + // Build must be currently running + // Hit API using claims.run_id, run_number and run_attempt + const token = await getGitHubAppInstallationToken(project, { + metadata: 'read', + contents: 'read', + }); + const github = new Octokit({ auth: token }); + let isStillRunning = false; + try { + const workflowRunAttempt = await github.actions.getWorkflowRunAttempt({ + owner: project.repoOwner, + repo: project.repoName, + run_id: claims.run_id, + attempt_number: claims.run_attempt, + }); + isStillRunning = workflowRunAttempt.data.status === 'in_progress'; + } catch { + isStillRunning = false; + } + if (!isStillRunning) + return { + ok: false, + error: 'GitHub Actions build is not running', + }; + + // SSH must be disabled for safety + // We should be able to check this when GitHub releases actions ssh + // for now just checking we're running on github infra is enough + if (claims.runner_environment !== 'github-hosted') + return { + ok: false, + error: 'GitHub Actions build could have SSH enabled, this is not allowed', + }; + + return { ok: true, needsLogBasedProof: false }; + } + + async metadataForInitialRequest( + req: Request, + res: Response, + ): Promise { + const result = validateMetadataObject(req.body); + if (result.error) { + res.status(400).json({ + error: 'Request Validation Error', + message: result.error.message, + }); + return null; + } + + return { + oidcToken: result.value.oidcToken, + buildUrl: result.value.buildUrl, + }; + } + + async validateActiveRequest( + request: OTPRequest, + config: GitHubActionsRequesterConfig, + ): Promise { + const { project } = request; + + // validate and parse claims from request + let claims: jwt.Jwt | null; + try { + claims = await getSignatureValidatedOIDCClaims( + this, + project, + config, + request.requestMetadata.oidcToken, + ); + } catch (err) { + if (typeof err === 'string') { + return { + ok: false, + error: err, + }; + } + claims = null; + } + + if (!claims) { + return { + ok: false, + error: 'Failed to validate OIDC token', + }; + } + + const claimsEvaluation = await this.doOpenIDConnectClaimsMatchProjectInternal(claims, project); + if (!claimsEvaluation.ok) { + return { + ok: false, + error: claimsEvaluation.error, + }; + } + + return { + ok: true, + needsLogBasedProof: false, + }; + } + + async validateProofForRequest( + request: OTPRequest, + config: GitHubActionsRequesterConfig, + ): Promise { + // Not needed, default closed + return false; + } + + async isOTPRequestValidForRequester( + request: OTPRequest, + ): Promise | null> { + const result = validateMetadataObject(request.requestMetadata); + if (result.error) return null; + return request as any; + } + + async getRequestInformationToPassOn( + request: OTPRequest, + ): Promise { + const { project } = request; + + return { + description: `GitHub Actions Build for ${project.repoOwner}/${project.repoName}`, + url: request.requestMetadata.buildUrl, + }; + } +} diff --git a/src/server/requesters/Requester.ts b/src/server/requesters/Requester.ts index 346d3a3..5ded280 100644 --- a/src/server/requesters/Requester.ts +++ b/src/server/requesters/Requester.ts @@ -7,6 +7,7 @@ import { RequestInformation } from '../responders/Responder'; export type AllowedState = | { ok: true; + needsLogBasedProof: boolean; } | { ok: false; diff --git a/yarn.lock b/yarn.lock index 6c723ba..b9b69d9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1490,6 +1490,11 @@ resolved "https://registry.yarnpkg.com/@types/jwk-to-pem/-/jwk-to-pem-2.0.1.tgz#ba6949f447e02cb7bebf101551e3a4dea5f4fde4" integrity sha512-QXmRPhR/LPzvXBHTPfG2BBfMTkNLUD7NyRcPft8m5xFCeANa1BZyLgT0Gw+OxdWx6i1WCpT27EqyggP4UUHMrA== +"@types/libsodium-wrappers@^0.7.14": + version "0.7.14" + resolved "https://registry.yarnpkg.com/@types/libsodium-wrappers/-/libsodium-wrappers-0.7.14.tgz#f688f8d44e46ed61c401f82ff757581655fbcc42" + integrity sha512-5Kv68fXuXK0iDuUir1WPGw2R9fOZUlYlSAa0ztMcL0s0BfIDTqg9GXz8K30VJpPP3sxWhbolnQma2x+/TfkzDQ== + "@types/mime@*": version "3.0.1" resolved "https://registry.yarnpkg.com/@types/mime/-/mime-3.0.1.tgz#5f8f2bca0a5863cb69bc0b0acd88c96cb1d4ae10" @@ -5446,6 +5451,18 @@ levn@~0.3.0: prelude-ls "~1.1.2" type-check "~0.3.2" +libsodium-wrappers@^0.7.13: + version "0.7.13" + resolved "https://registry.yarnpkg.com/libsodium-wrappers/-/libsodium-wrappers-0.7.13.tgz#83299e06ee1466057ba0e64e532777d2929b90d3" + integrity sha512-kasvDsEi/r1fMzKouIDv7B8I6vNmknXwGiYodErGuESoFTohGSKZplFtVxZqHaoQ217AynyIFgnOVRitpHs0Qw== + dependencies: + libsodium "^0.7.13" + +libsodium@^0.7.13: + version "0.7.13" + resolved "https://registry.yarnpkg.com/libsodium/-/libsodium-0.7.13.tgz#230712ec0b7447c57b39489c48a4af01985fb393" + integrity sha512-mK8ju0fnrKXXfleL53vtp9xiPq5hKM0zbDQtcxQIsSmxNgSxqCj6R7Hl9PkrNe2j29T4yoDaF7DJLK9/i5iWUw== + lilconfig@^2.0.3: version "2.0.6" resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.0.6.tgz#32a384558bd58af3d4c6e077dd1ad1d397bc69d4"