├── .eslintignore ├── .eslintrc.json ├── .github └── workflows │ └── push.yml ├── .gitignore ├── .husky ├── .gitignore ├── commit-msg └── pre-commit ├── .prettierignore ├── .prettierrc.json ├── LICENSE ├── README.md ├── examples ├── common │ ├── images │ │ └── uploads │ │ │ └── .gitkeep │ ├── pages │ │ └── home.md │ └── render.js ├── generic │ ├── .gitignore │ ├── README.md │ ├── admin.html │ ├── config.yml │ ├── index.js │ └── package.json └── vercel │ ├── .gitignore │ ├── README.md │ ├── admin.html │ ├── api │ ├── begin.js │ └── complete.js │ ├── build.js │ ├── config.yml │ ├── package.json │ └── vercel.json ├── index.js ├── jest.config.js ├── lib ├── config.js ├── convict.js ├── debug.js ├── handlers │ ├── generic.js │ ├── generic.test.js │ ├── index.js │ ├── templates │ │ ├── complete.html.mustache │ │ ├── error.html.mustache │ │ └── partials │ │ │ ├── admin-panel-link.html.mustache │ │ │ ├── common-scripts.html.mustache │ │ │ ├── foot.html.mustache │ │ │ └── head.html.mustache │ ├── utils.js │ ├── utils.test.js │ └── vercel.js ├── index.js └── providers │ ├── GitHub.js │ ├── Provider.js │ └── index.js ├── package.json └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | # Misc 2 | /_ 3 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "commonjs": true, 4 | "es6": true, 5 | "node": true, 6 | "jest/globals": true 7 | }, 8 | "extends": [ 9 | "eslint:recommended", 10 | "standard", 11 | "plugin:node/recommended", 12 | "prettier" 13 | ], 14 | "plugins": ["jest"], 15 | "parserOptions": { 16 | "ecmaVersion": 2018 17 | }, 18 | "rules": { 19 | "node/exports-style": ["error", "module.exports"], 20 | "node/prefer-global/buffer": ["error", "always"], 21 | "node/prefer-global/console": ["error", "always"], 22 | "node/prefer-global/process": ["error", "always"], 23 | "node/prefer-global/url-search-params": ["error", "always"], 24 | "node/prefer-global/url": ["error", "always"], 25 | "node/no-unsupported-features/es-syntax": ["error"], 26 | "node/no-unsupported-features/es-builtins": ["error"], 27 | "curly": ["error", "all"], 28 | "max-len": ["error", { "code": 140, "ignoreUrls": true }], 29 | "no-undefined": "error", 30 | "camelcase": "error", 31 | "no-confusing-arrow": "error", 32 | "no-var": "error", 33 | "no-console": "off" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /.github/workflows/push.yml: -------------------------------------------------------------------------------- 1 | name: CI/CD 2 | on: push 3 | 4 | jobs: 5 | test-and-release: 6 | name: Test and Release 7 | runs-on: ubuntu-latest 8 | steps: 9 | # Checkout the repo at the push ref 10 | - uses: actions/checkout@v1 11 | # Install deps 12 | - name: Install 13 | run: yarn 14 | # Run tests 15 | - name: Test 16 | run: yarn test 17 | # Attempt release if we're on master 18 | - name: Release 19 | if: github.ref == 'refs/heads/master' 20 | run: yarn run release-ci 21 | env: 22 | GH_TOKEN: ${{ secrets.GH_TOKEN }} 23 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Coverage directory 15 | coverage 16 | 17 | # Compiled binary addons (https://nodejs.org/api/addons.html) 18 | build/Release 19 | 20 | # Dependency directories 21 | node_modules/ 22 | 23 | # Optional npm cache directory 24 | .npm 25 | 26 | # Optional eslint cache 27 | .eslintcache 28 | 29 | # Optional REPL history 30 | .node_repl_history 31 | 32 | # Output of 'npm pack' 33 | *.tgz 34 | 35 | # Yarn Integrity file 36 | .yarn-integrity 37 | 38 | # Misc 39 | /_ 40 | 41 | # JetBrains IDEs 42 | .idea 43 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | yarn commitlint --edit $1 3 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | yarn lint-staged 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Misc 2 | /_ 3 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "tabWidth": 4, 4 | "semi": true, 5 | "singleQuote": true, 6 | "arrowParens": "always" 7 | } 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Brandon Phillips 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `netlify-cms-oauth-provider-node` 2 | 3 | [![](https://img.shields.io/npm/v/netlify-cms-oauth-provider-node)](https://www.npmjs.com/package/netlify-cms-oauth-provider-node) 4 | 5 | A stateless [external OAuth provider](https://www.netlifycms.org/docs/authentication-backends/#external-oauth-clients) 6 | for `netlify-cms`. 7 | 8 | This package exposes an API that makes it easy to use in a traditional long-running Node server (i.e. using `express`) 9 | or in stateless serverless functions (i.e. 10 | [Vercel Serverless Functions](https://vercel.com/docs/serverless-functions/introduction)). 11 | 12 | ## Usage 13 | 14 | **Note:** More detailed documentation and inline code samples are in the works. For now, it's best to check out the 15 | examples: 16 | 17 | - [Generic Node HTTP server](./examples/generic) 18 | - [Vercel functions](./examples/vercel) 19 | 20 | ### Overview 21 | 22 | This library exports handler-creating functions: 23 | 24 | - `createBeginHandler(config: object, options: CreateConfigOptions): function(state: string=): Promise` 25 | - `createCompleteHandler(config: object, options: CreateConfigOptions): function(code: string=, params: object=): Promise` 26 | - `createHandlers(config: object, options: CreateConfigOptions): ({ begin: (function(state: string=): Promise), complete: (function(code: string=, params: object=): Promise) })` 27 | - `createVercelBeginHandler(config: object, options: CreateConfigOptions): AsyncVercelServerlessFunction` 28 | - `createVercelCompleteHandler(config: object, options: CreateConfigOptions): AsyncVercelServerlessFunction` 29 | - `createVercelHandlers(config: object, options: CreateConfigOptions): ({ begin: AsyncVercelServerlessFunction, complete: AsyncVercelServerlessFunction })` 30 | 31 | They do the following: 32 | 33 | - Generic handlers 34 | - `createBeginHandler`: Creates a generic async function that takes an optional `state` string parameter (possibly used 35 | for CSRF protection, not currently implemented in this library) and resolves eventually with a URL to redirect the 36 | user to in order to kick off the netlify-cms OAuth flow with the provider (i.e. GitHub). 37 | - `createCompleteHandler`: Creates a generic async function that takes an authorization code (and optional additional 38 | parameters) received from the OAuth provider and eventually resolves with a string of HTML that should be returned 39 | to the requesting user. The HTML will use the `postMessage` API to send the access token that we got from exchanging 40 | the authorization code with the provider to netlify-cms. 41 | - `createHandlers`: Creates both of the above handlers and returns an object containing them on the `begin` and 42 | `complete` keys. 43 | - Vercel Handlers 44 | - `createVercelBeginHandler`: Creates an async Vercel serverless function that handles everything for you and 45 | delegates to the generic begin handler described above. 46 | - `createVercelCompleteHandler`: Creates an async Vercel serverless function that handles everything for you and 47 | delegates to the generic complete handler described above. 48 | - `createVercelHandlers`: Creates both of the above async Vercel serverless functions and returns an object containing 49 | them on the `begin` and `complete` keys. 50 | 51 | That's a lot to digest but essentially: 52 | 53 | - All of the handler-creating functions take two optional arguments: 54 | - `config: object`: An `object` that can have any of the [configuration parameters](#configuration). This object 55 | is optional but some configuration parameters are not (they can be specified i.e. via env variables and setting 56 | `useEnv` in `options` to `true` instead of via this object.) 57 | - `options: CreateConfigOptions`: An object that can take any of the [`CreateConfigOptions` options](#createconfigoptions) 58 | that effect how config is read, compiled, and validated. Typically you'll want to pass `{ useEnv: true }` for this 59 | to read config from the environment, which is disabled by default for security and predictability. 60 | 61 | ## Configuration 62 | 63 | **Note:** More detailed documentation on available configuration parameters are in the works. 64 | 65 | For details on available configuration parameters, check out [`lib/config.js`](./lib/config.js) which uses 66 | [`convict`](https://github.com/mozilla/node-convict) to parse and validate configuration. To sum up: 67 | 68 | - `origin: string|array`: Required. The HTTP origin of the host of the netlify-cms admin panel using this OAuth 69 | provider. Multiple origin domains can be provided as an array of strings or a single comma-separated string. You can 70 | provide only the domain part (`'example.com'`) which implies any protocol on any port or you can explicitly specify 71 | a protocol and/or port (`'https://example.com'` or `'http://example.com:8080'`). 72 | - `completeUrl: string`: Required. The URL (specified during the OAuth 2.0 authorization flow) that the `complete` 73 | handler is hosted at. 74 | - `oauthClientID: string`: Required. The OAuth 2.0 Client ID received from the OAuth provider. 75 | - `oauthClientSecret`: Required. The OAuth 2.0 Client secret received from the OAuth provider. 76 | - `dev: boolean=`: Default: `process.env.NODE_ENV === 'development'`. Enabled more verbose errors in the generated HTML 77 | UI, etc. 78 | - `adminPanelUrl: string=`: Default: `''`. The URL of the admin panel to link the user back to in case something 79 | goes horribly wrong. 80 | - `oauthProvider: string=`: Default: `'github'`. The Git service / OAuth provider to use. 81 | - `oauthTokenHost: string=`: Default: `''`. The OAuth 2.0 token host URI for the OAuth provider. If not provided, 82 | this will be guessed based on the provider. 83 | - `oauthTokenPath: string=`: Default: `''`. The relative URI to the OAuth 2.0 token endpoint for the OAuth provider. 84 | If not provided, this will be guessed based on the provider. 85 | - `oauthAuthorizePath: string=`: Default: `''`. The relative URI to the OAuth 2.0 authorization endpoint for the 86 | OAuth provider. If not provided, this will be guessed based on the provider. 87 | - `oauthScopes: string=`: Default: `''`. The scopes to claim during the OAuth 2.0 authorization request with the OAuth 88 | provider. If not provided, this will be guessed based on the provider with the goal to ensure the user has read/write 89 | access to repositories. 90 | 91 | Config can be passed as an object as the first argument of any handler-creating function and additionaly via 92 | environment variables as long as you pass `{useEnv: true}` as the second argument to any handler-creating function 93 | to enable this behavior. See below. 94 | 95 | ### `CreateConfigOptions` 96 | 97 | In addition to config, there's also the options object optionally passed as the second arg of any handler creating 98 | function. It determines how configuration is compiled. For example, by default configuration will not be read from 99 | the environment; one must set `useEnv` to `true` in the options to enable that functionality. 100 | 101 | The available options are: 102 | 103 | - `useEnv?: boolean`: Default: `false` (for security and predictability). Set to `true` to load config from 104 | `process.env`. 105 | - `useArgs?: boolean`: Default: `false` (for security and predictability). Set to `true` to load config from 106 | `process.argv`. 107 | - `extraConvictOptions?: object`: Default: `{}`. Additional [`opts` to pass to `convict`](https://github.com/mozilla/node-convict/tree/master/packages/convict#var-config--convictschema-opts). 108 | - `extraValidateOptions?: object`: Default: `{}`. Additional [`options` to pass to `config.validate`](https://github.com/mozilla/node-convict/tree/master/packages/convict#configvalidateoptions). 109 | - `skipAlreadyValidatedCheck?: boolean`: Default: `false`. Always reload and revalidate config when it's passed in even 110 | if the object instance already has been. This is generally used for i.e. tests and internal use and isn't very helpful 111 | but if you mutate the config object at all, you might need this. 112 | 113 | ## TODO 114 | 115 | - [ ] More detailed usage instructions and examples 116 | - [ ] More detailed configuration documentation 117 | -------------------------------------------------------------------------------- /examples/common/images/uploads/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bericp1/netlify-cms-oauth-provider-node/3925f4320701b0999b52b28ee4156fa07cbc9f98/examples/common/images/uploads/.gitkeep -------------------------------------------------------------------------------- /examples/common/pages/home.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Home 3 | --- 4 | 5 | This is the home page. 6 | -------------------------------------------------------------------------------- /examples/common/render.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const frontmatter = require('front-matter'); 4 | const marked = require('marked'); 5 | 6 | async function render(name) { 7 | // Resolve the path to the possible markdown file 8 | const fullPathToPossibleMarkdownFile = path.resolve( 9 | __dirname, 10 | `./pages/${name}${name.endsWith('.md') ? '' : '.md'}`, 11 | ); 12 | // Attempt to read the file, returning false ("no I did not handle this request") if any error occurs reading the file. 13 | let markdownFileContents = null; 14 | try { 15 | markdownFileContents = await fs.promises.readFile(fullPathToPossibleMarkdownFile, { encoding: 'utf8' }); 16 | } catch (ignored) { 17 | return false; 18 | } 19 | // Parse the file first using `front-matter` and `marked` 20 | const fileFrontmatter = frontmatter(markdownFileContents); 21 | const htmlFileContents = marked(fileFrontmatter.body); 22 | // Generate the HTML, using the frontmatter to generate the title. 23 | return `${fileFrontmatter.attributes.title || ''}` 24 | + `\n${htmlFileContents}`; 25 | } 26 | 27 | /** 28 | * @returns {Promise<[string, string][]>} 29 | */ 30 | async function renderAllPages() { 31 | const fullPathToPages = path.resolve( 32 | __dirname, 33 | `./pages`, 34 | ); 35 | const filesInPagesDir = await fs.promises.readdir(fullPathToPages, { encoding: 'utf8', withFileTypes: true }); 36 | return Promise.all( 37 | filesInPagesDir 38 | .reduce((entries, dirEnt) => { 39 | if (dirEnt.isFile() && dirEnt.name.endsWith('.md')) { 40 | entries.push( 41 | render(dirEnt.name) 42 | .then((html) => [ 43 | dirEnt.name.replace(/\.md$/i, '.html'), 44 | html, 45 | ]) 46 | ); 47 | } 48 | return entries; 49 | }, []), 50 | ); 51 | } 52 | 53 | module.exports = { render, renderAllPages }; 54 | -------------------------------------------------------------------------------- /examples/generic/.gitignore: -------------------------------------------------------------------------------- 1 | public/ 2 | .env 3 | 4 | # Note that we want this example to always use the latest version of the main package so we don't lock 5 | yarn.lock 6 | 7 | .now 8 | .vercel 9 | -------------------------------------------------------------------------------- /examples/generic/README.md: -------------------------------------------------------------------------------- 1 | # Generic Example 2 | 3 | ## Prerequisites 4 | 5 | - Node 11.14+ (`fs.promises`) 6 | 7 | ## Instructions 8 | 9 | 1. Inside the root of the project (not this example directory), install its dependencies and link the library sources 10 | globally: 11 | ```shell script 12 | yarn 13 | yarn link 14 | ``` 15 | 1. Now move into this example directory, install its deps, and complete the link so the node server uses the sources 16 | locally: 17 | ```shell script 18 | cd examples/generic/ 19 | yarn 20 | yarn link netlify-cms-oauth-provider-node 21 | ``` 22 | 2. Create a `.env` file in this directory with the following contents, filling in `OAUTH_CLIENT_ID` and 23 | `OAUTH_CLIENT_SECRET` with your GitHub OAuth app's ID and secret. 24 | ```text 25 | DEBUG=netlify-cms-oauth-provider-node* 26 | NODE_ENV=development 27 | HOSTNAME=localhost 28 | PORT=3000 29 | OAUTH_CLIENT_ID= 30 | OAUTH_CLIENT_SECRET= 31 | ``` 32 | 3. If you've forked this repository, update [`config.yml`](./config.yml) with your repo. Otherwise you will be in a 33 | read-only mode OR the login will fail since you (probably) won't have write access to this package's repository. 34 | 4. Run the dev server: 35 | ```shell script 36 | yarn start 37 | ``` 38 | 5. Visit the local dev server at `http://localhost:3000` 39 | -------------------------------------------------------------------------------- /examples/generic/admin.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Netlify CMS + netlify-cms-oauth-provider-node Test 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /examples/generic/config.yml: -------------------------------------------------------------------------------- 1 | backend: 2 | name: github 3 | repo: bericp1/netlify-cms-oauth-provider-node 4 | branch: master 5 | base_url: http://localhost:3000 6 | auth_endpoint: api/admin/auth/begin 7 | publish_mode: editorial_workflow 8 | media_folder: "examples/common/images/uploads" 9 | collections: 10 | - name: "pages" 11 | label: "Pages" 12 | folder: "examples/common/pages" 13 | create: true 14 | slug: "{{slug}}" 15 | fields: 16 | - {label: "Title", name: "title", widget: "string"} 17 | - {label: "Body", name: "body", widget: "markdown"} 18 | -------------------------------------------------------------------------------- /examples/generic/index.js: -------------------------------------------------------------------------------- 1 | const http = require('http'); 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | const { render } = require('../common/render'); // eslint-disable-line node/no-unpublished-require 5 | const netlifyCmsOAuth = require('netlify-cms-oauth-provider-node'); 6 | 7 | const port = process.env.PORT || 3000; 8 | const hostname = process.env.HOSTNAME || 'localhost'; 9 | 10 | // Create the handlers, using env variables for the ones not explicitly specified. 11 | const netlifyCmsOAuthHandlers = netlifyCmsOAuth.createHandlers({ 12 | origin: `${hostname}:${port}`, 13 | completeUrl: `http://${hostname}${port === 80 ? '' : `:${port}`}/api/admin/auth/complete`, 14 | adminPanelUrl: `http://${hostname}${port === 80 ? '' : `:${port}`}/admin`, 15 | oauthProvider: 'github', 16 | }, { 17 | useEnv: true, 18 | }); 19 | 20 | /** 21 | * Return a 404 to the user. This is our fallback route. 22 | * 23 | * @param {IncomingMessage} req 24 | * @param {OutgoingMessage} res 25 | * @return {Promise} 26 | */ 27 | async function handleNotFound(req, res) { 28 | res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' }); 29 | res.end('Not found.'); 30 | } 31 | 32 | /** 33 | * Creates a request handler that performs a redirect. 34 | * 35 | * TODO This probably doesn't need to be a factory function / decorator like it is based on the usages below. 36 | * 37 | * @param {string} to 38 | * @param {number=302} status 39 | * @return {function(req: IncomingMessage, res: OutgoingMessage): void} 40 | */ 41 | function createRedirectHandler(to, status = 302) { 42 | return function (req, res) { 43 | res.writeHead(status, { Location: to, 'Content-Type': 'text/html; charset=utf-8' }); 44 | res.end(`Redirecting to ${to}...`); 45 | }; 46 | } 47 | 48 | /** 49 | * Creates a request handler that serves a specific static file. 50 | * 51 | * @param {string} filename An absolute file path or one relative to the server root directory 52 | * @param {(string|null)=} type A valid mime type. If not set, no Content-Type will be sent to the client. 53 | * @return {function(req: IncomingMessage, res: OutgoingMessage, ctx: object): Promise} 54 | */ 55 | function createStaticFileHandler(filename, type = null) { 56 | return async function (req, res, ctx) { 57 | try { 58 | // Get the full absolute path to the file. 59 | const fullPath = path.resolve(__dirname, filename); 60 | // Get its contents 61 | // TODO Improve by using streams or just node-static 62 | const fileContents = await fs.promises.readFile(fullPath, { encoding: 'utf8' }); 63 | // Generate the headers containing the mime type 64 | const headers = type ? { 'Content-Type': type } : {}; 65 | // Write the header and file contents out 66 | res.writeHead(200, headers); 67 | res.write(fileContents); 68 | } catch (error) { 69 | // If an error occurred, we'll just write out a 404 70 | // TODO Improve error handling, i.e. a 500 when this is an unexpected error 71 | return handleNotFound(req, res, ctx); 72 | } finally { 73 | // Always end the response 74 | res.end(); 75 | } 76 | } 77 | } 78 | 79 | /** 80 | * Handles the request to kick off the admin OAuth flow using this library. 81 | * 82 | * @param {IncomingMessage} req 83 | * @param {OutgoingMessage} res 84 | * @return {Promise} 85 | */ 86 | async function handleAdminAuthBegin(req, res) { 87 | // Generate the auth URI and redirect the user there. 88 | const authorizationUri = await netlifyCmsOAuthHandlers.begin(); 89 | return createRedirectHandler(authorizationUri)(req, res); 90 | } 91 | 92 | /** 93 | * Handles the request to complete the admin OAuth flow using this library. 94 | * 95 | * @param {IncomingMessage} req 96 | * @param {OutgoingMessage} res 97 | * @param {URL} parsedRequest 98 | * @return {Promise} 99 | */ 100 | async function handleAdminAuthComplete(req, res, { parsedRequest }) { 101 | // Extract the code from the query parameters 102 | const code = parsedRequest.searchParams.get('code') || null; 103 | // Allow the library to complete the oauth flow, exchange the auth code for an access token, and generate the popup HTML that 104 | // will hand it off to the netlify-cms admin panel using the `postMessage` API. 105 | const content = await netlifyCmsOAuthHandlers.complete(code); 106 | res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); 107 | res.end(content); 108 | } 109 | 110 | /** 111 | * Will serve the netlify-cms config file. 112 | * 113 | * @type {function(IncomingMessage, OutgoingMessage): Promise} 114 | */ 115 | const handleAdminConfig = createStaticFileHandler('config.yml', 'text/yaml; charset=utf-8'); 116 | 117 | /** 118 | * Will serve the netlify-cms main HTML file. 119 | * 120 | * @type {function(IncomingMessage, OutgoingMessage): Promise} 121 | */ 122 | const handleAdmin = createStaticFileHandler('admin.html', 'text/html; charset=utf-8'); 123 | 124 | /** 125 | * Possibly handle a requested markdown page. The provided route is compared to our available markdown page files 126 | * and if one is found, it's compiled via `front-matter` and `marked` served to the user as HTML. Otherwise it does 127 | * nothing. 128 | * 129 | * @param {IncomingMessage} req 130 | * @param {OutgoingMessage} res 131 | * @param {string} route 132 | * @return {Promise} Resolves with true if the request was handled (a page was found and rendered) or false 133 | * otherwise. 134 | */ 135 | async function handlePage(req, res, { route }) { 136 | const pageName = route.replace(/^\//, ''); 137 | const finalHtml = await render(pageName); 138 | // Serve the HTML to the user. 139 | res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); 140 | res.end(finalHtml); 141 | return true; 142 | } 143 | 144 | /** 145 | * Our server request handler. Node's `http` server module will run this function for every incoming request. 146 | * 147 | * @param {IncomingMessage} req 148 | * @param {OutgoingMessage} res 149 | * @return {Promise} 150 | */ 151 | async function handleRequest(req, res) { 152 | // Parse the request 153 | const parsedRequest = new URL(req.url, `http://${req.headers.host}`); 154 | const route = parsedRequest.pathname.toLowerCase().trim().replace(/\/+$/, '') || '/'; 155 | 156 | // Generate a context object that gets passed to all of our handlers so they have extra info about the request 157 | const ctx = { parsedRequest, route }; 158 | 159 | // Redirect to canonical routes if the original route doesn't match the final processed route. 160 | if (ctx.route !== ctx.parsedRequest.pathname) { 161 | console.log(`Redirecting: '${ctx.parsedRequest.pathname}' -> '${ctx.route}'`); 162 | await createRedirectHandler(`http://${req.headers.host}${ctx.route}`, 301)(req, res, ctx); 163 | return; 164 | } 165 | 166 | // Manually suppoort some aliases 167 | // TODO Also redirect from one to the other so that one is treated as canonical 168 | // TODO Abstract this out into a Map or something 169 | if (ctx.route === '/') { 170 | console.log(`Serving alias: '${ctx.route}' => '/home'...`); 171 | ctx.route = '/home'; 172 | } else { 173 | console.log(`Serving: '${ctx.route}'`); 174 | } 175 | 176 | // Simplistic routing using a good, old-fashioned set of conditionals 177 | if (ctx.route === '/api/admin/auth/begin') { 178 | return handleAdminAuthBegin(req, res, ctx); 179 | } else if (ctx.route === '/api/admin/auth/complete') { 180 | return handleAdminAuthComplete(req, res, ctx); 181 | } else if (ctx.route === '/admin/config.yml' || ctx.route === '/config.yml') { 182 | return handleAdminConfig(req, res, ctx); 183 | } else if (ctx.route.startsWith('/admin')) { 184 | return handleAdmin(req, res, ctx); 185 | } 186 | 187 | // If none of the above explicit routes matched, see if we can match against the markdown pages 188 | const handledPage = await handlePage(req, res, ctx); 189 | 190 | // If the markdown pages didn't match, finally just send a 404 191 | if (!handledPage) { 192 | return handleNotFound(req, res, ctx); 193 | } 194 | } 195 | 196 | // Create the server 197 | // TODO Support `https` 198 | const server = http.createServer(handleRequest); 199 | 200 | // Listen on the desired port 201 | server.listen(port, () => { 202 | console.log(`Listening on port ${port}...`); 203 | }); 204 | -------------------------------------------------------------------------------- /examples/generic/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "netlify-cms-oauth-provider-node-generic-example", 3 | "private": true, 4 | "main": "index.js", 5 | "scripts": { 6 | "start": "node -r dotenv/config index.js" 7 | }, 8 | "engines": { 9 | "node": ">=11.14.0" 10 | }, 11 | "dependencies": { 12 | "dotenv": "^8.2.0", 13 | "front-matter": "^3.1.0", 14 | "marked": "^0.8.2", 15 | "netlify-cms-oauth-provider-node": "latest" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /examples/vercel/.gitignore: -------------------------------------------------------------------------------- 1 | public/ 2 | .env 3 | 4 | # Note that we want this example to always use the latest version of the main package so we don't lock 5 | yarn.lock 6 | 7 | .now 8 | .vercel 9 | -------------------------------------------------------------------------------- /examples/vercel/README.md: -------------------------------------------------------------------------------- 1 | # Zeit Vercel Serverless Functions Examples 2 | 3 | ## Prerequisites 4 | 5 | - [The `vercel` CLI](https://zeit.co/download): `npm i -g vercel@latest` 6 | 7 | ## Instructions 8 | 9 | **Note:** Right now this example _does not_ use the local source files in the serverless functions inside 10 | [`api/`](./api) since, at the time of this writing, `vercel dev` does not support symlinked packages or 11 | local dependencies. 12 | 13 | 1. Install the latest version of the main package as a dep to this example: 14 | ```shell script 15 | yarn add netlify-cms-oauth-provider-node@latest 16 | ``` 17 | 2. Create a `.env` file in this directory with the following contents, filling in `OAUTH_CLIENT_ID` and 18 | `OAUTH_CLIENT_SECRET` with your GitHub OAuth app's ID and secret. Also ensure that your GitHub OAuth app's 19 | callback URL matches `COMPLETE_URL`. 20 | ```text 21 | DEBUG=netlify-cms-oauth-provider-node* 22 | COMPLETE_URL=http://localhost:3000/api/complete 23 | OAUTH_CLIENT_ID= 24 | OAUTH_CLIENT_SECRET= 25 | NODE_ENV=development 26 | OAUTH_PROVIDER=github 27 | ORIGIN=localhost:3000 28 | ``` 29 | 3. If you've forked this repository, update [`config.yml`](./config.yml) with your repo. Otherwise you will be in a 30 | read-only mode OR the login will fail since you (probably) won't have write access to this package's repository. 31 | 4. Run the dev server: 32 | ```shell script 33 | yarn start 34 | ``` 35 | 5. Visit the local dev server at (likely) `http://localhost:3000` 36 | -------------------------------------------------------------------------------- /examples/vercel/admin.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Netlify CMS + netlify-cms-oauth-provider-node Vercel Test 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /examples/vercel/api/begin.js: -------------------------------------------------------------------------------- 1 | const { createVercelBeginHandler } = require('netlify-cms-oauth-provider-node'); 2 | 3 | module.exports = createVercelBeginHandler({}, { useEnv: true }); 4 | -------------------------------------------------------------------------------- /examples/vercel/api/complete.js: -------------------------------------------------------------------------------- 1 | const { createVercelCompleteHandler } = require('netlify-cms-oauth-provider-node'); 2 | 3 | module.exports = createVercelCompleteHandler({}, { useEnv: true }); 4 | -------------------------------------------------------------------------------- /examples/vercel/build.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const { renderAllPages } = require('../common/render'); // eslint-disable-line node/no-unpublished-require 4 | 5 | renderAllPages() 6 | .then((pages) => Promise.all(pages.map(async ([fileName, html]) => { 7 | const finalFileName = fileName === 'home.html' ? 'index.html' : fileName; 8 | const publicPathForFile = path.resolve(__dirname, './public', finalFileName); 9 | console.log(`Writing ${finalFileName} to ${publicPathForFile}`); 10 | await fs.promises.writeFile(publicPathForFile, html); 11 | }))); 12 | -------------------------------------------------------------------------------- /examples/vercel/config.yml: -------------------------------------------------------------------------------- 1 | backend: 2 | name: github 3 | repo: bericp1/netlify-cms-oauth-provider-node 4 | branch: master 5 | base_url: http://localhost:3000 6 | auth_endpoint: api/begin 7 | publish_mode: editorial_workflow 8 | media_folder: "examples/common/images/uploads" 9 | collections: 10 | - name: "pages" 11 | label: "Pages" 12 | folder: "examples/common/pages" 13 | create: true 14 | slug: "{{slug}}" 15 | fields: 16 | - {label: "Title", name: "title", widget: "string"} 17 | - {label: "Body", name: "body", widget: "markdown"} 18 | -------------------------------------------------------------------------------- /examples/vercel/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "netlify-cms-oauth-provider-node-vercel-example", 3 | "private": true, 4 | "scripts": { 5 | "start": "vercel dev", 6 | "clean": "rm -rf public/", 7 | "build": "yarn run clean && mkdir public && cp admin.html config.yml public/ && node ./build.js" 8 | }, 9 | "engines": { 10 | "node": "14.x" 11 | }, 12 | "dependencies": { 13 | "front-matter": "^4.0.2", 14 | "marked": "^2.0.1", 15 | "netlify-cms-oauth-provider-node": "latest" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /examples/vercel/vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "cleanUrls": true 3 | } 4 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib'); 2 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // Automatically clear mock calls and instances between every test 3 | clearMocks: true, 4 | 5 | // The directory where Jest should output its coverage files 6 | coverageDirectory: 'coverage', 7 | 8 | // The test environment that will be used for testing 9 | testEnvironment: 'node', 10 | 11 | // Source files roots (limit to `lib/`) 12 | roots: ['/lib'], 13 | 14 | // Looks for tests in the __tests__ folder or alongside js files with the .(test|spec).js 15 | testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.js$', 16 | }; 17 | -------------------------------------------------------------------------------- /lib/config.js: -------------------------------------------------------------------------------- 1 | const { has } = require('ramda'); 2 | const convict = require('./convict'); 3 | const { createDebug } = require('./debug'); 4 | 5 | const DEBUG_KEY = 'config'; 6 | 7 | const debug = createDebug(DEBUG_KEY); 8 | 9 | const keyForValidatedConfigs = Symbol('validatedConfig'); 10 | 11 | const definition = { 12 | dev: { 13 | doc: 'Enabled more verbose errors in the UI, etc.', 14 | format: Boolean, 15 | default: process.env.NODE_ENV === 'development', 16 | env: 'DEV', 17 | }, 18 | origin: { 19 | doc: 20 | 'The HTTP origin of the host of the netlify-cms admin panel using this OAuth provider. ' + 21 | 'Multiple origin domains can be provided as an array of strings or a single comma-separated string. ' + 22 | "You can provide only the domain part (`'example.com'`) which implies any protocol on any port or you can explicitly " + 23 | "specify a protocol and/or port (`'https://example.com'` or `'http://example.com:8080'`)", 24 | format: 'origin-list', 25 | default: null, 26 | allowEmpty: false, 27 | env: 'ORIGIN', 28 | }, 29 | completeUrl: { 30 | doc: 31 | 'The URL (specified during the OAuth 2.0 authorization flow) that the `complete` handler is hosted at.', 32 | default: null, 33 | format: String, 34 | env: 'COMPLETE_URL', 35 | }, 36 | adminPanelUrl: { 37 | doc: 38 | 'The URL of the admin panel to link the user back to in case something goes horribly wrong.', 39 | default: '', 40 | format: String, 41 | env: 'ADMIN_PANEL_URL', 42 | }, 43 | oauthProvider: { 44 | doc: 'The Git service / OAuth provider to use.', 45 | default: 'github', 46 | format: ['github'], 47 | env: 'OAUTH_PROVIDER', 48 | }, 49 | oauthClientID: { 50 | doc: 'The OAuth 2.0 Client ID received from the OAuth provider.', 51 | default: null, 52 | format: String, 53 | env: 'OAUTH_CLIENT_ID', 54 | }, 55 | oauthClientSecret: { 56 | doc: 'The OAuth 2.0 Client secret received from the OAuth provider.', 57 | default: null, 58 | format: String, 59 | env: 'OAUTH_CLIENT_SECRET', 60 | sensitive: true, 61 | }, 62 | oauthTokenHost: { 63 | doc: 64 | 'The OAuth 2.0 token host URI for the OAuth provider. ' + 65 | 'If not provided, this will be guessed based on the provider. ' + 66 | 'You must provide this for GitHub enterprise.', 67 | default: '', 68 | format: String, 69 | env: 'OAUTH_TOKEN_HOST', 70 | }, 71 | oauthTokenPath: { 72 | doc: 73 | 'The relative URI to the OAuth 2.0 token endpoint for the OAuth provider. ' + 74 | 'If not provided, this will be guessed based on the provider.', 75 | default: '', 76 | format: String, 77 | env: 'OAUTH_TOKEN_PATH', 78 | }, 79 | oauthAuthorizePath: { 80 | doc: 81 | 'The relative URI to the OAuth 2.0 authorization endpoint for the OAuth provider. ' + 82 | 'If not provided, this will be guessed based on the provider.', 83 | default: '', 84 | format: String, 85 | env: 'OAUTH_AUTHORIZE_PATH', 86 | }, 87 | oauthScopes: { 88 | doc: 89 | 'The scopes to claim during the OAuth 2.0 authorization request with the OAuth provider. ' + 90 | 'If not provided, this will be guessed based on the provider with the goal to ensure the user has ' + 91 | 'read/write access to repositories.', 92 | default: '', 93 | format: String, 94 | env: 'OAUTH_SCOPES', 95 | }, 96 | }; 97 | 98 | /** 99 | * Mutates the provided object, marking it as a validated config so we can check for it later. 100 | * @param {object} config 101 | */ 102 | function markConfigAsValidated(config) { 103 | config[keyForValidatedConfigs] = true; 104 | } 105 | 106 | /** 107 | * Determine if a given value is a validated and authentic config. 108 | * 109 | * @param {object} config 110 | * @return {boolean} 111 | */ 112 | function isConfigValidated(config) { 113 | return !!( 114 | config && 115 | typeof config === 'object' && 116 | has(keyForValidatedConfigs, config) && 117 | config[keyForValidatedConfigs] 118 | ); 119 | } 120 | 121 | /** 122 | * @typedef {{ 123 | * skipAlreadyValidatedCheck?: boolean, 124 | * useEnv?: boolean, 125 | * useArgs?: boolean, 126 | * extraConvictOptions?: {}, 127 | * extraValidateOptions?: {} 128 | * }} CreateConfigOptions 129 | */ 130 | 131 | /** 132 | * @typedef {{get: function(string): *}} ConvictConfig 133 | */ 134 | 135 | /** 136 | * Create and validate a convict configuration instance for this package. 137 | * 138 | * @param {{}=} userConfig 139 | * @param {boolean=} skipAlreadyValidatedCheck Set to true to always try to reload and revalidate the provided config 140 | * @param {boolean=} useEnv Set to true to try to extract config values from environment variables 141 | * @param {boolean=} useArgs Set to true to try to extract config values from command line arguments 142 | * @param {{}=} extraConvictOptions Additional options to pass directly to convict 143 | * @param {{}=} extraValidateOptions Additional options to pass directly to convict's validate function. 144 | * @return {{ get: function(string?): *, toObject: function(): {} }} The convict config instance 145 | */ 146 | function createConfig( 147 | userConfig = {}, 148 | { 149 | skipAlreadyValidatedCheck = false, 150 | useEnv = false, 151 | useArgs = false, 152 | extraConvictOptions = {}, 153 | extraValidateOptions = {}, 154 | } = {}, 155 | ) { 156 | // If the config provided is already a validated config, we can just straight up return it. 157 | if (!skipAlreadyValidatedCheck && isConfigValidated(userConfig)) { 158 | return userConfig; 159 | } 160 | 161 | // Build out convict options 162 | const convictOptions = {}; 163 | if (!useEnv) { 164 | convictOptions.env = {}; 165 | } 166 | if (!useArgs) { 167 | convictOptions.args = []; 168 | } 169 | 170 | // Merge together options 171 | const processedOptions = { 172 | ...convictOptions, 173 | ...extraConvictOptions, 174 | }; 175 | 176 | // Build the config based on our definition and options 177 | const config = convict(definition, processedOptions); 178 | 179 | // Merge in the user config 180 | config.load(userConfig); 181 | 182 | // Validate the config; this throws if the config is invalid 183 | config.validate({ 184 | allowed: 'warn', 185 | output: debug, 186 | ...extraValidateOptions, 187 | }); 188 | 189 | // Mark the config as authentic and validated 190 | markConfigAsValidated(config); 191 | 192 | // Decorate the config with a util function that converts the config into a plain object (hiding sensitive fields) 193 | config.toObject = () => { 194 | return JSON.parse(config.toString()); 195 | }; 196 | 197 | // Return it 198 | return config; 199 | } 200 | 201 | module.exports = { 202 | debug, 203 | DEBUG_KEY, 204 | definition, 205 | markConfigAsValidated, 206 | keyForValidatedConfigs, 207 | isConfigValidated, 208 | createConfig, 209 | }; 210 | -------------------------------------------------------------------------------- /lib/convict.js: -------------------------------------------------------------------------------- 1 | const { format } = require('util'); 2 | const convict = require('convict'); 3 | 4 | function isInvalidDueToEmptiness(finalVal, schema) { 5 | return schema.allowEmpty === false && finalVal.length === 0; 6 | } 7 | 8 | /** 9 | * A format for convict that coerces comma-separated strings into arrays. 10 | * 11 | * @type {{name: string, coerce: function, validate: function}} 12 | */ 13 | const listFormat = (() => { 14 | const coerce = (val, schema) => { 15 | if (!Array.isArray(val) && typeof val === 'object') { 16 | return null; 17 | } 18 | 19 | // First, coerce the value into an array by treating it as a comma-separated string if it isn't already an array. 20 | const processedVal = !Array.isArray(val) 21 | ? `${val || ''}`.trim().split(',') 22 | : val; 23 | 24 | // We don't use map or reduce here so we can bail early if we stumble upon a bad value. 25 | const finalVal = []; 26 | 27 | for (const item of processedVal) { 28 | if ( 29 | typeof item === 'object' || 30 | typeof item === 'undefined' || 31 | typeof item === 'symbol' || 32 | typeof item === 'function' 33 | ) { 34 | return null; 35 | } 36 | finalVal.push(item); 37 | } 38 | 39 | return finalVal; 40 | }; 41 | 42 | const validate = (val, schema) => { 43 | const finalVal = coerce(val); 44 | if (finalVal === null || isInvalidDueToEmptiness(finalVal, schema)) { 45 | throw new Error( 46 | format( 47 | 'Expected array or string of comma-separated values, received:', 48 | val, 49 | ), 50 | ); 51 | } 52 | }; 53 | 54 | return { 55 | name: 'list', 56 | validate, 57 | coerce, 58 | }; 59 | })(); 60 | 61 | const originListFormat = (() => { 62 | const coerce = (...restArgs) => { 63 | const listVal = listFormat.coerce(...restArgs); 64 | if (listVal === null) { 65 | return listVal; 66 | } 67 | 68 | // We don't use map or reduce here so we can bail early if we stumble upon a bad value. 69 | const finalVal = []; 70 | 71 | for (const item of listVal) { 72 | const processedItem = `${item}`.trim().toLowerCase(); 73 | if (!processedItem) { 74 | return null; 75 | } 76 | finalVal.push(processedItem); 77 | } 78 | 79 | return finalVal; 80 | }; 81 | 82 | const validate = (val, schema) => { 83 | const finalVal = coerce(val); 84 | if (finalVal === null || isInvalidDueToEmptiness(finalVal, schema)) { 85 | throw new Error( 86 | format( 87 | 'Expected array or string of comma-separated HTTP origins, received:', 88 | val, 89 | ), 90 | ); 91 | } 92 | }; 93 | 94 | return { 95 | name: 'origin-list', 96 | coerce, 97 | validate, 98 | }; 99 | })(); 100 | 101 | convict.addFormat(listFormat); 102 | 103 | convict.addFormat(originListFormat); 104 | 105 | module.exports = convict; 106 | -------------------------------------------------------------------------------- /lib/debug.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug'); 2 | const { flatten } = require('ramda'); 3 | 4 | const DEBUG_ROOT_KEY = 'netlify-cms-oauth-provider-node'; 5 | 6 | const DEBUG_KEY_SEPARATOR = ':'; 7 | 8 | /** 9 | * Create a debug logger function that's pre-namespaced to this package. 10 | * 11 | * @param {(string|string[])...} keys 12 | * @return {function} 13 | */ 14 | function createDebug(...keys) { 15 | const processedKeys = [DEBUG_ROOT_KEY, ...flatten(keys)]; 16 | return debug(processedKeys.join(DEBUG_KEY_SEPARATOR)); 17 | } 18 | 19 | module.exports = { 20 | DEBUG_ROOT_KEY, 21 | DEBUG_KEY_SEPARATOR, 22 | debug: debug(DEBUG_ROOT_KEY), 23 | createDebug, 24 | }; 25 | -------------------------------------------------------------------------------- /lib/handlers/generic.js: -------------------------------------------------------------------------------- 1 | const { 2 | coerceErrorToMessage, 3 | receivesUserConfig, 4 | renderTemplate, 5 | generateRegexPatternFromOriginList, 6 | } = require('./utils'); 7 | const { createDebug } = require('../debug'); 8 | const { createProvider } = require('../providers'); 9 | 10 | const DEBUG_KEY = 'handlers:generic'; 11 | const debug = createDebug(DEBUG_KEY); 12 | 13 | /** 14 | * Given config, create an async function that resolves to an OAuth authorization URI to begin the provider OAuth flow. 15 | * 16 | * The returned function takes a single param, an object containing extra query params used to generate the URI. 17 | * 18 | * @type {function(object=, CreateConfigOptions): function(string=): Promise} 19 | */ 20 | const createBeginHandler = receivesUserConfig((config) => { 21 | const provider = createProvider(config.get('oauthProvider'), config); 22 | return async ({ state = null, ...restParams } = {}) => { 23 | const url = provider.generateAuthorizeUri({ state, ...restParams }); 24 | debug( 25 | "Generated authorization URL for provider '%s': %s", 26 | provider.getNameForNetlify(), 27 | url, 28 | ); 29 | return url; 30 | }; 31 | }); 32 | 33 | /** 34 | * Given config, create an async function that takes an authorization code from the provider and resolves with a string 35 | * of HTML that can be rendered in the user's browser to communicate with the netlify-cms instance that opened the window 36 | * to send it the access token we received from the provider (or the error if it occurred). 37 | * 38 | * @type {function(object=, CreateConfigOptions): function(string, object=): Promise} 39 | */ 40 | const createCompleteHandler = receivesUserConfig((config) => { 41 | const provider = createProvider(config.get('oauthProvider'), config); 42 | return async (code, params = {}) => { 43 | const title = `Logging you in via ${provider.getDisplayName()}...`; 44 | const view = { 45 | title, 46 | description: title, 47 | oauthProvider: provider.getNameForNetlify(), 48 | originPattern: generateRegexPatternFromOriginList( 49 | config.get('origin'), 50 | ), 51 | adminPanelLink: { 52 | url: config.get('adminPanelUrl') || '#', 53 | target: config.get('adminPanelUrl') ? '_blank' : '_self', 54 | }, 55 | }; 56 | 57 | debug( 58 | "Exchanging authorization token for provider '%s'...", 59 | provider.getNameForNetlify(), 60 | ); 61 | 62 | if (code) { 63 | try { 64 | const accessToken = await provider.exchangeAuthorizationCodeForToken( 65 | code, 66 | params, 67 | ); 68 | view.message = 'success'; 69 | view.content = JSON.stringify({ 70 | token: accessToken.token.access_token, 71 | provider: view.oauthProvider, 72 | }); 73 | view.display = title; 74 | } catch (e) { 75 | const errorMessage = coerceErrorToMessage(e, { 76 | dev: config.get('dev'), 77 | }) 78 | .replace(/:/g, ' –') 79 | .replace(/(\r\n|\r|\n)/, ' – '); 80 | view.message = 'error'; 81 | view.content = `An error occurred. ${errorMessage}`; 82 | view.display = `An error occurred. Please close this page and try again. ${errorMessage}`; 83 | view.displayClasses = 'error'; 84 | } 85 | } else { 86 | const errorMessage = `Invalid code received from ${provider.getDisplayName()} or code could not be received. `; 87 | view.message = 'error'; 88 | view.content = `An error occurred. ${errorMessage}`; 89 | view.display = `An error occurred. Please close this page and try again. ${errorMessage}`; 90 | view.displayClasses = 'error'; 91 | } 92 | 93 | debug( 94 | 'Rendering HTML template to communicate access token back to netlify-cms', 95 | ); 96 | 97 | return renderTemplate('complete.html', view); 98 | }; 99 | }); 100 | 101 | const createHandlers = receivesUserConfig((config) => { 102 | return { 103 | begin: createBeginHandler(config), 104 | complete: createCompleteHandler(config), 105 | }; 106 | }); 107 | 108 | module.exports = { 109 | createHandlers, 110 | createBeginHandler, 111 | createCompleteHandler, 112 | }; 113 | -------------------------------------------------------------------------------- /lib/handlers/generic.test.js: -------------------------------------------------------------------------------- 1 | const { createHandlers } = require('./generic'); 2 | 3 | const okayConfig = { 4 | origin: 'localhost', 5 | completeUrl: 'https://localhost/complete', 6 | oauthClientID: 'abc123', 7 | oauthClientSecret: 'def456', 8 | }; 9 | 10 | test('createHandlers works', () => { 11 | const handlers = createHandlers(okayConfig); 12 | expect(typeof handlers).toBe('object'); 13 | expect(handlers).toHaveProperty('begin'); 14 | expect(handlers).toHaveProperty('complete'); 15 | expect(typeof handlers.begin).toBe('function'); 16 | expect(typeof handlers.complete).toBe('function'); 17 | }); 18 | -------------------------------------------------------------------------------- /lib/handlers/index.js: -------------------------------------------------------------------------------- 1 | const generic = require('./generic'); 2 | const vercel = require('./vercel'); 3 | 4 | module.exports = { 5 | generic, 6 | vercel, 7 | }; 8 | -------------------------------------------------------------------------------- /lib/handlers/templates/complete.html.mustache: -------------------------------------------------------------------------------- 1 | {{>head.html}} 2 |

