Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
31 changes: 31 additions & 0 deletions docs/how-to/authentication/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,37 @@ The main `Page` component uses `isReady` and `isAuthenticated` to manage the use

You are now ready to access the auth page you created. In your terminal start up the dev server by running `npm run dev` and visit the [`http://localhost:3000/gated`](http://localhost:3000/gated) route. If the authentication was set up properly, you should be redirected to the WP login screen. Once you login, you should be sent back to the gated page and see the gated content.

## Connect with an Application Password instead of the secret key

Your Next.js server exchanges authorization codes for tokens by calling the FaustWP plugin. It can authenticate those calls with a WordPress [Application Password](https://make.wordpress.org/core/2020/11/05/application-passwords-integration-guide/) instead of `FAUST_SECRET_KEY`. Each Application Password belongs to one user, can be revoked on its own, and only allows what that user's role allows.

1. Create a WordPress user for your frontend and give it a role with the `faustwp_exchange_tokens` capability. Administrators have it by default, but a dedicated low-privilege user is safer:

```php title="my-plugin.php"
// Roles are saved in the database, so add the role once, on activation.
register_activation_hook( __FILE__, function () {
add_role( 'faust_frontend', 'Faust frontend', array(
'read' => true,
'faustwp_exchange_tokens' => true,
) );
} );
```

2. Generate an Application Password for that user under **Users → Profile → Application Passwords**.
3. Add the credentials to your Next.js app's server environment:

```bash title=".env.local"
FAUST_APPLICATION_USER=faust-frontend
FAUST_APPLICATION_PASSWORD="abcd efgh ijkl mnop qrst uvwx"
```

4. Restart your app and log in again to check that previews and authentication still work. When both are set, Faust.js uses the Application Password and no longer sends `FAUST_SECRET_KEY`, so you can then remove `FAUST_SECRET_KEY` from your frontend's environment.

The `faust blockset` command reads the same two variables, but uploading blocks needs an administrator's Application Password (a super admin's on Multisite). Set an administrator's credentials only in the environment where you run `faust blockset`, such as a CI job, and keep the frontend's low-privilege credentials everywhere else.

> [!NOTE]
> WordPress only accepts Application Passwords over HTTPS, except on local development sites. Some servers remove the `Authorization` header before it reaches WordPress; if `faust dev` reports that the credentials were rejected even though they're correct, check your server's configuration. Sites protected with HTTP Basic auth can't use Application Passwords, since both use the same header.

## Next steps

Here are some things you can do to continue to test and add to your auth strategy with the toolkit:
Expand Down
4 changes: 2 additions & 2 deletions packages/faustwp-cli/src/blockset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import FormData from 'form-data';
import { ZipArchive } from 'archiver';
import { spawnSync } from 'child_process';

import { getWpUrl, getWpSecret, hasYarn } from './utils/index.js';
import { getWpUrl, getWpAuthHeaders, hasYarn } from './utils/index.js';
import { debugLog } from './stdout/index.js';

// File paths used throughout the blockset process
Expand Down Expand Up @@ -178,7 +178,7 @@ export async function uploadToWordPress(zipPath: string): Promise<void> {
const apiUrl = `${getWpUrl()}/?rest_route=/faustwp/v1/blockset`;
const headers = {
...form.getHeaders(),
'x-faustwp-secret': getWpSecret() || '',
...getWpAuthHeaders(),
};

try {
Expand Down
8 changes: 6 additions & 2 deletions packages/faustwp-cli/src/generatePossibleTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import 'isomorphic-fetch';
import fs from 'fs';

import { infoLog, errorLog, debugLog } from './stdout/index.js';
import { getGraphqlEndpoint, getWpSecret, getWpUrl } from './utils/index.js';
import {
getGraphqlEndpoint,
getWpAuthHeaders,
getWpUrl,
} from './utils/index.js';

type PossibleTypes = {
[key: string]: any;
Expand All @@ -25,7 +29,7 @@ export async function generatePossibleTypes(): Promise<void> {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-faust-secret': getWpSecret() || '',
...getWpAuthHeaders('x-faust-secret'),
},
body: JSON.stringify({
variables: {},
Expand Down
6 changes: 4 additions & 2 deletions packages/faustwp-cli/src/healthCheck/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ import { verifyGraphQLEndpoint } from './verifyGraphQLEndpoint.js';

/**
* Ensure that everything Faust requires to run is available.
*
* @param command The CLI command being run, if any.
*/
export async function healthCheck(): Promise<void> {
export async function healthCheck(command?: string): Promise<void> {
// Check Faust Env variables before continuing.
await validateFaustEnvVars();
await validateFaustEnvVars(command);

// Perform our health checks.
await verifyGraphQLEndpoint();
Expand Down
82 changes: 69 additions & 13 deletions packages/faustwp-cli/src/healthCheck/validateFaustEnvVars.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { getWpSecret, getWpUrl } from '../utils/index.js';
import {
getWpApplicationPassword,
getWpAuthHeaders,
getWpUrl,
hasWpCredentials,
} from '../utils/index.js';
import { errorLog, infoLog, warnLog } from '../stdout/index.js';
import { validateNextWordPressUrl } from './validateNextWordPressUrl.js';

Expand All @@ -8,11 +13,25 @@ export function isWPEngineComSubdomain(url: string) {
return regex.test(url);
}

/**
* Commands that call WordPress with their own permissions instead of
* exchanging Faust tokens, so they work with an Application Password whose
* user can't exchange tokens.
*/
const COMMANDS_WITHOUT_TOKEN_EXCHANGE = [
'generatePossibleTypes',
'generateGlobalStylesheet',
'blockset',
];

/**
* Validates that the appropriate Faust related environment variables are set.
*
* @param command The CLI command being run, if any.
*/
export const validateFaustEnvVars = async () => {
const secretWp = getWpSecret();
export const validateFaustEnvVars = async (command?: string) => {
const hasCredentials = hasWpCredentials();
const applicationPassword = getWpApplicationPassword();

if (!process.env.NEXT_PUBLIC_WORDPRESS_URL) {
errorLog('Could not find NEXT_PUBLIC_WORDPRESS_URL environment variable.');
Expand All @@ -31,33 +50,59 @@ export const validateFaustEnvVars = async () => {
);
}

if (!secretWp) {
warnLog('Could not find FAUST_SECRET_KEY environment variable.');
if (
!applicationPassword &&
(process.env.FAUST_APPLICATION_USER ||
process.env.FAUST_APPLICATION_PASSWORD)
) {
warnLog(
'Set both FAUST_APPLICATION_USER and FAUST_APPLICATION_PASSWORD to use an Application Password.',
);
}

if (!hasCredentials) {
warnLog(
'Could not find FAUST_SECRET_KEY, or FAUST_APPLICATION_USER and FAUST_APPLICATION_PASSWORD, environment variables.',
);
warnLog('Some functionality may be limited.');
}

if (process.env.NEXT_PUBLIC_WORDPRESS_URL.startsWith('http://') && secretWp) {
if (
process.env.NEXT_PUBLIC_WORDPRESS_URL.startsWith('http://') &&
hasCredentials
) {
warnLog('Your WordPress site is not running on https!');
warnLog(
'This is a security concern as all traffic with your secret key is in plain text.',
'This is a security concern as all traffic with your credentials is in plain text.',
);
warnLog(
'Please make sure your production Faust app runs with a WordPress instance on https!',
);
}

if (secretWp) {
// send secret key
if (hasCredentials) {
const apiUrl = `${getWpUrl()}/?rest_route=/faustwp/v1/validate_secret_key`;
const headers = {
'x-faustwp-secret': secretWp,
};
const headers = getWpAuthHeaders();
Comment thread
josephfusco marked this conversation as resolved.
try {
const response = await fetch(apiUrl, {
headers,
method: 'POST',
});
if (response.status === 401) {
// WordPress answers 401 when the credentials aren't accepted, and 403
// when an Application Password's user lacks the capability.
if (
applicationPassword &&
response.status === 403 &&
command &&
COMMANDS_WITHOUT_TOKEN_EXCHANGE.includes(command)
) {
warnLog(
"The FAUST_APPLICATION_USER user can't exchange Faust tokens, so logins and previews won't work. Give it a role with the faustwp_exchange_tokens capability if you need them.",
);
return;
}

if (response.status === 401 || response.status === 403) {
const wwwAuth = response.headers.get('www-authenticate') || '';
if (wwwAuth.toLowerCase().includes('basic')) {
errorLog(
Expand All @@ -66,6 +111,17 @@ export const validateFaustEnvVars = async () => {
errorLog(
'Faust cannot validate the secret key until Basic Auth credentials are provided or the protection is removed.',
);
} else if (applicationPassword && response.status === 403) {
errorLog(
"The FAUST_APPLICATION_USER user can't exchange Faust tokens. Give it a role with the faustwp_exchange_tokens capability.",
);
} else if (applicationPassword) {
errorLog(
'Ensure FAUST_APPLICATION_USER and FAUST_APPLICATION_PASSWORD are a valid WordPress Application Password.',
Comment thread
josephfusco marked this conversation as resolved.
);
errorLog(
'If they are, your server may be removing the Authorization header before it reaches WordPress.',
);
} else {
errorLog(
'Ensure your FAUST_SECRET_KEY environment variable matches your Secret Key in the Faust WordPress plugin settings',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getWpSecret, getWpUrl } from '../utils/index.js';
import { getWpAuthHeaders, getWpUrl } from '../utils/index.js';
import { errorLog, warnLog } from '../stdout/index.js';

/**
Expand All @@ -12,7 +12,7 @@ export async function validateNextWordPressUrl(): Promise<void> {
const apiUrl = `${getWpUrl()}/?rest_route=/faustwp/v1/validate_public_wordpress_url`;
const headers = {
'Content-Type': 'application/json',
'x-faustwp-secret': getWpSecret() || '',
...getWpAuthHeaders(),
};

const postData = {
Expand Down
6 changes: 3 additions & 3 deletions packages/faustwp-cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { blockset } from './blockset.js';
import {
getCliArgs,
getNextCliArgs,
getWpSecret,
hasWpCredentials,
isDebug,
printFaustVersion,
} from './utils/index.js';
Expand Down Expand Up @@ -48,7 +48,7 @@ import { marshallTelemetryData, sendTelemetryData } from './telemetry/index.js';
* is available before continuing.
*/
if (!otherArgs.includes('--skip-health-checks')) {
await healthCheck();
await healthCheck(arg1);
}

// eslint-disable-next-line default-case
Expand All @@ -73,7 +73,7 @@ import { marshallTelemetryData, sendTelemetryData } from './telemetry/index.js';
}
}

if (getWpSecret()) {
if (hasWpCredentials()) {
try {
const telemetryData = marshallTelemetryData(arg1);
await sendTelemetryData(telemetryData);
Expand Down
13 changes: 7 additions & 6 deletions packages/faustwp-cli/src/telemetry/sendTelemetryData.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import 'isomorphic-fetch';

import { getWpUrl } from '../utils/getWpUrl.js';
import { getWpSecret } from '../utils/getWpSecret.js';
import {
getWpAuthHeaders,
hasWpCredentials,
} from '../utils/getWpAuthHeaders.js';
import { TelemetryData } from './marshallTelemetryData.js';

/**
Expand All @@ -10,18 +13,16 @@ import { TelemetryData } from './marshallTelemetryData.js';
* @param payload The data being sent to telemetry API
*/
export const sendTelemetryData = (payload: TelemetryData) => {
const secret = getWpSecret();

if (!secret) {
throw new Error('Faust secret key is required');
if (!hasWpCredentials()) {
throw new Error('Faust secret key or Application Password is required');
}

// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const WP_TELEMETRY_ENDPOINT = `${getWpUrl()!}/?rest_route=/faustwp/v1/process_telemetry`;

return fetch(WP_TELEMETRY_ENDPOINT, {
headers: {
'x-faustwp-secret': secret,
...getWpAuthHeaders(),
'Content-Type': 'application/json',
},
method: 'POST',
Expand Down
47 changes: 47 additions & 0 deletions packages/faustwp-cli/src/utils/getWpAuthHeaders.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { getWpSecret } from './getWpSecret.js';

/**
* Returns the WordPress Application Password credentials from the project's env,
* or undefined unless both are set.
*/
export function getWpApplicationPassword() {
const user = process.env.FAUST_APPLICATION_USER;
const password = process.env.FAUST_APPLICATION_PASSWORD;

if (!user || !password) {
return undefined;
}

return { user, password };
}

/**
* Whether the project's env has credentials the CLI can send to WordPress.
*/
export function hasWpCredentials() {
return Boolean(getWpApplicationPassword() || getWpSecret());
}

/**
* Returns the headers that authenticate the CLI with WordPress. An Application
* Password is used when configured, otherwise the Faust secret key.
*
* @param secretHeader The header that carries the secret key.
*/
export function getWpAuthHeaders(
secretHeader = 'x-faustwp-secret',
): Record<string, string> {
const applicationPassword = getWpApplicationPassword();

if (applicationPassword) {
const credentials = Buffer.from(
`${applicationPassword.user}:${applicationPassword.password}`,
).toString('base64');

return { Authorization: `Basic ${credentials}` };
}

const secret = getWpSecret();

return secret ? { [secretHeader]: secret } : {};
}
5 changes: 5 additions & 0 deletions packages/faustwp-cli/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ export { getCliArgs } from './getCliArgs.js';
export { getNextCliArgs } from './getNextCliArgs.js';
export { isDebug } from './isDebug.js';
export { getWpSecret } from './getWpSecret.js';
export {
getWpApplicationPassword,
getWpAuthHeaders,
hasWpCredentials,
} from './getWpAuthHeaders.js';
export { getWpUrl } from './getWpUrl.js';
export { getGraphqlEndpoint } from './getGraphqlEndpoint.js';
export { hasYarn } from './hasYarn.js';
Expand Down
Loading
Loading