From 4f419dd27101c4db89d7f227b7a8bd9ac4b52fce Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?S=C3=A9amus=20=C3=93=20Ceanainn?=
 <seamusoceanainn@gmail.com>
Date: Tue, 30 Jan 2024 14:07:27 +0000
Subject: [PATCH] AWS Cognito Proxy for Github (#7014)

* feat: support using 'Bearer' keyword instead of 'token' for Github backend

* feat: add additional configuration options to PKCE authenticator

* feat: add working AWS proxy and update Github and Git Gateway implementations to allow for it

---------

Co-authored-by: Seamus O Ceanainn <seamus@whatnot.com>
---
 packages/decap-cms-app/package.json           |  1 +
 packages/decap-cms-app/src/extensions.js      |  2 +
 .../README.md                                 |  9 +++
 .../package.json                              | 45 +++++++++++
 .../src/AuthenticationPage.js                 | 76 +++++++++++++++++++
 .../src/implementation.tsx                    | 47 ++++++++++++
 .../src/index.ts                              | 12 +++
 .../webpack.config.js                         |  3 +
 .../src/GitHubAPI.ts                          |  7 +-
 .../src/AuthenticationPage.js                 |  2 +
 packages/decap-cms-backend-github/src/API.ts  | 20 +++--
 .../src/GraphQLAPI.ts                         |  2 +-
 .../src/implementation.tsx                    | 26 ++++---
 .../src/AuthenticationPage.js                 |  9 ++-
 packages/decap-cms-lib-auth/src/pkce-oauth.js | 33 +++++---
 .../decap-cms-lib-util/src/implementation.ts  |  1 +
 16 files changed, 263 insertions(+), 32 deletions(-)
 create mode 100644 packages/decap-cms-backend-aws-cognito-github-proxy/README.md
 create mode 100644 packages/decap-cms-backend-aws-cognito-github-proxy/package.json
 create mode 100644 packages/decap-cms-backend-aws-cognito-github-proxy/src/AuthenticationPage.js
 create mode 100644 packages/decap-cms-backend-aws-cognito-github-proxy/src/implementation.tsx
 create mode 100644 packages/decap-cms-backend-aws-cognito-github-proxy/src/index.ts
 create mode 100644 packages/decap-cms-backend-aws-cognito-github-proxy/webpack.config.js

diff --git a/packages/decap-cms-app/package.json b/packages/decap-cms-app/package.json
index 1f5413e55595..1f7dcde7f876 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 (
+      <AuthenticationPage
+        onLogin={this.handleLogin}
+        loginDisabled={inProgress}
+        loginErrorMessage={this.state.loginError}
+        logoUrl={config.logo_url}
+        siteUrl={config.site_url}
+        renderButtonContent={() => (
+          <React.Fragment>
+            <LoginButtonIcon type="link" /> {inProgress ? t('auth.loggingIn') : t('auth.login')}
+          </React.Fragment>
+        )}
+        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..e7bc6de432b8
--- /dev/null
+++ b/packages/decap-cms-backend-aws-cognito-github-proxy/src/implementation.tsx
@@ -0,0 +1,47 @@
+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<string, unknown>) => (
+      <AuthenticationPage {...props} backend={this} />
+    );
+    wrappedAuthenticationPage.displayName = 'AuthenticationPage';
+    return wrappedAuthenticationPage;
+  }
+
+  async currentUser({ token }: { token: string }): Promise<GitHubUser> {
+    if (!this._currentUserPromise) {
+      this._currentUserPromise = fetch(this.baseUrl + '/oauth2/userInfo', {
+        headers: {
+          Authorization: `${this.tokenKeyword} ${token}`,
+        },
+      }).then(async (res: Response): Promise<GitHubUser> => {
+        if (res.status == 401) {
+          this.logout();
+          return Promise.reject('Token expired');
+        }
+        const userInfo = await res.json();
+        const owner = this.originRepo.split('/')[1];
+        return {
+          name: userInfo.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<GitHubConfig, 'getUser'> & {
   apiRoot: string;
   tokenPromise: () => Promise<string>;
   commitAuthor: { name: string };
@@ -18,7 +18,10 @@ export default class API extends GithubAPI {
   isLargeMedia: (filename: string) => Promise<boolean>;
 
   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-gitea/src/AuthenticationPage.js b/packages/decap-cms-backend-gitea/src/AuthenticationPage.js
index c61584bcc8d4..2def4d2ac66f 100644
--- a/packages/decap-cms-backend-gitea/src/AuthenticationPage.js
+++ b/packages/decap-cms-backend-gitea/src/AuthenticationPage.js
@@ -25,6 +25,8 @@ export default class GiteaAuthenticationPage extends React.Component {
       auth_endpoint: 'login/oauth/authorize',
       app_id,
       auth_token_endpoint: 'login/oauth/access_token',
+      auth_token_endpoint_content_type: 'application/json; charset=utf-8',
+      redirect_uri: document.location.origin + document.location.pathname,
     });
     // Complete authentication if we were redirected back to from the provider.
     this.auth.completeAuth((err, data) => {
diff --git a/packages/decap-cms-backend-github/src/API.ts b/packages/decap-cms-backend-github/src/API.ts
index 3f05b1bbc91a..a044342c5fca 100644
--- a/packages/decap-cms-backend-github/src/API.ts
+++ b/packages/decap-cms-backend-github/src/API.ts
@@ -50,6 +50,7 @@ export const MOCK_PULL_REQUEST = -1;
 export interface Config {
   apiRoot?: string;
   token?: string;
+  tokenKeyword?: string;
   branch?: string;
   useOpenAuthoring?: boolean;
   repo?: string;
@@ -57,6 +58,8 @@ export interface Config {
   squashMerges: boolean;
   initialWorkflowStatus: string;
   cmsLabelPrefix: string;
+  baseUrl?: string;
+  getUser: ({ token }: { token: string }) => Promise<GitHubUser>;
 }
 
 interface TreeFile {
@@ -173,6 +176,7 @@ let migrationNotified = false;
 export default class API {
   apiRoot: string;
   token: string;
+  tokenKeyword: string;
   branch: string;
   useOpenAuthoring?: boolean;
   repo: string;
@@ -186,7 +190,8 @@ export default class API {
   mergeMethod: string;
   initialWorkflowStatus: string;
   cmsLabelPrefix: string;
-
+  baseUrl?: string;
+  getUser: ({ token }: { token: string }) => Promise<GitHubUser>;
   _userPromise?: Promise<GitHubUser>;
   _metadataSemaphore?: Semaphore;
 
@@ -195,6 +200,7 @@ export default class API {
   constructor(config: Config) {
     this.apiRoot = config.apiRoot || 'https://api.github.com';
     this.token = config.token || '';
+    this.tokenKeyword = config.tokenKeyword || 'token';
     this.branch = config.branch || 'master';
     this.useOpenAuthoring = config.useOpenAuthoring;
     this.repo = config.repo || '';
@@ -213,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<GitHubUser>;
-  }
-
   async hasWriteAccess() {
     try {
       const result: Octokit.ReposGetResponse = await this.request(this.repoURL);
@@ -251,7 +255,7 @@ export default class API {
     };
 
     if (this.token) {
-      baseHeader.Authorization = `token ${this.token}`;
+      baseHeader.Authorization = `${this.tokenKeyword} ${this.token}`;
       return Promise.resolve(baseHeader);
     }
 
@@ -576,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 00e1cceb7741..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 ? `token ${this.token}` : '',
+          authorization: this.token ? `${this.tokenKeyword} ${this.token}` : '',
         },
       };
     });
diff --git a/packages/decap-cms-backend-github/src/implementation.tsx b/packages/decap-cms-backend-github/src/implementation.tsx
index 5a448e746f82..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,9 +78,12 @@ export default class GitHub implements Implementation {
   mediaFolder: string;
   previewContext: string;
   token: string | null;
+  tokenKeyword: string;
   squashMerges: boolean;
   cmsLabelPrefix: string;
   useGraphql: boolean;
+  baseUrl?: string;
+  bypassWriteAccessCheckForAppTokens = false;
   _currentUserPromise?: Promise<GitHubUser>;
   _userIsOriginMaintainerPromises?: {
     [key: string]: Promise<boolean>;
@@ -119,6 +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.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;
@@ -153,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);
@@ -185,7 +190,7 @@ export default class GitHub implements Implementation {
     let repoExists = false;
     while (!repoExists) {
       repoExists = await fetch(`${this.apiRoot}/repos/${repo}`, {
-        headers: { Authorization: `token ${token}` },
+        headers: { Authorization: `${this.tokenKeyword} ${token}` },
       })
         .then(() => true)
         .catch(err => {
@@ -208,7 +213,7 @@ export default class GitHub implements Implementation {
     if (!this._currentUserPromise) {
       this._currentUserPromise = fetch(`${this.apiRoot}/user`, {
         headers: {
-          Authorization: `token ${token}`,
+          Authorization: `${this.tokenKeyword} ${token}`,
         },
       }).then(res => res.json());
     }
@@ -229,7 +234,7 @@ export default class GitHub implements Implementation {
         `${this.apiRoot}/repos/${this.originRepo}/collaborators/${username}/permission`,
         {
           headers: {
-            Authorization: `token ${token}`,
+            Authorization: `${this.tokenKeyword} ${token}`,
           },
         },
       )
@@ -246,7 +251,7 @@ export default class GitHub implements Implementation {
       const repo = await fetch(`${this.apiRoot}/repos/${currentUser.login}/${repoName}`, {
         method: 'GET',
         headers: {
-          Authorization: `token ${token}`,
+          Authorization: `${this.tokenKeyword} ${token}`,
         },
       }).then(res => res.json());
 
@@ -294,7 +299,7 @@ export default class GitHub implements Implementation {
       return fetch(`${this.apiRoot}/repos/${this.repo}/merge-upstream`, {
         method: 'POST',
         headers: {
-          Authorization: `token ${token}`,
+          Authorization: `${this.tokenKeyword} ${token}`,
         },
         body: JSON.stringify({
           branch: this.branch,
@@ -306,7 +311,7 @@ export default class GitHub implements Implementation {
       const fork = await fetch(`${this.apiRoot}/repos/${this.originRepo}/forks`, {
         method: 'POST',
         headers: {
-          Authorization: `token ${token}`,
+          Authorization: `${this.tokenKeyword} ${token}`,
         },
       }).then(res => res.json());
       return this.pollUntilForkExists({ repo: fork.full_name, token });
@@ -318,6 +323,7 @@ export default class GitHub implements Implementation {
     const apiCtor = this.useGraphql ? GraphQLAPI : API;
     this.api = new apiCtor({
       token: this.token,
+      tokenKeyword: this.tokenKeyword,
       branch: this.branch,
       repo: this.repo,
       originRepo: this.originRepo,
@@ -326,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 => {
@@ -342,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-backend-gitlab/src/AuthenticationPage.js b/packages/decap-cms-backend-gitlab/src/AuthenticationPage.js
index 54a097cd5552..6eb5cc61fb59 100644
--- a/packages/decap-cms-backend-gitlab/src/AuthenticationPage.js
+++ b/packages/decap-cms-backend-gitlab/src/AuthenticationPage.js
@@ -14,7 +14,14 @@ const LoginButtonIcon = styled(Icon)`
 
 const clientSideAuthenticators = {
   pkce: ({ base_url, auth_endpoint, app_id, auth_token_endpoint }) =>
-    new PkceAuthenticator({ base_url, auth_endpoint, app_id, auth_token_endpoint }),
+    new PkceAuthenticator({
+      base_url,
+      auth_endpoint,
+      app_id,
+      auth_token_endpoint,
+      auth_token_endpoint_content_type: 'application/json; charset=utf-8',
+      redirect_uri: document.location.origin + document.location.pathname,
+    }),
 
   implicit: ({ base_url, auth_endpoint, app_id, clearHash }) =>
     new ImplicitAuthenticator({ base_url, auth_endpoint, app_id, clearHash }),
diff --git a/packages/decap-cms-lib-auth/src/pkce-oauth.js b/packages/decap-cms-lib-auth/src/pkce-oauth.js
index 5c1a9ce65e4a..803ce11aebc7 100644
--- a/packages/decap-cms-lib-auth/src/pkce-oauth.js
+++ b/packages/decap-cms-lib-auth/src/pkce-oauth.js
@@ -53,6 +53,8 @@ export default class PkceAuthenticator {
     const authTokenEndpoint = trim(config.auth_token_endpoint, '/');
     this.auth_url = `${baseURL}/${authEndpoint}`;
     this.auth_token_url = `${baseURL}/${authTokenEndpoint}`;
+    this.auth_token_endpoint_content_type = config.auth_token_endpoint_content_type;
+    this.redirect_uri = trim(config.redirect_uri, '/');
     this.appID = config.app_id;
   }
 
@@ -63,7 +65,7 @@ export default class PkceAuthenticator {
 
     const authURL = new URL(this.auth_url);
     authURL.searchParams.set('client_id', this.appID);
-    authURL.searchParams.set('redirect_uri', document.location.origin + document.location.pathname);
+    authURL.searchParams.set('redirect_uri', this.redirect_uri);
     authURL.searchParams.set('response_type', 'code');
     authURL.searchParams.set('scope', options.scope);
 
@@ -92,7 +94,13 @@ export default class PkceAuthenticator {
       return;
     }
 
-    const { nonce } = JSON.parse(params.get('state'));
+    let nonce;
+    try {
+      nonce = JSON.parse(params.get('state')).nonce;
+    } catch (SyntaxError) {
+      nonce = JSON.parse(params.get('state').replace(/\\"/g, '"')).nonce;
+    }
+
     const validNonce = validateNonce(nonce);
     if (!validNonce) {
       return cb(new Error('Invalid nonce'));
@@ -106,24 +114,27 @@ export default class PkceAuthenticator {
       const code = params.get('code');
       const authURL = new URL(this.auth_token_url);
 
+      const token_request_body_object = {
+        client_id: this.appID,
+        code,
+        grant_type: 'authorization_code',
+        redirect_uri: this.redirect_uri,
+        code_verifier: getCodeVerifier(),
+      };
+
       const response = await fetch(authURL.href, {
         method: 'POST',
-        body: JSON.stringify({
-          client_id: this.appID,
-          code,
-          grant_type: 'authorization_code',
-          redirect_uri: document.location.origin + document.location.pathname,
-          code_verifier: getCodeVerifier(),
-        }),
+        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': 'application/json; charset=utf-8',
+          'Content-Type': this.auth_token_endpoint_content_type,
         },
       });
       const data = await response.json();
 
       //no need for verifier code so remove
       clearCodeVerifier();
-
       cb(null, { token: data.access_token, ...data });
     }
   }
diff --git a/packages/decap-cms-lib-util/src/implementation.ts b/packages/decap-cms-lib-util/src/implementation.ts
index de055099b77c..8b8a05cfe0a0 100644
--- a/packages/decap-cms-lib-util/src/implementation.ts
+++ b/packages/decap-cms-lib-util/src/implementation.ts
@@ -111,6 +111,7 @@ export type Config = {
     proxy_url?: string;
     auth_type?: string;
     app_id?: string;
+    base_url?: string;
     cms_label_prefix?: string;
     api_version?: string;
   };