Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SLB-187: integration of Auth.js with Gatsby, Storybook and Drupal OAuth #178

Draft
wants to merge 55 commits into
base: release
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
db13e53
chore: fix issue with node-gyp and node-addon-api
colorfield Feb 12, 2024
00e3317
feat(gatsby-auth): add nextauth
colorfield Feb 12, 2024
322e561
feat(gatsby-auth): add authentication ui (wip)
colorfield Feb 13, 2024
103082c
fix(gatsby-auth): missing dependencies
colorfield Feb 14, 2024
29966e0
fix(gatsby-oauth): authenticate with gatsby develop
colorfield Feb 14, 2024
51b8131
feat(gatsby-oauth): set dummy credentials and drupal providers
colorfield Feb 14, 2024
166dbbb
docs(gatsby-oauth): basic setup with drupal
colorfield Feb 14, 2024
a8b9300
chore(gatsby-oauth): give it a 🍌 so next-auth logs are quiet
colorfield Feb 14, 2024
781e3fb
refactor(gatsby-oauth): render login header only if a provider is ava…
colorfield Feb 14, 2024
f7bb017
chore(gatsby-oauth): prevent to export oauth2 tokens as default content
colorfield Feb 14, 2024
c86ba53
chore: copilot ignore
colorfield Mar 28, 2024
c63a0f0
chore(gatsby-oauth): add supabase adapters
colorfield Mar 28, 2024
d4b3acc
feat(gatsby-oauth): add currentUser directive
colorfield Mar 28, 2024
c894e86
chore: add ray
colorfield Mar 28, 2024
8553572
chore(gatsby-oauth): nextauth configuration
colorfield Apr 5, 2024
c01bb69
docs(gatsby-oauth): local development
colorfield Apr 5, 2024
ce22c53
Revert "chore(gatsby-oauth): add supabase adapters"
colorfield Apr 5, 2024
c79a0e9
Revert "chore: add ray"
colorfield Apr 5, 2024
01e6d8b
chore(gatsby-oauth): set user/profile path
colorfield Apr 5, 2024
3e3460e
Merge remote-tracking branch 'origin/release' into gatsby-oauth
colorfield Apr 5, 2024
fea4b3c
chore(gatsby-oauth): manually fix merge conflicts
colorfield Apr 5, 2024
0bab335
chore(gatsby-oauth): storybook integration
colorfield Apr 5, 2024
7eea981
Merge remote-tracking branch 'origin/release' into gatsby-oauth
colorfield Apr 10, 2024
695dd8e
chore: manually fix merge conflicts
colorfield Apr 10, 2024
77d6e18
chore(gastby-oauth): update pnpm lock
colorfield Apr 11, 2024
b11901e
fix(gatsby-oauth): ssr
colorfield Apr 11, 2024
572d7bc
refactor: query (wip)
colorfield Apr 11, 2024
2151682
refactor: query
colorfield Apr 12, 2024
35d5f0a
Merge remote-tracking branch 'origin/release' into gatsby-oauth
colorfield Apr 12, 2024
ef458f8
chore: lint fix
colorfield Apr 12, 2024
8eb7ae8
fix(gatsby-oauth): remove unnecessary accessToken
colorfield Apr 15, 2024
7d2893c
fix(gatsby-oauth): accessToken with executor
colorfield Apr 15, 2024
1f5e97f
fix(gatsby-oauth): executor condition
colorfield Apr 15, 2024
2a12f0b
fix(gatsby-oauth): accessToken with executor
colorfield Apr 16, 2024
c4be894
Merge remote-tracking branch 'origin/release' into gatsby-oauth
colorfield Apr 16, 2024
923d68e
refactor(gatsby-oauth): use directives service
colorfield Apr 16, 2024
4d3e1f0
docs(gatsby-oauth): improve wording
colorfield Apr 16, 2024
1b587b5
refactor(gatsby-oauth): use a more generic client id for the frontend
colorfield Apr 25, 2024
cb62ec0
chore(gatsby-oauth): create website consumer, add to INIT
colorfield Apr 25, 2024
0cad60c
Merge remote-tracking branch 'origin/release' into gatsby-oauth
colorfield Apr 25, 2024
1be1f04
fix: pnpm.lock merge
colorfield Apr 25, 2024
2e87f79
docs(gatsby-oauth): clearer environment variables setup
colorfield Apr 25, 2024
c7c79e1
chore(gatsby-oauth): remove node-gyp, node-addon-api
colorfield Apr 25, 2024
9394661
fix(gatsby-oauth): typo
colorfield Apr 25, 2024
ffbd7ae
Merge remote-tracking branch 'origin/release' into gatsby-oauth
colorfield Apr 25, 2024
a66950f
chore: rollback pnpm-lock and package.json
colorfield Apr 25, 2024
f878d84
chore: re-add next-auth
colorfield Apr 25, 2024
49256ac
chore: re-add faker
colorfield Apr 25, 2024
cb123d7
chore: re-add next-auth dependencies
colorfield Apr 25, 2024
52b8c48
Merge remote-tracking branch 'origin/release' into gatsby-oauth
colorfield May 2, 2024
a2279bd
fix(gatsby-oauth): remove absolute url
colorfield May 2, 2024
12a7258
chore(gatsby-oauth): remove nextauth env vars with publisher
colorfield May 2, 2024
acf8294
docs(gatsby-oauth): fix redirect uri
colorfield May 2, 2024
04f5fa5
chore: use @netlify/plugin-nextjs ^4
colorfield Jun 26, 2024
d52c7d5
chore: add @babel/preset-typescript
colorfield Jun 27, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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',
},
};
10 changes: 9 additions & 1 deletion apps/website/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,15 @@
"@amazeelabs/publisher": "^2.4.17",
"@amazeelabs/strangler-netlify": "^1.1.9",
"@amazeelabs/token-auth-middleware": "^1.1.1",
"@babel/preset-typescript": "^7.24.7",
"@custom/cms": "workspace:*",
"@custom/decap": "workspace:*",
"@custom/schema": "workspace:*",
"@custom/ui": "workspace:*",
"@gatsbyjs/reach-router": "^2.0.1",
"@netlify/plugin-nextjs": "^4.41.3",
"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 +32,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 +61,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
Loading