Skip to content

Commit

Permalink
Merge branch 'gatsby-oauth' into dev
Browse files Browse the repository at this point in the history
  • Loading branch information
colorfield committed May 2, 2024
2 parents 9e49fb7 + acf8294 commit 29addae
Show file tree
Hide file tree
Showing 31 changed files with 1,143 additions and 80 deletions.
19 changes: 15 additions & 4 deletions INIT.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,17 +70,28 @@ 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 websiteClientSecret = randomString(32);
replace(
['apps/cms/.lagoon.env'],
'WEBSITE_OAUTH2_CLIENT_SECRET=REPLACE_ME',
`WEBSITE_OAUTH2_CLIENT_SECRET=${websiteClientSecret}`,
);
console.log(
'Website OAuth2 environment variables to be set in Netlify',
`AUTH_DRUPAL_ID: website`,
`AUTH_DRUPAL_SECRET: ${websiteClientSecret}`,
);
// Template's prod domain is special.
replace(
Expand Down
59 changes: 59 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ Other steps

- [Create a new Lagoon project](https://amazeelabs.atlassian.net/wiki/spaces/ALU/pages/368115717/Create+a+new+Lagoon+project)
- [Create a new Netlify project](https://amazeelabs.atlassian.net/wiki/spaces/ALU/pages/368017428/Create+a+new+Netlify+project)
- Set `AUTH_DRUPAL_ID` and `AUTH_DRUPAL_SECRET` in
[Netlify environment variables](#gatsby-authentication--sso)
- Check the [Environment overrides](#environment-overrides) section below
- Check the [Choose a CMS](#choose-a-cms) section below
- Create `dev` and `prod` branches (and optionally `stage`) from `release`
Expand Down Expand Up @@ -217,6 +219,63 @@ lagoon runtime configuration.
lagoon add variable -p [project name] -e dev -N NETLIFY_SITE_ID -V [netlify site id]
```

### Gatsby authentication / SSO

Authentication providers are relying on Auth.js (formerly Next-Auth) and can be
configured in `/apps/website/nextauth.config.js`

An example provider is available for Drupal.

On Netlify, several environment variables are required to be set:

#### For all providers

- `NEXTAUTH_URL` The URL of the frontend. This is used for the callback.
- `NEXTAUTH_SECRET` A random string used for encryption.

Generate the secret with e.g. `openssl rand -base64 32`

#### For Drupal

- `AUTH_DRUPAL_ID` The client ID of the Drupal Consumer
- `AUTH_DRUPAL_SECRET` The client secret of the Drupal Consumer

Drupal environment variables are displayed in the console when running
`pnpx @amazeelabs/mzx run INIT.md`.

<details>
<summary>How it works</summary>
A `Website` consumer is created in Drupal `/admin/config/services/consumer` with

- Label: `Website`
- Client ID: `website`
- Secret: a random string matching `AUTH_DRUPAL_SECRET`
- Redirect URI: `[netlify-gatsby-site-url]/api/auth/callback/drupal`

#### Other providers

Refer to [Auth.js documentation](https://next-auth.js.org/providers/).

</details>

<details>
<summary>Local development</summary>

#### Start Drupal and Gatsby

- Drupal: in `/apps/cms` - `pnpm start` use http://127.0.0.1:8888
- Gatsby: in `/apps/website` - `pnpm gatsby:develop` use
http://localhost:8000/en

#### Basic troubleshooting

- Make sure to have keys generated
http://127.0.0.1:8888/en/admin/config/people/simple_oauth
- Make sure to have the correct client id and secret set
http://127.0.0.1:8888/en/admin/config/services/consumer/2/edit

</details>

### Publisher authentication with Drupal

Publisher can require to authenticate with Drupal based on OAuth2. It is only
Expand Down
5 changes: 4 additions & 1 deletion apps/cms/.lagoon.env
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,8 @@ PUBLISHER_URL="https://build.${LAGOON_ENVIRONMENT}.${LAGOON_PROJECT}.ch4.amazee.
NETLIFY_URL="https://build.${LAGOON_ENVIRONMENT}.${LAGOON_PROJECT}.ch4.amazee.io"
PREVIEW_URL="https://preview.${LAGOON_ENVIRONMENT}.${LAGOON_PROJECT}.ch4.amazee.io"

# Used to set the original client secret.
# Used to set the original client secret for Publisher.
PUBLISHER_OAUTH2_CLIENT_SECRET=REPLACE_ME

# Used to set the original client secret for the Website.
WEBSITE_OAUTH2_CLIENT_SECRET=REPLACE_ME
4 changes: 4 additions & 0 deletions apps/website/gatsby-browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import '@custom/ui/styles.css';

import { GatsbyBrowser } from 'gatsby';

import { WrapRootElement } from './src/utils/wrapRootElement';

export const wrapRootElement = WrapRootElement;

export const shouldUpdateScroll: GatsbyBrowser['shouldUpdateScroll'] = (
args,
) => {
Expand Down
2 changes: 0 additions & 2 deletions apps/website/gatsby-config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@
// TS file name should be different from gastby-config.ts, otherwise Gatsby will
// pick it up instead of the JS file.

import { existsSync } from 'fs';

process.env.NETLIFY_URL = process.env.NETLIFY_URL || 'http://127.0.0.1:8000';

process.env.CLOUDINARY_API_KEY = process.env.CLOUDINARY_API_KEY || 'test';
Expand Down
8 changes: 8 additions & 0 deletions apps/website/gatsby-node.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,14 @@ export const createPages = async ({ actions }) => {
});
});

// Create a profile page in each language.
Object.values(Locale).forEach((locale) => {
actions.createPage({
path: `/${locale}/profile`,
component: resolve(`./src/templates/profile.tsx`),
});
});

// Broken Gatsby links will attempt to load page-data.json files, which don't exist
// and also should not be piped into the strangler function. Thats why they
// are caught right here.
Expand Down
57 changes: 57 additions & 0 deletions apps/website/nextauth.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
const AUTH_DRUPAL_URL = process.env.AUTH_DRUPAL_URL || 'http://127.0.0.1:8888';

/** @type {import("next-auth").NextAuthOptions} */
export const authConfig = {
providers: [
// Drupal provider.
// Other providers can be added here (e.g. Google, Keycloak).
{
// Client ID and secret are set in the Drupal Consumer.
clientId: process.env.AUTH_DRUPAL_ID || 'website',
clientSecret: process.env.AUTH_DRUPAL_SECRET || 'banana',
id: 'drupal',
name: 'Drupal',
type: 'oauth',
// Language prefix is added to prevent 301
// that will not be handled by NextAuth.
authorization: {
url: `${AUTH_DRUPAL_URL}/en/oauth/authorize`,
params: {
scope: 'authenticated',
},
},
token: `${AUTH_DRUPAL_URL}/en/oauth/token`,
userinfo: {
// Additional userinfo can be fetched with an extra request
// using the access token.
url: `${AUTH_DRUPAL_URL}/en/oauth/userinfo`,
},
profile(profile, tokens) {
return {
id: profile.sub,
name: profile.name,
email: profile.email,
tokens: tokens,
};
},
},
],
// Persist the user object in the session.
// @todo use the refresh token
// https://github.com/nextauthjs/next-auth-refresh-token-example/blob/main/pages/api/auth/%5B...nextauth%5D.js
callbacks: {
async jwt({ token, user }) {
return { ...token, ...user };
},
async session({ session, token }) {
session.user = token;
return session;
},
},
secret: process.env.NEXTAUTH_SECRET,
theme: {
logo: 'https://www.amazeelabs.com/images/icon.png',
colorScheme: 'light',
brandColor: '#951B81',
},
};
9 changes: 8 additions & 1 deletion apps/website/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
"@custom/decap": "workspace:*",
"@custom/schema": "workspace:*",
"@custom/ui": "workspace:*",
"@gatsbyjs/reach-router": "^2.0.1",
"@netlify/plugin-nextjs": "^5.1.2",
"babel-loader": "^9.1.3",
"body-parser": "^1.20.2",
"gatsby": "^5.13.1",
"gatsby-plugin-layout": "^4.13.0",
"gatsby-plugin-manifest": "^5.13.0",
Expand All @@ -27,7 +31,10 @@
"gatsby-source-filesystem": "^5.13.0",
"image-size": "^1.1.1",
"mime-types": "^2.1.35",
"multer": "1.4.5-lts.1",
"netlify-cli": "^17.21.1",
"next": "^14.2.3",
"next-auth": "^4.24.7",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
Expand All @@ -53,7 +60,7 @@
"serve": "netlify dev --cwd=. --dir=public --port=8000",
"dev": "pnpm clean && publisher",
"open": "open http://127.0.0.1:8000/___status/",
"gatsby:develop": "ENABLE_GATSBY_REFRESH_ENDPOINT=true pnpm gatsby develop",
"gatsby:develop": "NEXTAUTH_URL=http://localhost:8000 NEXTAUTH_SECRET=banana ENABLE_GATSBY_REFRESH_ENDPOINT=true pnpm gatsby develop",
"gatsby:refresh": "curl -X POST http://localhost:8000/__refresh",
"clean": "gatsby clean"
}
Expand Down
10 changes: 10 additions & 0 deletions apps/website/src/api/auth/[...nextauth].js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// If your deployment environment supports Gatsby Functions, you won't need the root `api` folder, only this.
import NextAuth from 'next-auth';

import { authConfig } from '../../../nextauth.config';

// @ts-ignore
export default async function handler(req, res) {
req.query.nextauth = req.params.nextauth.split('/');
return await NextAuth(req, res, authConfig);
}
2 changes: 1 addition & 1 deletion apps/website/src/templates/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export function Head({ data }: HeadProps<typeof query>) {
export default function PageTemplate({ data }: PageProps<typeof query>) {
// Retrieve the current location and prefill the
// "ViewPageQuery" with these arguments.
// That makes shure the `useOperation(ViewPageQuery, ...)` with this
// That makes sure the `useOperation(ViewPageQuery, ...)` with this
// path immediately returns this data.
const [location] = useLocation();
return (
Expand Down
38 changes: 38 additions & 0 deletions apps/website/src/templates/profile.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { CurrentUserQuery, OperationExecutor } from '@custom/schema';
import { UserProfile } from '@custom/ui/routes/UserProfile';
import { useSession } from 'next-auth/react';
import React from 'react';

import { drupalExecutor } from '../utils/drupal-executor';

export default function ProfilePage() {
const session = useSession();
let accessToken: string | undefined = undefined;
if (session && session.status === 'authenticated') {
// @ts-ignore
accessToken = session.data.user.tokens.access_token;
const authenticatedExecutor = drupalExecutor(
`${process.env.GATSBY_DRUPAL_URL}/graphql`,
false,
);
return (
<OperationExecutor
executor={async () => {
const data = await authenticatedExecutor(
CurrentUserQuery,
{},
accessToken,
);
return {
currentUser: data.currentUser,
};
}}
id={CurrentUserQuery}
>
<UserProfile />
</OperationExecutor>
);
} else {
return <UserProfile />;
}
}
31 changes: 30 additions & 1 deletion apps/website/src/utils/drupal-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,39 @@ export function drupalExecutor(endpoint: string, forward: boolean = true) {
return async function <OperationId extends AnyOperationId>(
id: OperationId,
variables?: OperationVariables<OperationId>,
accessToken?: string,
) {
const url = new URL(endpoint, window.location.origin);
const isMutation = id.includes('Mutation:');
if (isMutation) {
const isAuthenticated = accessToken !== undefined;

if (isAuthenticated) {
const { data, errors } = await (
await fetch(url, {
method: 'POST',
body: JSON.stringify({
queryId: id,
variables: variables,
}),
headers: forward
? {
'SLB-Forwarded-Proto': window.location.protocol.slice(0, -1),
'SLB-Forwarded-Host': window.location.hostname,
'SLB-Forwarded-Port': window.location.port,
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
}
: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
).json();
if (errors) {
throw errors;
}
return data;
} else if (isMutation) {
const { data, errors } = await (
await fetch(url, {
method: 'POST',
Expand Down
7 changes: 7 additions & 0 deletions apps/website/src/utils/wrapRootElement.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { GatsbyBrowser, WrapRootElementBrowserArgs } from 'gatsby';
import { SessionProvider } from 'next-auth/react';
import React from 'react';

export const WrapRootElement: GatsbyBrowser['wrapRootElement'] = ({
element,
}: WrapRootElementBrowserArgs) => <SessionProvider>{element}</SessionProvider>;
Loading

0 comments on commit 29addae

Please sign in to comment.