feat: Adds PKCE authentication for GitLab closes #5236 (#5239)

This commit is contained in:
Eric Krebs 2021-04-14 03:50:53 -05:00 committed by GitHub
parent 2a06244f77
commit 829409e0bc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 206 additions and 31 deletions

View File

@ -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.

View File

@ -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!'));
}

View File

@ -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 };

View File

@ -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 });
}
}
}

View File

@ -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'
);
}

View File

@ -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 |
| ------------------------------------------------------------ | --------------------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |

View File

@ -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.