{{display}}

3 | 7 | {{>common-scripts.html}} 8 | 52 | {{>foot.html}} 53 | -------------------------------------------------------------------------------- /lib/handlers/templates/error.html.mustache: -------------------------------------------------------------------------------- 1 | {{>head.html}} 2 |

3 | {{#message}} 4 | {{message}} 5 | {{/message}} 6 | {{^message}} 7 | An unknown error occurred. 8 | {{/message}} 9 |

10 |

11 | Please return to {{>admin-panel-link.html}} and try to log in again. 12 |

13 | {{>foot.html}} 14 | -------------------------------------------------------------------------------- /lib/handlers/templates/partials/admin-panel-link.html.mustache: -------------------------------------------------------------------------------- 1 | {{#adminPanelLink}} 2 | the admin panel 3 | {{/adminPanelLink}} 4 | {{^adminPanelLink}} 5 | the admin panel 6 | {{/adminPanelLink}} 7 | -------------------------------------------------------------------------------- /lib/handlers/templates/partials/common-scripts.html.mustache: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /lib/handlers/templates/partials/foot.html.mustache: -------------------------------------------------------------------------------- 1 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /lib/handlers/templates/partials/head.html.mustache: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{#title}} 7 | {{title}} 8 | {{/title}} 9 | {{#description}} 10 | 11 | {{/description}} 12 | 13 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /lib/handlers/utils.js: -------------------------------------------------------------------------------- 1 | const { promisify } = require('util'); 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | const Mustache = require('mustache'); 5 | const escapeStringRegexp = require('escape-string-regexp'); 6 | const { fromPairs } = require('ramda'); 7 | const { MultiError, fullStack } = require('verror'); 8 | const { createConfig } = require('../config'); 9 | const { createDebug } = require('../debug'); 10 | 11 | const readFileAsync = promisify(fs.readFile); 12 | const readdirAsync = promisify(fs.readdir); 13 | 14 | const DEBUG_KEY = 'handlers:utils'; 15 | const debug = createDebug(DEBUG_KEY); 16 | 17 | const TEMPLATES_DIR = path.join(__dirname, 'templates'); 18 | const PARTIALS_DIR = path.join(TEMPLATES_DIR, 'partials'); 19 | 20 | /** 21 | * A higher order function that will automatically process the config going into the function through our convict schema. 22 | * 23 | * The wrapper function takes 2 arguments: the raw user config and options to pass to `createConfig`. The wrapped function is called 24 | * with the compiled, validated config and whatever remaining arguments the wrapper function was called with. 25 | * 26 | * @param {function(ConvictConfig, ...[*]): *} fn 27 | * @return {function(object=, CreateConfigOptions, ...[*]): *} 28 | */ 29 | function receivesUserConfig(fn) { 30 | return function configAcceptor( 31 | extraConfig = {}, 32 | { useEnv = false, useArgs = false, ...restOpts } = {}, 33 | ...restArgs 34 | ) { 35 | const options = { 36 | useEnv, 37 | useArgs, 38 | ...restOpts, 39 | }; 40 | const config = createConfig(extraConfig, options); 41 | debug('Compiled config with options for wrapped function: %o', { 42 | config: config.toObject(), 43 | options, 44 | fn, 45 | }); 46 | return fn(config, ...restArgs); 47 | }; 48 | } 49 | 50 | /** 51 | * Load the partials. This will only ever load the partials off the disk once, returning the cached partials map object upon subsequent 52 | * calls. 53 | * 54 | * @type {function(): Promise>} 55 | */ 56 | const loadPartials = (() => { 57 | const debug = createDebug(DEBUG_KEY, 'loadPartials'); 58 | 59 | /** 60 | * Our cached partials 61 | * @type {null|Readonly|Promise>} 62 | */ 63 | let partials = null; 64 | 65 | /** 66 | * Actually load the partials into a frozen read-only object from the disk. The object is keyed by the partials' names. 67 | * 68 | * @return {Promise>} 69 | */ 70 | async function reallyLoadPartials({ encoding = 'utf8' } = {}) { 71 | // Grab a list of the names of all the files in the `partials` directory 72 | const possiblePartialFilenames = await readdirAsync(PARTIALS_DIR); 73 | const partialFiles = possiblePartialFilenames.reduce( 74 | (files, filename) => { 75 | // Track the template name (filename less the extension) and its absolute path if it's a mustache tempalte only. 76 | const matches = filename.match(/^(.*)\.mustache$/i); 77 | if (matches) { 78 | files.push({ 79 | name: matches[1], 80 | path: path.join(PARTIALS_DIR, filename), 81 | }); 82 | } 83 | return files; 84 | }, 85 | [], 86 | ); 87 | debug('Attempting to load partials: %o', partialFiles); 88 | // Async map each file object into a pair containing the template name and file contents 89 | const partialEntries = await Promise.all( 90 | partialFiles.map(async ({ path, name }) => [ 91 | name, 92 | (await readFileAsync(path)).toString(encoding), 93 | ]), 94 | ); 95 | // Combine the entry pairs into an object and freeze the object. 96 | const finalPartials = Object.freeze(fromPairs(partialEntries)); 97 | debug('Loaded partials: %o', finalPartials); 98 | return finalPartials; 99 | } 100 | 101 | /** 102 | * The actual `loadPartials` function becomes this simple function which will only actually load the partials off the 103 | * disk once ever. 104 | */ 105 | return (...args) => { 106 | if (!partials) { 107 | debug('Partials not cached, loading from disk...'); 108 | // Set our empty cached partials object to be a promise that will resolve to the partials object. This acts as a blocking 109 | // lock so that if this function is called multiple times before partials are finished loading from the first call, it 110 | // won't load the partials multiple times. 111 | partials = reallyLoadPartials(...args); 112 | return partials; 113 | } 114 | 115 | if ( 116 | typeof partials === 'object' && 117 | typeof partials.then === 'function' 118 | ) { 119 | // Partials are currently being loaded so we return the promise that's loading them. 120 | return partials; 121 | } 122 | 123 | // Partials have already been loaded so we return a promise that will resolve to them 124 | return Promise.resolve(partials); 125 | }; 126 | })(); 127 | 128 | /** 129 | * Load a raw mustache template file's contents. 130 | * 131 | * @param {string} name 132 | * @param {string=} encoding 133 | * @return {Promise} 134 | */ 135 | async function loadTemplate(name, { encoding = 'utf8' } = {}) { 136 | const templatePath = path.join(TEMPLATES_DIR, `${name}.mustache`); 137 | debug("Loading mustache template from '%s'...", templatePath); 138 | const buffer = await readFileAsync(templatePath, { encoding }); 139 | return buffer.toString(encoding); 140 | } 141 | 142 | /** 143 | * Render out a mustache template by name. 144 | * 145 | * @param {string} name 146 | * @param {object=} view 147 | * @param {string=} encoding 148 | * @return {Promise} 149 | */ 150 | async function renderTemplate(name, view = {}, { encoding = 'utf8' } = {}) { 151 | debug( 152 | "Attempting to render mustache template '%s' with view data: %o", 153 | name, 154 | view, 155 | ); 156 | const partials = await loadPartials(); 157 | const template = await loadTemplate(name, { encoding }); 158 | return Mustache.render(template, view, partials); 159 | } 160 | 161 | /** 162 | * Given (probably) a JS error object, format it to a message string. Handles MultiErrors (`verror`). 163 | * 164 | * @param {Error|string|*} error The error to format to a string 165 | * @param {boolean=} dev Whether or not to format to a detailed string including the full stack trace 166 | * @param {string[]=} parts The names of the props to include in non dev messages 167 | * @param {string=} sep The separator to use in non dev messages. 168 | * @param {string=} defaultMessage The default message to use when a message couldn't be coerced for some reason (rare). 169 | * @param {(function(Error, string): *)=} report 170 | * @return {string} 171 | */ 172 | function coerceErrorToMessage( 173 | error, 174 | { 175 | dev = process.env.NODE_ENV === 'development', 176 | parts = ['name', 'message'], 177 | sep = ': ', 178 | defaultMessage = 'An unknown error occurred.', 179 | report = () => {}, 180 | } = {}, 181 | ) { 182 | if (error) { 183 | if (typeof error === 'object') { 184 | const fullStackMessage = 185 | error instanceof MultiError 186 | ? error.errors().map(fullStack).join('\n') 187 | : fullStack(error); 188 | 189 | report(error, fullStackMessage); 190 | 191 | // If we're in dev mode, use the whole stack trace which includes the error name and message. 192 | if (dev) { 193 | return `${fullStackMessage}` || defaultMessage; 194 | } 195 | 196 | // If we're not in dev mode it's sufficient to return only the top error if this is a MultiError. 197 | const targetError = 198 | error instanceof MultiError && error.errors().length > 0 199 | ? error.errors()[0] 200 | : error; 201 | 202 | // Otherwise, join together (typically) the error name and message. 203 | const messageParts = parts.reduce((parts, prop) => { 204 | if (targetError && targetError[prop]) { 205 | parts.push(targetError[prop]); 206 | } 207 | return parts; 208 | }, []); 209 | 210 | return messageParts.join(sep).trim() || defaultMessage; 211 | } 212 | 213 | // If it wasn't an object, we'll assume it's coercible to a string and do the thing. 214 | const errorMessage = `An error occurred: ${error}`; 215 | report(error, errorMessage); 216 | return errorMessage; 217 | } 218 | 219 | return defaultMessage; 220 | } 221 | 222 | /** 223 | * Render the error template w/ an error message and sane defaults. 224 | * 225 | * @param {string|null} message 226 | * @param {object=} extraViewData 227 | * @return {Promise} 228 | */ 229 | async function renderErrorTemplate(message, extraViewData = {}) { 230 | return renderTemplate('error.html', { 231 | title: 'An error occurred', 232 | description: 'An error occurred during login.', 233 | message, 234 | ...extraViewData, 235 | }); 236 | } 237 | 238 | /** 239 | * Given an array of origin strings, generate a regex that matches them all, designed to match format of `event.origin` of 240 | * a DOM MessageEvent. 241 | * 242 | * @see https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage#The_dispatched_event 243 | * 244 | * @param {string|string[]} originList 245 | * @return {string} 246 | */ 247 | function generateRegexPatternFromOriginList(originList) { 248 | const debug = createDebug(DEBUG_KEY, 'generateRegexPatternFromOriginList'); 249 | // Coerce the argument into an array 250 | const origins = Array.isArray(originList) ? originList : [originList]; 251 | // Generate all the sub patterns to match 252 | const subPatterns = origins.reduce((patterns, origin) => { 253 | // Extract the protocol, host, and port from the origin 254 | const matches = origin.match(/^(https?:\/\/)?([^:]+)(:[0-9]+)?$/i); 255 | if (matches) { 256 | // Extract out captured groups (we `.slice(1)` to skip the element at index 0 which is the fully matched string) 257 | const [protocol = null, host = '', port = ''] = matches.slice(1); 258 | debug("Parsed '%s' into: %o", origin, { protocol, host, port }); 259 | if (host) { 260 | // If no protocol was specified, allow both. 261 | const protocols = protocol 262 | ? [protocol] 263 | : ['https://', 'http://']; 264 | 265 | debug('Allowing protocols: %o', protocols); 266 | 267 | // Generate multiple patterns for each protoocol + port combo 268 | const newPatterns = protocols.reduce( 269 | (newPatterns, protocol) => { 270 | // If a port was explicitly specified only ever allow that port, otherwise allow the lack of a port or the port 271 | // that matches the protocol 272 | const ports = port 273 | ? [port] 274 | : ['', protocol === 'https://' ? ':443' : ':80']; 275 | 276 | debug( 277 | "For protocol '%s', allowing ports: %o", 278 | protocol, 279 | ports, 280 | ); 281 | 282 | return [ 283 | ...newPatterns, 284 | ...ports.map((port) => `${protocol}${host}${port}`), 285 | ]; 286 | }, 287 | [], 288 | ); 289 | return patterns.concat(newPatterns); 290 | } 291 | } 292 | return patterns; 293 | }, []); 294 | // Escape all the individual patterns such that they can be concatenated together into a valid regexp capture group 295 | const escapedSubPatterns = subPatterns.map((pattern) => { 296 | return escapeStringRegexp(`${pattern}`).replace(/\//g, '\\/'); 297 | }); 298 | const finalPattern = `/^(${escapedSubPatterns.join('|')})$/i`; 299 | debug('Produced final pattern: %s', finalPattern); 300 | return finalPattern; 301 | } 302 | 303 | module.exports = { 304 | receivesUserConfig, 305 | coerceErrorToMessage, 306 | loadPartials, 307 | loadTemplate, 308 | renderTemplate, 309 | renderErrorTemplate, 310 | generateRegexPatternFromOriginList, 311 | }; 312 | -------------------------------------------------------------------------------- /lib/handlers/utils.test.js: -------------------------------------------------------------------------------- 1 | const { loadPartials, generateRegexPatternFromOriginList } = require('./utils'); 2 | 3 | test('loadPartials only actually loads partials once', async () => { 4 | const promise1 = loadPartials(); 5 | const promise2 = loadPartials(); 6 | expect(promise1).toBe(promise2); 7 | const partials1 = await promise1; 8 | const partials2 = await promise2; 9 | expect(partials1).toBe(partials2); 10 | }); 11 | 12 | /** 13 | * Run the `generateRegexPatternFromOriginList` function and eval (!) its result to ensure it compiles into a valid regex. 14 | * 15 | * We use eval (I know! I know!) to simulate it's true usage which is that the result of the function is injected literally into 16 | * a `