Skip to content

Commit

Permalink
Merge remote-tracking branch 'origin/SLB-450-preview-auth' into dev
Browse files Browse the repository at this point in the history
  • Loading branch information
colorfield committed Jul 12, 2024
2 parents 4f53060 + 82da3b8 commit 6a22f1d
Show file tree
Hide file tree
Showing 11 changed files with 739 additions and 80 deletions.
25 changes: 21 additions & 4 deletions INIT.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,17 +74,34 @@ replace(
'PROJECT_NAME=example',
'PROJECT_NAME=' + process.env.PROJECT_NAME_MACHINE,
);
const clientSecret = randomString(32);
const publisherClientSecret = randomString(32);
replace(
['apps/cms/.lagoon.env', 'apps/website/.lagoon.env'],
'PUBLISHER_OAUTH2_CLIENT_SECRET=REPLACE_ME',
'PUBLISHER_OAUTH2_CLIENT_SECRET=' + clientSecret,
'PUBLISHER_OAUTH2_CLIENT_SECRET=' + publisherClientSecret,
);
const sessionSecret = randomString(32);
const publisherSessionSecret = randomString(32);
replace(
['apps/website/.lagoon.env'],
'PUBLISHER_OAUTH2_SESSION_SECRET=REPLACE_ME',
'PUBLISHER_OAUTH2_SESSION_SECRET=' + sessionSecret,
'PUBLISHER_OAUTH2_SESSION_SECRET=' + publisherSessionSecret,
);
const previewClientSecret = randomString(32);
replace(
['apps/cms/.lagoon.env'],
'PREVIEW_OAUTH2_CLIENT_SECRET=REPLACE_ME',
'PREVIEW_OAUTH2_CLIENT_SECRET=' + previewClientSecret,
);
replace(
['apps/preview/.lagoon.env'],
'OAUTH2_CLIENT_SECRET=REPLACE_ME',
'OAUTH2_CLIENT_SECRET=' + previewClientSecret,
);
const previewSessionSecret = randomString(32);
replace(
['apps/preview/.lagoon.env'],
'OAUTH2_SESSION_SECRET=REPLACE_ME',
'OAUTH2_SESSION_SECRET=' + previewSessionSecret,
);
// Template's prod domain is special.
replace(
Expand Down
1 change: 1 addition & 0 deletions apps/cms/.lagoon.env
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ PREVIEW_URL="https://preview.${LAGOON_ENVIRONMENT}.${LAGOON_PROJECT}.ch4.amazee.

# Used to set the original client secret.
PUBLISHER_OAUTH2_CLIENT_SECRET=REPLACE_ME
PREVIEW_OAUTH2_CLIENT_SECRET=REPLACE_ME
10 changes: 10 additions & 0 deletions apps/preview/.lagoon.env
Original file line number Diff line number Diff line change
@@ -1,2 +1,12 @@
PROJECT_NAME=example
DRUPAL_URL="https://nginx.${LAGOON_ENVIRONMENT}.${LAGOON_PROJECT}.ch4.amazee.io"

# Authentication with OAuth2
AUTHENTICATION_TYPE=oauth2
OAUTH2_CLIENT_SECRET=REPLACE_ME
OAUTH2_SESSION_SECRET=REPLACE_ME

# Authentication with Basic Auth
#AUTHENTICATION_TYPE=basic
#BASIC_AUTH_USER=preview
#BASIC_AUTH_PASSWORD=preview
1 change: 1 addition & 0 deletions apps/preview/.lagoon.env.prod
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
DRUPAL_URL="https://example.cms.amazeelabs.dev"
OAUTH2_ENVIRONMENT_TYPE=production
11 changes: 10 additions & 1 deletion apps/preview/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,29 @@
"dependencies": {
"@custom/schema": "workspace:*",
"@custom/ui": "workspace:*",
"cookie-parser": "^1.4.6",
"express": "^4.19.2",
"express-basic-auth": "^1.2.1",
"express-session": "^1.18.0",
"express-ws": "^5.0.2",
"memorystore": "^1.6.7",
"node-fetch": "^3.3.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"rxjs": "^7.8.1"
"rxjs": "^7.8.1",
"simple-oauth2": "^5.1.0"
},
"devDependencies": {
"@swc/cli": "^0.1.63",
"@swc/core": "^1.3.102",
"@types/cookie-parser": "^1.4.7",
"@types/express": "^4.17.21",
"@types/express-session": "^1.18.0",
"@types/express-ws": "^3.0.4",
"@types/node": "^20.11.17",
"@types/react": "^18.2.46",
"@types/react-dom": "^18.2.18",
"@types/simple-oauth2": "^5.0.7",
"@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.2.0",
"@vitejs/plugin-react-swc": "^3.5.0",
Expand Down
120 changes: 118 additions & 2 deletions apps/preview/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,36 @@ import express from 'express';
import expressWs from 'express-ws';
import { Subject } from 'rxjs';

import {
getAuthenticationMiddleware,
isSessionRequired,
} from './utils/authentication.js';
import { getConfig } from './utils/config.js';
import {
getOAuth2AuthorizeUrl,
getPersistedAccessToken,
hasPreviewAccess,
initializeSession,
isAuthenticated,
oAuth2AuthorizationCodeClient,
persistAccessToken,
stateMatches,
} from './utils/oAuth2.js';

const expressServer = express();
const expressWsInstance = expressWs(expressServer);
const { app } = expressWsInstance;

const updates$ = new Subject();
app.use(express.json());

// A session is only needed for OAuth2.
if (isSessionRequired()) {
initializeSession(expressServer);
}
// Authentication middleware based on the configuration.
const authMiddleware = getAuthenticationMiddleware();

app.get('/endpoint.js', (_, res) => {
res.send(
`window.GRAPHQL_ENDPOINT = "${
Expand All @@ -17,7 +40,6 @@ app.get('/endpoint.js', (_, res) => {
);
});

// TODO: Protect endpoints and preview with Drupal authentication.
app.post('/__preview', (req, res) => {
updates$.next(req.body || {});
res.json(true);
Expand All @@ -31,11 +53,105 @@ app.ws('/__preview', (ws) => {
ws.on('close', sub.unsubscribe);
});

app.get('/__preview/*', (req, _, next) => {
app.get('/__preview/*', authMiddleware, (req, _, next) => {
req.url = '/';
next();
});

// ---------------------------------------------------------------------------
// OAuth2 routes
// ---------------------------------------------------------------------------

// Fallback route for login. Is used if there is no origin cookie.
app.get('/oauth/login', async (req, res) => {
if (await isAuthenticated(req)) {
const accessPreview = await hasPreviewAccess(req);
if (accessPreview) {
res.send('Preview access is granted.');
} else {
res.send(
'Preview access is not granted. Contact your site administrator. <a href="/oauth/logout">Log out</a>',
);
}
} else {
res.cookie('origin', req.path).send('<a href="/oauth">Log in</a>');
}
});

// Redirects to authentication provider.
app.get('/oauth', (req, res) => {
const client = oAuth2AuthorizationCodeClient();
if (!client) {
throw new Error('Missing OAuth2 client.');
}
const authorizationUri = getOAuth2AuthorizeUrl(client, req);
res.redirect(authorizationUri);
});

// Callback from authentication provider.
app.get('/oauth/callback', async (req, res) => {
const oAuth2Config = getConfig().oAuth2;
if (!oAuth2Config) {
throw new Error('Missing OAuth2 configuration.');
}

const client = oAuth2AuthorizationCodeClient();
if (!client) {
throw new Error('Missing OAuth2 client.');
}

// Check if the state matches.
if (!stateMatches(req)) {
return res.status(500).json('State does not match.');
}

const { code } = req.query;
const options = {
code,
scope: oAuth2Config.scope,
// Do not include redirect_uri, makes Drupal simple_oauth fail.
// Returns 400 Bad Request.
//redirect_uri: 'http://127.0.0.1:7777/callback',
};

try {
// @ts-ignore options due to missing redirect_uri.
const accessToken = await client.getToken(options);
console.log('/oauth/callback accessToken', accessToken);
persistAccessToken(accessToken, req);

if (req.cookies.origin) {
res.redirect(req.cookies.origin);
} else {
res.redirect('/oauth/login');
}
} catch (error) {
console.error(error);
return (
res
.status(500)
// @ts-ignore
.json(`Authentication failed with error: ${error.message}`)
);
}
});

// Removes the session.
app.get('/oauth/logout', async (req, res) => {
const accessToken = getPersistedAccessToken(req);
if (!accessToken) {
return res.status(401).send('No token found.');
}

// Requires this Drupal patch
// https://www.drupal.org/project/simple_oauth/issues/2945273
// await accessToken.revokeAll();
req.session.destroy(function (err) {
console.log('Remove session', err);
});
res.redirect('/oauth/login');
});

app.use(express.static('./dist'));

const isLagoon = !!process.env.LAGOON;
Expand Down
57 changes: 57 additions & 0 deletions apps/preview/server/utils/authentication.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { NextFunction, Request, RequestHandler, Response } from 'express';
import basicAuth from 'express-basic-auth';

import { getConfig } from './config.js';
import { oAuth2AuthCodeMiddleware } from './oAuth2.js';

/**
* Returns the Express authentication middleware based on the configuration.
*
* Favours OAuth2, then Basic Auth, then falling back to no auth
* if not configured (= grant access).
*/
export const getAuthenticationMiddleware = (): RequestHandler =>
((): RequestHandler => {
const config = getConfig();
switch (config.authenticationType) {
case 'oauth2':
if (config.oAuth2) {
return oAuth2AuthCodeMiddleware;
} else {
console.error('Missing OAuth2 configuration.');
}
break;
case 'basic':
if (config.basicAuth) {
return basicAuth({
users: { [config.basicAuth.username]: config.basicAuth.password },
challenge: true,
});
} else {
console.error('Missing basic auth configuration.');
}
break;
case 'noauth':
break;
default:
console.error('Unknown authentication type.');
break;
}

return (req: Request, res: Response, next: NextFunction): void => next();
})();

/**
* Checks if a session is required based on the configuration.
*/
export const isSessionRequired = (): boolean => {
let result = false;
if (getConfig().oAuth2) {
const oAuth2Config = getConfig().oAuth2;
if (!oAuth2Config) {
throw new Error('Missing OAuth2 configuration.');
}
result = true;
}
return result;
};
47 changes: 47 additions & 0 deletions apps/preview/server/utils/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
export type PreviewConfig = {
authenticationType: string; // 'oauth2' | 'basic' | 'noauth';
drupalHost: string;
/**
* Basic auth.
*/
basicAuth?: {
username: string;
password: string;
};
/**
* OAuth2.
*/
oAuth2?: {
clientId: string;
clientSecret: string;
scope: string;
tokenHost: string;
tokenPath: string;
authorizePath: string;
sessionSecret: string;
environmentType?: string; // 'development' | 'production';
};
};

export const getConfig = (): PreviewConfig => {
return {
authenticationType: process.env.AUTHENTICATION_TYPE || 'noauth',
drupalHost: process.env.DRUPAL_URL || 'http://127.0.0.1:8888',
basicAuth: {
username: process.env.BASIC_AUTH_USER || 'test',
password: process.env.BASIC_AUTH_PASSWORD || 'test',
},
oAuth2: {
clientId: process.env.OAUTH2_CLIENT_ID || 'preview',
clientSecret: process.env.OAUTH2_CLIENT_SECRET || 'preview',
scope: process.env.OAUTH2_SCOPE || 'preview',
tokenHost: process.env.DRUPAL_URL || 'http://127.0.0.1:8888',
tokenPath: process.env.OAUTH2_TOKEN_PATH || '/oauth/token',
authorizePath:
process.env.OAUTH2_AUTHORIZE_PATH ||
'/oauth/authorize?response_type=code',
sessionSecret: process.env.OAUTH2_SESSION_SECRET || 'banana',
environmentType: process.env.OAUTH2_ENVIRONMENT_TYPE || 'development',
},
};
};
Loading

0 comments on commit 6a22f1d

Please sign in to comment.