From 829409e0bc03b4591ee6b59d9895adc4e7190037 Mon Sep 17 00:00:00 2001 From: Eric Krebs Date: Wed, 14 Apr 2021 03:50:53 -0500 Subject: [PATCH] feat: Adds PKCE authentication for GitLab closes #5236 (#5239) --- .../src/AuthenticationPage.js | 19 ++- .../src/implicit-oauth.js | 23 +--- packages/netlify-cms-lib-auth/src/index.js | 5 +- .../netlify-cms-lib-auth/src/pkce-oauth.js | 126 ++++++++++++++++++ packages/netlify-cms-lib-auth/src/utils.js | 24 ++++ .../content/docs/external-oauth-clients.md | 2 +- website/content/docs/gitlab-backend.md | 38 +++++- 7 files changed, 206 insertions(+), 31 deletions(-) create mode 100644 packages/netlify-cms-lib-auth/src/pkce-oauth.js create mode 100644 packages/netlify-cms-lib-auth/src/utils.js diff --git a/packages/netlify-cms-backend-gitlab/src/AuthenticationPage.js b/packages/netlify-cms-backend-gitlab/src/AuthenticationPage.js index 1ae22cac..ff41ef76 100644 --- a/packages/netlify-cms-backend-gitlab/src/AuthenticationPage.js +++ b/packages/netlify-cms-backend-gitlab/src/AuthenticationPage.js @@ -1,13 +1,25 @@ import React from 'react'; import PropTypes from 'prop-types'; import styled from '@emotion/styled'; -import { NetlifyAuthenticator, ImplicitAuthenticator } from 'netlify-cms-lib-auth'; +import { + NetlifyAuthenticator, + ImplicitAuthenticator, + PkceAuthenticator, +} from 'netlify-cms-lib-auth'; import { AuthenticationPage, Icon } from 'netlify-cms-ui-default'; const LoginButtonIcon = styled(Icon)` margin-right: 18px; `; +const clientSideAuthenticators = { + pkce: ({ base_url, auth_endpoint, app_id, auth_token_endpoint }) => + new PkceAuthenticator({ base_url, auth_endpoint, app_id, auth_token_endpoint }), + + implicit: ({ base_url, auth_endpoint, app_id, clearHash }) => + new ImplicitAuthenticator({ base_url, auth_endpoint, app_id, clearHash }), +}; + export default class GitLabAuthenticationPage extends React.Component { static propTypes = { onLogin: PropTypes.func.isRequired, @@ -30,11 +42,12 @@ export default class GitLabAuthenticationPage extends React.Component { app_id = '', } = this.props.config.backend; - if (authType === 'implicit') { - this.auth = new ImplicitAuthenticator({ + if (clientSideAuthenticators[authType]) { + this.auth = clientSideAuthenticators[authType]({ base_url, auth_endpoint, app_id, + auth_token_endpoint: 'oauth/token', clearHash: this.props.clearHash, }); // Complete implicit authentication if we were redirected back to from the provider. diff --git a/packages/netlify-cms-lib-auth/src/implicit-oauth.js b/packages/netlify-cms-lib-auth/src/implicit-oauth.js index 1d394f38..e9966d3f 100644 --- a/packages/netlify-cms-lib-auth/src/implicit-oauth.js +++ b/packages/netlify-cms-lib-auth/src/implicit-oauth.js @@ -1,20 +1,7 @@ import { Map } from 'immutable'; import trim from 'lodash/trim'; import trimEnd from 'lodash/trimEnd'; -import uuid from 'uuid/v4'; - -function createNonce() { - const nonce = uuid(); - window.sessionStorage.setItem('netlify-cms-auth', JSON.stringify({ nonce })); - return nonce; -} - -function validateNonce(check) { - const auth = window.sessionStorage.getItem('netlify-cms-auth'); - const valid = auth && JSON.parse(auth).nonce; - window.localStorage.removeItem('netlify-cms-auth'); - return check === valid; -} +import { createNonce, validateNonce, isInsecureProtocol } from './utils'; export default class ImplicitAuthenticator { constructor(config = {}) { @@ -26,13 +13,7 @@ export default class ImplicitAuthenticator { } authenticate(options, cb) { - if ( - document.location.protocol !== 'https:' && - // TODO: Is insecure localhost a bad idea as well? I don't think it is, since you are not actually - // sending the token over the internet in this case, assuming the auth URL is secure. - document.location.hostname !== 'localhost' && - document.location.hostname !== '127.0.0.1' - ) { + if (isInsecureProtocol()) { return cb(new Error('Cannot authenticate over insecure protocol!')); } diff --git a/packages/netlify-cms-lib-auth/src/index.js b/packages/netlify-cms-lib-auth/src/index.js index 0cd6f7ee..dca5ca64 100644 --- a/packages/netlify-cms-lib-auth/src/index.js +++ b/packages/netlify-cms-lib-auth/src/index.js @@ -1,4 +1,5 @@ import NetlifyAuthenticator from './netlify-auth'; import ImplicitAuthenticator from './implicit-oauth'; -export const NetlifyCmsLibAuth = { NetlifyAuthenticator, ImplicitAuthenticator }; -export { NetlifyAuthenticator, ImplicitAuthenticator }; +import PkceAuthenticator from './pkce-oauth'; +export const NetlifyCmsLibAuth = { NetlifyAuthenticator, ImplicitAuthenticator, PkceAuthenticator }; +export { NetlifyAuthenticator, ImplicitAuthenticator, PkceAuthenticator }; diff --git a/packages/netlify-cms-lib-auth/src/pkce-oauth.js b/packages/netlify-cms-lib-auth/src/pkce-oauth.js new file mode 100644 index 00000000..40f0be03 --- /dev/null +++ b/packages/netlify-cms-lib-auth/src/pkce-oauth.js @@ -0,0 +1,126 @@ +import trim from 'lodash/trim'; +import trimEnd from 'lodash/trimEnd'; +import { createNonce, validateNonce, isInsecureProtocol } from './utils'; + +async function sha256(text) { + const encoder = new TextEncoder(); + const data = encoder.encode(text); + const digest = await window.crypto.subtle.digest('SHA-256', data); + const sha = String.fromCharCode(...new Uint8Array(digest)); + return sha; +} + +// based on https://github.com/auth0/auth0-spa-js/blob/9a83f698127eae7da72691b0d4b1b847567687e3/src/utils.ts#L147 +function generateVerifierCode() { + // characters that can be used for codeVerifer + // excludes _~ as if included would cause an uneven distribution as char.length would no longer be a factor of 256 + const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-.'; + const randomValues = Array.from(window.crypto.getRandomValues(new Uint8Array(128))); + return randomValues + .map(val => { + return chars[val % chars.length]; + }) + .join(''); +} + +async function createCodeChallenge(codeVerifier) { + const sha = await sha256(codeVerifier); + // https://tools.ietf.org/html/rfc7636#appendix-A + return btoa(sha) + .split('=')[0] + .replace(/\+/g, '-') + .replace(/\//g, '_'); +} + +const CODE_VERIFIER_STORAGE_KEY = 'netlify-cms-pkce-verifier-code'; + +function createCodeVerifier() { + const codeVerifier = generateVerifierCode(); + window.sessionStorage.setItem(CODE_VERIFIER_STORAGE_KEY, codeVerifier); + return codeVerifier; +} + +function getCodeVerifier() { + return window.sessionStorage.getItem(CODE_VERIFIER_STORAGE_KEY); +} + +function clearCodeVerifier() { + window.sessionStorage.removeItem(CODE_VERIFIER_STORAGE_KEY); +} + +export default class PkceAuthenticator { + constructor(config = {}) { + const baseURL = trimEnd(config.base_url, '/'); + const authEndpoint = trim(config.auth_endpoint, '/'); + const authTokenEndpoint = trim(config.auth_token_endpoint, '/'); + this.auth_url = `${baseURL}/${authEndpoint}`; + this.auth_token_url = `${baseURL}/${authTokenEndpoint}`; + this.appID = config.app_id; + } + + async authenticate(options, cb) { + if (isInsecureProtocol()) { + return cb(new Error('Cannot authenticate over insecure protocol!')); + } + + 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('response_type', 'code'); + authURL.searchParams.set('scope', options.scope); + + const state = JSON.stringify({ auth_type: 'pkce', nonce: createNonce() }); + + authURL.searchParams.set('state', state); + + authURL.searchParams.set('code_challenge_method', 'S256'); + const codeVerifier = createCodeVerifier(); + const codeChallenge = await createCodeChallenge(codeVerifier); + authURL.searchParams.set('code_challenge', codeChallenge); + + document.location.assign(authURL.href); + } + + /** + * Complete authentication if we were redirected back to from the provider. + */ + async completeAuth(cb) { + const params = new URLSearchParams(document.location.search); + + // Remove code from url + window.history.replaceState(null, '', document.location.pathname); + + if (!params.has('code') && !params.has('error')) { + return; + } + + const { nonce } = JSON.parse(params.get('state')); + const validNonce = validateNonce(nonce); + if (!validNonce) { + return cb(new Error('Invalid nonce')); + } + + if (params.has('error')) { + return cb(new Error(`${params.get('error')}: ${params.get('error_description')}`)); + } + + if (params.has('code')) { + const code = params.get('code'); + const authURL = new URL(this.auth_token_url); + authURL.searchParams.set('client_id', this.appID); + authURL.searchParams.set('code', code); + authURL.searchParams.set('grant_type', 'authorization_code'); + authURL.searchParams.set( + 'redirect_uri', + document.location.origin + document.location.pathname, + ); + authURL.searchParams.set('code_verifier', getCodeVerifier()); + //no need for verifier code so remove + clearCodeVerifier(); + + const response = await fetch(authURL.href, { method: 'POST' }); + const data = await response.json(); + cb(null, { token: data.access_token, ...data }); + } + } +} diff --git a/packages/netlify-cms-lib-auth/src/utils.js b/packages/netlify-cms-lib-auth/src/utils.js new file mode 100644 index 00000000..38fd5ee3 --- /dev/null +++ b/packages/netlify-cms-lib-auth/src/utils.js @@ -0,0 +1,24 @@ +import uuid from 'uuid/v4'; + +export function createNonce() { + const nonce = uuid(); + window.sessionStorage.setItem('netlify-cms-auth', JSON.stringify({ nonce })); + return nonce; +} + +export function validateNonce(check) { + const auth = window.sessionStorage.getItem('netlify-cms-auth'); + const valid = auth && JSON.parse(auth).nonce; + window.localStorage.removeItem('netlify-cms-auth'); + return check === valid; +} + +export function isInsecureProtocol() { + return ( + document.location.protocol !== 'https:' && + // TODO: Is insecure localhost a bad idea as well? I don't think it is, since you are not actually + // sending the token over the internet in this case, assuming the auth URL is secure. + document.location.hostname !== 'localhost' && + document.location.hostname !== '127.0.0.1' + ); +} diff --git a/website/content/docs/external-oauth-clients.md b/website/content/docs/external-oauth-clients.md index 759b265a..8f52d1d1 100644 --- a/website/content/docs/external-oauth-clients.md +++ b/website/content/docs/external-oauth-clients.md @@ -3,7 +3,7 @@ group: Accounts weight: 60 title: External OAuth Clients --- -If you would like to facilitate your own OAuth authentication rather than use Netlify's service or implicit grant, you can use one of the community-maintained projects below. Feel free to hit the "Edit this page" button if you'd like to add yours! +If you would like to facilitate your own OAuth authentication rather than use Netlify's service or a client side flow like implicit or PKCE, you can use one of the community-maintained projects below. Feel free to hit the "Edit this page" button if you'd like to add yours! | Author | Supported Git hosts | Language(s)/Platform(s) | Link | | ------------------------------------------------------------ | --------------------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | diff --git a/website/content/docs/gitlab-backend.md b/website/content/docs/gitlab-backend.md index d012dab1..5eef9e9b 100644 --- a/website/content/docs/gitlab-backend.md +++ b/website/content/docs/gitlab-backend.md @@ -7,10 +7,11 @@ For repositories stored on GitLab, the `gitlab` backend allows CMS users to log **Note:** GitLab default branch is protected by default, thus typically requires `maintainer` permissions in order for users to have push access. -The GitLab API allows for two types of OAuth2 flows: +The GitLab API allows for three types of OAuth2 flows: * [Authorization Code Flow](https://docs.gitlab.com/ce/api/oauth2.html#authorization-code-flow), which works much like the GitHub OAuth flow described above. -* [Implicit Grant Flow](https://docs.gitlab.com/ce/api/oauth2.html#implicit-grant-flow), which operates *without* the need for an authentication server. +* [Authorization Code with PKCE Flow](https://docs.gitlab.com/ce/api/oauth2.html#authorization-code-with-proof-key-for-code-exchange-pkce), which operates *without* the need for an authentication server. +* (DEPRECATED [Implicit Grant Flow](https://docs.gitlab.com/ce/api/oauth2.html#implicit-grant-flow), which operates *without* the need for an authentication server. ## Authorization Code Flow with Netlify @@ -28,7 +29,36 @@ backend: repo: owner-name/repo-name # Path to your GitLab repository ``` -## Client-Side Implicit Grant (GitLab) + +## Client-Side PKCE Authorization + +With GitLab's PKCE authorization, users can authenticate with GitLab directly from the client. To do this: + +1. Follow the [GitLab docs](https://docs.gitlab.com/ee/integration/oauth_provider.html#adding-an-application-through-the-profile) to add your Netlify CMS instance as an OAuth application and uncheck the **Confidential** checkbox. For the **Redirect URI**, enter the address where you access Netlify CMS, for example, `https://www.mysite.com/admin/`. For scope, select `api`. +2. GitLab gives you an **Application ID**. Copy this ID and enter it in your Netlify CMS `config.yml` file, along with the following settings: + + ```yaml + backend: + name: gitlab + repo: owner-name/repo-name # Path to your GitLab repository + auth_type: pkce # Required for pkce + app_id: your-app-id # Application ID from your GitLab settings + ``` + + You can also use PKCE Authorization with a self-hosted GitLab instance. This requires adding `api_root`, `base_url`, and `auth_endpoint` fields: + + ```yaml + backend: + name: gitlab + repo: owner-name/repo-name # Path to your GitLab repository + auth_type: pkce # Required for pkce + app_id: your-app-id # Application ID from your GitLab settings + api_root: https://my-hosted-gitlab-instance.com/api/v4 + base_url: https://my-hosted-gitlab-instance.com + auth_endpoint: oauth/authorize + ``` + +## (DEPRECATED) Client-Side Implicit Grant **Note:** This method is not recommended and will be deprecated both [by GitLab](https://gitlab.com/gitlab-org/gitlab/-/issues/288516) and [in the OAuth 2.1 specification](https://oauth.net/2.1/) in the future. @@ -58,4 +88,4 @@ With GitLab's Implicit Grant, users can authenticate with GitLab directly from t auth_endpoint: oauth/authorize ``` -**Note:** In both cases, GitLab also provides you with a client secret. You should *never* store this in your repo or reveal it in the client. +**Note:** In all cases, GitLab also provides you with a client secret. You should *never* store this in your repo or reveal it in the client.