diff --git a/packages/decap-cms-app/package.json b/packages/decap-cms-app/package.json index 2e6579faca87..ca8a105b2e27 100644 --- a/packages/decap-cms-app/package.json +++ b/packages/decap-cms-app/package.json @@ -32,6 +32,7 @@ "dayjs": "^1.11.10", "decap-cms-backend-azure": "^3.1.0-beta.0", "decap-cms-backend-bitbucket": "^3.1.0-beta.0", + "decap-cms-backend-aws-cognito-github-proxy": "^3.1.0-beta.0", "decap-cms-backend-git-gateway": "^3.1.0-beta.0", "decap-cms-backend-github": "^3.1.0-beta.1", "decap-cms-backend-gitlab": "^3.1.0-beta.0", diff --git a/packages/decap-cms-app/src/extensions.js b/packages/decap-cms-app/src/extensions.js index 92fc0bbcbcbc..eef9ebd77063 100644 --- a/packages/decap-cms-app/src/extensions.js +++ b/packages/decap-cms-app/src/extensions.js @@ -2,6 +2,7 @@ import { DecapCmsCore as CMS } from 'decap-cms-core'; // Backends import { AzureBackend } from 'decap-cms-backend-azure'; +import { AwsCognitoGitHubProxyBackend } from 'decap-cms-backend-aws-cognito-github-proxy'; import { GitHubBackend } from 'decap-cms-backend-github'; import { GitLabBackend } from 'decap-cms-backend-gitlab'; import { GiteaBackend } from 'decap-cms-backend-gitea'; @@ -33,6 +34,7 @@ import * as locales from 'decap-cms-locales'; // Register all the things CMS.registerBackend('git-gateway', GitGatewayBackend); CMS.registerBackend('azure', AzureBackend); +CMS.registerBackend('aws-cognito-github-proxy', AwsCognitoGitHubProxyBackend); CMS.registerBackend('github', GitHubBackend); CMS.registerBackend('gitlab', GitLabBackend); CMS.registerBackend('gitea', GiteaBackend); diff --git a/packages/decap-cms-backend-aws-cognito-github-proxy/README.md b/packages/decap-cms-backend-aws-cognito-github-proxy/README.md new file mode 100644 index 000000000000..d8cde5870057 --- /dev/null +++ b/packages/decap-cms-backend-aws-cognito-github-proxy/README.md @@ -0,0 +1,9 @@ +# GitHub backend + +An abstraction layer between the CMS and a proxied version of [Github](https://docs.github.com/en/rest). + +## Code structure + +`Implementation` - wraps [Github Backend](https://github.com/decaporg/decap-cms/tree/master/packages/decap-cms-lib-auth/README.md) for proxied version of Github. + +`AuthenticationPage` - uses [lib-auth](https://github.com/decaporg/decap-cms/tree/master/packages/decap-cms-lib-auth/README.md) to create an AWS Cognito compatible generic Authentication page supporting PKCE. diff --git a/packages/decap-cms-backend-aws-cognito-github-proxy/package.json b/packages/decap-cms-backend-aws-cognito-github-proxy/package.json new file mode 100644 index 000000000000..e7ed4fb41771 --- /dev/null +++ b/packages/decap-cms-backend-aws-cognito-github-proxy/package.json @@ -0,0 +1,45 @@ +{ + "name": "decap-cms-backend-aws-cognito-github-proxy", + "description": "GitHub backend for Decap CMS proxied through AWS Cognito", + "version": "3.1.0-beta.1", + "license": "MIT", + "repository": "https://github.com/decaporg/decap-cms/tree/master/packages/decap-cms-backend-aws-cognito-github-proxy", + "bugs": "https://github.com/decaporg/decap-cms/issues", + "module": "dist/esm/index.js", + "main": "dist/decap-cms-backend-aws-cognito-github-proxy.js", + "keywords": [ + "decap-cms", + "backend", + "github", + "aws-cognito" + ], + "sideEffects": false, + "scripts": { + "develop": "yarn build:esm --watch", + "build": "cross-env NODE_ENV=production webpack", + "build:esm": "cross-env NODE_ENV=esm babel src --out-dir dist/esm --ignore \"**/__tests__\" --root-mode upward --extensions \".js,.jsx,.ts,.tsx\"", + "createFragmentTypes": "node scripts/createFragmentTypes.js" + }, + "dependencies": { + "apollo-cache-inmemory": "^1.6.2", + "apollo-client": "^2.6.3", + "apollo-link-context": "^1.0.18", + "apollo-link-http": "^1.5.15", + "common-tags": "^1.8.0", + "graphql": "^15.0.0", + "graphql-tag": "^2.10.1", + "js-base64": "^3.0.0", + "semaphore": "^1.1.0" + }, + "peerDependencies": { + "@emotion/react": "^11.11.1", + "@emotion/styled": "^11.11.0", + "decap-cms-lib-auth": "^3.0.0", + "decap-cms-backend-github": "^3.0.0", + "decap-cms-lib-util": "^3.0.0", + "decap-cms-ui-default": "^3.0.0", + "lodash": "^4.17.11", + "prop-types": "^15.7.2", + "react": "^18.2.0" + } +} diff --git a/packages/decap-cms-backend-aws-cognito-github-proxy/src/AuthenticationPage.js b/packages/decap-cms-backend-aws-cognito-github-proxy/src/AuthenticationPage.js new file mode 100644 index 000000000000..94ce2974fe6a --- /dev/null +++ b/packages/decap-cms-backend-aws-cognito-github-proxy/src/AuthenticationPage.js @@ -0,0 +1,76 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import styled from '@emotion/styled'; +import { PkceAuthenticator } from 'decap-cms-lib-auth'; +import { AuthenticationPage, Icon } from 'decap-cms-ui-default'; + +const LoginButtonIcon = styled(Icon)` + margin-right: 18px; +`; + +export default class GenericPKCEAuthenticationPage extends React.Component { + static propTypes = { + inProgress: PropTypes.bool, + config: PropTypes.object.isRequired, + onLogin: PropTypes.func.isRequired, + t: PropTypes.func.isRequired, + }; + + state = {}; + + componentDidMount() { + const { + base_url = '', + app_id = '', + auth_endpoint = 'oauth2/authorize', + auth_token_endpoint = 'oauth2/token', + redirect_uri = document.location.origin + document.location.pathname, + } = this.props.config.backend; + this.auth = new PkceAuthenticator({ + base_url, + auth_endpoint, + app_id, + auth_token_endpoint, + redirect_uri, + auth_token_endpoint_content_type: 'application/x-www-form-urlencoded; charset=utf-8', + }); + // Complete authentication if we were redirected back to from the provider. + this.auth.completeAuth((err, data) => { + if (err) { + this.setState({ loginError: err.toString() }); + return; + } + this.props.onLogin(data); + }); + } + + handleLogin = e => { + e.preventDefault(); + this.auth.authenticate({ scope: 'https://api.github.com/repo openid email' }, (err, data) => { + if (err) { + this.setState({ loginError: err.toString() }); + return; + } + this.props.onLogin(data); + }); + }; + + render() { + const { inProgress, config, t } = this.props; + return ( + ( + + {inProgress ? t('auth.loggingIn') : t('auth.login')} + + )} + t={t} + /> + ); + } +} diff --git a/packages/decap-cms-backend-aws-cognito-github-proxy/src/implementation.tsx b/packages/decap-cms-backend-aws-cognito-github-proxy/src/implementation.tsx new file mode 100644 index 000000000000..9b5288ce4154 --- /dev/null +++ b/packages/decap-cms-backend-aws-cognito-github-proxy/src/implementation.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import { GitHubBackend } from 'decap-cms-backend-github'; + +import AuthenticationPage from './AuthenticationPage'; + +import type { GitHubUser } from 'decap-cms-backend-github/src/implementation'; +import type { Config } from 'decap-cms-lib-util/src'; + +export default class AwsCognitoGitHubProxyBackend extends GitHubBackend { + constructor(config: Config, options = {}) { + super(config, options); + + this.bypassWriteAccessCheckForAppTokens = true; + this.tokenKeyword = 'Bearer'; + } + + authComponent() { + const wrappedAuthenticationPage = (props: Record) => ( + + ); + wrappedAuthenticationPage.displayName = 'AuthenticationPage'; + return wrappedAuthenticationPage; + } + + async currentUser({ token }: { token: string }): Promise { + if (!this._currentUserPromise) { + this._currentUserPromise = fetch(this.baseUrl + '/oauth2/userInfo', { + headers: { + Authorization: `${this.tokenKeyword} ${token}`, + }, + }).then(res => + res.json().then(res => { + const owner = this.originRepo.split('/')[1]; + return { + name: res.email, + login: owner, + avatar_url: `https://github.com/${owner}.png`, + } as GitHubUser; + }), + ); + } + return this._currentUserPromise; + } +} diff --git a/packages/decap-cms-backend-aws-cognito-github-proxy/src/index.ts b/packages/decap-cms-backend-aws-cognito-github-proxy/src/index.ts new file mode 100644 index 000000000000..1a00e3f445c2 --- /dev/null +++ b/packages/decap-cms-backend-aws-cognito-github-proxy/src/index.ts @@ -0,0 +1,12 @@ +import { API } from 'decap-cms-backend-github'; + +import AwsCognitoGitHubProxyBackend from './implementation'; +import AuthenticationPage from './AuthenticationPage'; + +export const DecapCmsBackendAwsCognitoGithubProxy = { + AwsCognitoGitHubProxyBackend, + API, + AuthenticationPage, +}; + +export { AwsCognitoGitHubProxyBackend, API, AuthenticationPage }; diff --git a/packages/decap-cms-backend-aws-cognito-github-proxy/webpack.config.js b/packages/decap-cms-backend-aws-cognito-github-proxy/webpack.config.js new file mode 100644 index 000000000000..42edd361d4a7 --- /dev/null +++ b/packages/decap-cms-backend-aws-cognito-github-proxy/webpack.config.js @@ -0,0 +1,3 @@ +const { getConfig } = require('../../scripts/webpack.js'); + +module.exports = getConfig(); diff --git a/packages/decap-cms-backend-git-gateway/src/GitHubAPI.ts b/packages/decap-cms-backend-git-gateway/src/GitHubAPI.ts index 10a2bfa11f0f..70cc9f8e2289 100644 --- a/packages/decap-cms-backend-git-gateway/src/GitHubAPI.ts +++ b/packages/decap-cms-backend-git-gateway/src/GitHubAPI.ts @@ -5,7 +5,7 @@ import type { Config as GitHubConfig, Diff } from 'decap-cms-backend-github/src/ import type { FetchError } from 'decap-cms-lib-util'; import type { Octokit } from '@octokit/rest'; -type Config = GitHubConfig & { +type Config = Omit & { apiRoot: string; tokenPromise: () => Promise; commitAuthor: { name: string }; @@ -18,7 +18,10 @@ export default class API extends GithubAPI { isLargeMedia: (filename: string) => Promise; constructor(config: Config) { - super(config); + super({ + getUser: () => Promise.reject('Never used'), + ...config, + }); this.apiRoot = config.apiRoot; this.tokenPromise = config.tokenPromise; this.commitAuthor = config.commitAuthor; diff --git a/packages/decap-cms-backend-github/src/API.ts b/packages/decap-cms-backend-github/src/API.ts index 620fc47da146..a044342c5fca 100644 --- a/packages/decap-cms-backend-github/src/API.ts +++ b/packages/decap-cms-backend-github/src/API.ts @@ -50,7 +50,7 @@ export const MOCK_PULL_REQUEST = -1; export interface Config { apiRoot?: string; token?: string; - token_keyword?: string; + tokenKeyword?: string; branch?: string; useOpenAuthoring?: boolean; repo?: string; @@ -58,6 +58,8 @@ export interface Config { squashMerges: boolean; initialWorkflowStatus: string; cmsLabelPrefix: string; + baseUrl?: string; + getUser: ({ token }: { token: string }) => Promise; } interface TreeFile { @@ -174,7 +176,7 @@ let migrationNotified = false; export default class API { apiRoot: string; token: string; - token_keyword: string; + tokenKeyword: string; branch: string; useOpenAuthoring?: boolean; repo: string; @@ -188,7 +190,8 @@ export default class API { mergeMethod: string; initialWorkflowStatus: string; cmsLabelPrefix: string; - + baseUrl?: string; + getUser: ({ token }: { token: string }) => Promise; _userPromise?: Promise; _metadataSemaphore?: Semaphore; @@ -197,7 +200,7 @@ export default class API { constructor(config: Config) { this.apiRoot = config.apiRoot || 'https://api.github.com'; this.token = config.token || ''; - this.token_keyword = config.token_keyword || 'token'; + this.tokenKeyword = config.tokenKeyword || 'token'; this.branch = config.branch || 'master'; this.useOpenAuthoring = config.useOpenAuthoring; this.repo = config.repo || ''; @@ -216,21 +219,19 @@ export default class API { this.mergeMethod = config.squashMerges ? 'squash' : 'merge'; this.cmsLabelPrefix = config.cmsLabelPrefix; this.initialWorkflowStatus = config.initialWorkflowStatus; + this.baseUrl = config.baseUrl; + this.getUser = config.getUser; } static DEFAULT_COMMIT_MESSAGE = 'Automatically generated by Decap CMS'; user(): Promise<{ name: string; login: string }> { if (!this._userPromise) { - this._userPromise = this.getUser(); + this._userPromise = this.getUser({ token: this.token }); } return this._userPromise; } - getUser() { - return this.request('/user') as Promise; - } - async hasWriteAccess() { try { const result: Octokit.ReposGetResponse = await this.request(this.repoURL); @@ -254,7 +255,7 @@ export default class API { }; if (this.token) { - baseHeader.Authorization = `${this.token_keyword} ${this.token}`; + baseHeader.Authorization = `${this.tokenKeyword} ${this.token}`; return Promise.resolve(baseHeader); } @@ -579,7 +580,7 @@ export default class API { } try { - const user: GitHubUser = await this.request(`/users/${pullRequest.user.login}`); + const user = await this.user(); return user.name || user.login; } catch { return; diff --git a/packages/decap-cms-backend-github/src/GraphQLAPI.ts b/packages/decap-cms-backend-github/src/GraphQLAPI.ts index 9b694fcad6dd..74ab3f172b01 100644 --- a/packages/decap-cms-backend-github/src/GraphQLAPI.ts +++ b/packages/decap-cms-backend-github/src/GraphQLAPI.ts @@ -108,7 +108,7 @@ export default class GraphQLAPI extends API { headers: { 'Content-Type': 'application/json; charset=utf-8', ...headers, - authorization: this.token ? `${this.token_keyword} ${this.token}` : '', + authorization: this.token ? `${this.tokenKeyword} ${this.token}` : '', }, }; }); @@ -197,7 +197,6 @@ export default class GraphQLAPI extends API { fetchPolicy: CACHE_FIRST, // we can assume permission doesn't change often }); // https://developer.github.com/v4/enum/repositorypermission/ - console.log(data) const { viewerPermission } = data.repository; return ['ADMIN', 'MAINTAIN', 'WRITE'].includes(viewerPermission); } catch (error) { diff --git a/packages/decap-cms-backend-github/src/implementation.tsx b/packages/decap-cms-backend-github/src/implementation.tsx index dda41916c4f7..f5412b974c2d 100644 --- a/packages/decap-cms-backend-github/src/implementation.tsx +++ b/packages/decap-cms-backend-github/src/implementation.tsx @@ -42,7 +42,7 @@ import type { } from 'decap-cms-lib-util'; import type { Semaphore } from 'semaphore'; -type GitHubUser = Octokit.UsersGetAuthenticatedResponse; +export type GitHubUser = Octokit.UsersGetAuthenticatedResponse; const MAX_CONCURRENT_DOWNLOADS = 10; @@ -78,10 +78,12 @@ export default class GitHub implements Implementation { mediaFolder: string; previewContext: string; token: string | null; - token_keyword: string | null; + tokenKeyword: string; squashMerges: boolean; cmsLabelPrefix: string; useGraphql: boolean; + baseUrl?: string; + bypassWriteAccessCheckForAppTokens = false; _currentUserPromise?: Promise; _userIsOriginMaintainerPromises?: { [key: string]: Promise; @@ -120,7 +122,8 @@ export default class GitHub implements Implementation { this.branch = config.backend.branch?.trim() || 'master'; this.apiRoot = config.backend.api_root || 'https://api.github.com'; this.token = ''; - this.token_keyword = config.backend.token_keyword || 'token'; + this.tokenKeyword = 'token'; + this.baseUrl = config.backend.base_url; this.squashMerges = config.backend.squash_merges || false; this.cmsLabelPrefix = config.backend.cms_label_prefix || ''; this.useGraphql = config.backend.use_graphql || false; @@ -155,7 +158,7 @@ export default class GitHub implements Implementation { if (api) { auth = (await this.api - ?.getUser() + ?.getUser({ token: this.token ?? '' }) .then(user => !!user) .catch(e => { console.warn('Failed getting GitHub user', e); @@ -187,7 +190,7 @@ export default class GitHub implements Implementation { let repoExists = false; while (!repoExists) { repoExists = await fetch(`${this.apiRoot}/repos/${repo}`, { - headers: { Authorization: `${this.token_keyword} ${token}` }, + headers: { Authorization: `${this.tokenKeyword} ${token}` }, }) .then(() => true) .catch(err => { @@ -210,7 +213,7 @@ export default class GitHub implements Implementation { if (!this._currentUserPromise) { this._currentUserPromise = fetch(`${this.apiRoot}/user`, { headers: { - Authorization: `${this.token_keyword} ${token}`, + Authorization: `${this.tokenKeyword} ${token}`, }, }).then(res => res.json()); } @@ -231,7 +234,7 @@ export default class GitHub implements Implementation { `${this.apiRoot}/repos/${this.originRepo}/collaborators/${username}/permission`, { headers: { - Authorization: `${this.token_keyword} ${token}`, + Authorization: `${this.tokenKeyword} ${token}`, }, }, ) @@ -248,7 +251,7 @@ export default class GitHub implements Implementation { const repo = await fetch(`${this.apiRoot}/repos/${currentUser.login}/${repoName}`, { method: 'GET', headers: { - Authorization: `${this.token_keyword} ${token}`, + Authorization: `${this.tokenKeyword} ${token}`, }, }).then(res => res.json()); @@ -296,7 +299,7 @@ export default class GitHub implements Implementation { return fetch(`${this.apiRoot}/repos/${this.repo}/merge-upstream`, { method: 'POST', headers: { - Authorization: `${this.token_keyword} ${token}`, + Authorization: `${this.tokenKeyword} ${token}`, }, body: JSON.stringify({ branch: this.branch, @@ -308,7 +311,7 @@ export default class GitHub implements Implementation { const fork = await fetch(`${this.apiRoot}/repos/${this.originRepo}/forks`, { method: 'POST', headers: { - Authorization: `${this.token_keyword} ${token}`, + Authorization: `${this.tokenKeyword} ${token}`, }, }).then(res => res.json()); return this.pollUntilForkExists({ repo: fork.full_name, token }); @@ -320,7 +323,7 @@ export default class GitHub implements Implementation { const apiCtor = this.useGraphql ? GraphQLAPI : API; this.api = new apiCtor({ token: this.token, - token_keyword: this.token_keyword || 'token', + tokenKeyword: this.tokenKeyword, branch: this.branch, repo: this.repo, originRepo: this.originRepo, @@ -329,6 +332,8 @@ export default class GitHub implements Implementation { cmsLabelPrefix: this.cmsLabelPrefix, useOpenAuthoring: this.useOpenAuthoring, initialWorkflowStatus: this.options.initialWorkflowStatus, + baseUrl: this.baseUrl, + getUser: this.currentUser, }); const user = await this.api!.user(); const isCollab = await this.api!.hasWriteAccess().catch(error => { @@ -345,7 +350,7 @@ export default class GitHub implements Implementation { }); // Unauthorized user - if (!isCollab) { + if (!isCollab && !this.bypassWriteAccessCheckForAppTokens) { throw new Error('Your GitHub user account does not have access to this repo.'); } diff --git a/packages/decap-cms-lib-auth/src/pkce-oauth.js b/packages/decap-cms-lib-auth/src/pkce-oauth.js index 99d5b17d7156..52d0d3dee07c 100644 --- a/packages/decap-cms-lib-auth/src/pkce-oauth.js +++ b/packages/decap-cms-lib-auth/src/pkce-oauth.js @@ -124,10 +124,9 @@ export default class PkceAuthenticator { const response = await fetch(authURL.href, { method: 'POST', - body: - this.auth_token_endpoint_content_type.startsWith('application/x-www-form-urlencoded') - ? new URLSearchParams(Object.entries(token_request_body_object)).toString() - : JSON.stringify(token_request_body_object), + body: this.auth_token_endpoint_content_type.startsWith('application/x-www-form-urlencoded') + ? new URLSearchParams(Object.entries(token_request_body_object)).toString() + : JSON.stringify(token_request_body_object), headers: { 'Content-Type': this.auth_token_endpoint_content_type, }, diff --git a/packages/decap-cms-lib-util/src/implementation.ts b/packages/decap-cms-lib-util/src/implementation.ts index edbcf4ec1f8c..8b8a05cfe0a0 100644 --- a/packages/decap-cms-lib-util/src/implementation.ts +++ b/packages/decap-cms-lib-util/src/implementation.ts @@ -110,8 +110,8 @@ export type Config = { use_large_media_transforms_in_media_library?: boolean; proxy_url?: string; auth_type?: string; - token_keyword?: string; app_id?: string; + base_url?: string; cms_label_prefix?: string; api_version?: string; };