diff --git a/docs/how-to/authentication/index.md b/docs/how-to/authentication/index.md index 84cc04507..df9571c3f 100644 --- a/docs/how-to/authentication/index.md +++ b/docs/how-to/authentication/index.md @@ -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: diff --git a/packages/faustwp-cli/src/blockset.ts b/packages/faustwp-cli/src/blockset.ts index 0818411cd..9f14b08e7 100644 --- a/packages/faustwp-cli/src/blockset.ts +++ b/packages/faustwp-cli/src/blockset.ts @@ -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 @@ -178,7 +178,7 @@ export async function uploadToWordPress(zipPath: string): Promise { const apiUrl = `${getWpUrl()}/?rest_route=/faustwp/v1/blockset`; const headers = { ...form.getHeaders(), - 'x-faustwp-secret': getWpSecret() || '', + ...getWpAuthHeaders(), }; try { diff --git a/packages/faustwp-cli/src/generatePossibleTypes.ts b/packages/faustwp-cli/src/generatePossibleTypes.ts index 30fbe79d0..fbe4819bd 100644 --- a/packages/faustwp-cli/src/generatePossibleTypes.ts +++ b/packages/faustwp-cli/src/generatePossibleTypes.ts @@ -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; @@ -25,7 +29,7 @@ export async function generatePossibleTypes(): Promise { method: 'POST', headers: { 'Content-Type': 'application/json', - 'x-faust-secret': getWpSecret() || '', + ...getWpAuthHeaders('x-faust-secret'), }, body: JSON.stringify({ variables: {}, diff --git a/packages/faustwp-cli/src/healthCheck/index.ts b/packages/faustwp-cli/src/healthCheck/index.ts index f70c36d5f..c646c7b75 100644 --- a/packages/faustwp-cli/src/healthCheck/index.ts +++ b/packages/faustwp-cli/src/healthCheck/index.ts @@ -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 { +export async function healthCheck(command?: string): Promise { // Check Faust Env variables before continuing. - await validateFaustEnvVars(); + await validateFaustEnvVars(command); // Perform our health checks. await verifyGraphQLEndpoint(); diff --git a/packages/faustwp-cli/src/healthCheck/validateFaustEnvVars.ts b/packages/faustwp-cli/src/healthCheck/validateFaustEnvVars.ts index 006fb40e3..8206d481b 100644 --- a/packages/faustwp-cli/src/healthCheck/validateFaustEnvVars.ts +++ b/packages/faustwp-cli/src/healthCheck/validateFaustEnvVars.ts @@ -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'; @@ -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.'); @@ -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(); 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( @@ -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.', + ); + 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', diff --git a/packages/faustwp-cli/src/healthCheck/validateNextWordPressUrl.ts b/packages/faustwp-cli/src/healthCheck/validateNextWordPressUrl.ts index 46db8350d..e0c702001 100644 --- a/packages/faustwp-cli/src/healthCheck/validateNextWordPressUrl.ts +++ b/packages/faustwp-cli/src/healthCheck/validateNextWordPressUrl.ts @@ -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'; /** @@ -12,7 +12,7 @@ export async function validateNextWordPressUrl(): Promise { const apiUrl = `${getWpUrl()}/?rest_route=/faustwp/v1/validate_public_wordpress_url`; const headers = { 'Content-Type': 'application/json', - 'x-faustwp-secret': getWpSecret() || '', + ...getWpAuthHeaders(), }; const postData = { diff --git a/packages/faustwp-cli/src/index.ts b/packages/faustwp-cli/src/index.ts index d759f6e6a..99a8e2d73 100644 --- a/packages/faustwp-cli/src/index.ts +++ b/packages/faustwp-cli/src/index.ts @@ -9,7 +9,7 @@ import { blockset } from './blockset.js'; import { getCliArgs, getNextCliArgs, - getWpSecret, + hasWpCredentials, isDebug, printFaustVersion, } from './utils/index.js'; @@ -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 @@ -73,7 +73,7 @@ import { marshallTelemetryData, sendTelemetryData } from './telemetry/index.js'; } } - if (getWpSecret()) { + if (hasWpCredentials()) { try { const telemetryData = marshallTelemetryData(arg1); await sendTelemetryData(telemetryData); diff --git a/packages/faustwp-cli/src/telemetry/sendTelemetryData.ts b/packages/faustwp-cli/src/telemetry/sendTelemetryData.ts index 876447b6b..165f402be 100644 --- a/packages/faustwp-cli/src/telemetry/sendTelemetryData.ts +++ b/packages/faustwp-cli/src/telemetry/sendTelemetryData.ts @@ -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'; /** @@ -10,10 +13,8 @@ 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 @@ -21,7 +22,7 @@ export const sendTelemetryData = (payload: TelemetryData) => { return fetch(WP_TELEMETRY_ENDPOINT, { headers: { - 'x-faustwp-secret': secret, + ...getWpAuthHeaders(), 'Content-Type': 'application/json', }, method: 'POST', diff --git a/packages/faustwp-cli/src/utils/getWpAuthHeaders.ts b/packages/faustwp-cli/src/utils/getWpAuthHeaders.ts new file mode 100644 index 000000000..b1736f1f7 --- /dev/null +++ b/packages/faustwp-cli/src/utils/getWpAuthHeaders.ts @@ -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 { + 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 } : {}; +} diff --git a/packages/faustwp-cli/src/utils/index.ts b/packages/faustwp-cli/src/utils/index.ts index 8398cffbe..f62a0fe98 100644 --- a/packages/faustwp-cli/src/utils/index.ts +++ b/packages/faustwp-cli/src/utils/index.ts @@ -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'; diff --git a/packages/faustwp-cli/tests/healthCheck/validateFaustEnvVars.test.ts b/packages/faustwp-cli/tests/healthCheck/validateFaustEnvVars.test.ts index d7d7dcc27..530dab4e8 100644 --- a/packages/faustwp-cli/tests/healthCheck/validateFaustEnvVars.test.ts +++ b/packages/faustwp-cli/tests/healthCheck/validateFaustEnvVars.test.ts @@ -105,6 +105,131 @@ describe('healthCheck/validateFaustEnvVars', () => { mockLog.mockRestore(); }); + + it('validates an Application Password instead of the secret key', async () => { + // @ts-ignore + const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {}); + + process.env.NEXT_PUBLIC_WORDPRESS_URL = 'https://apppassword.local'; + process.env.FAUST_APPLICATION_USER = 'frontend'; + process.env.FAUST_APPLICATION_PASSWORD = 'abcd efgh ijkl mnop'; + + fetchMock.post( + 'https://apppassword.local/?rest_route=/faustwp/v1/validate_secret_key', + { status: 200 }, + ); + fetchMock.post( + 'https://apppassword.local/?rest_route=/faustwp/v1/validate_public_wordpress_url', + { status: 200, body: {} }, + ); + + await validateFaustEnvVars(); + + expect(mockExit).not.toHaveBeenCalled(); + expect(fetchMock).toHaveFetched( + 'https://apppassword.local/?rest_route=/faustwp/v1/validate_secret_key', + { + headers: { + Authorization: `Basic ${Buffer.from( + 'frontend:abcd efgh ijkl mnop', + ).toString('base64')}`, + }, + }, + ); + }); + + it("explains a 403 when the Application Password's user lacks the capability", async () => { + // @ts-ignore + const mockExit = jest.spyOn(process, 'exit').mockImplementation((code) => { + if (code && code !== 0) { + throw new Error(`Exit code: ${code}`); + } + }); + const mockLog = jest.spyOn(console, 'log').mockImplementation(() => {}); + + process.env.NEXT_PUBLIC_WORDPRESS_URL = 'https://nocap.local'; + process.env.FAUST_APPLICATION_USER = 'subscriber'; + process.env.FAUST_APPLICATION_PASSWORD = 'abcd efgh ijkl mnop'; + + fetchMock.post( + 'https://nocap.local/?rest_route=/faustwp/v1/validate_secret_key', + { status: 403 }, + ); + + try { + await validateFaustEnvVars(); + } catch (err) { + // Expected exit + } + + expect(mockExit).toHaveBeenCalledWith(1); + expect(mockLog).toHaveBeenCalledWith( + expect.stringContaining('faustwp_exchange_tokens'), + ); + + mockLog.mockRestore(); + }); + + it.each(['generatePossibleTypes', 'generateGlobalStylesheet', 'blockset'])( + "only warns about a user that can't exchange tokens when running %s", + async (command) => { + // @ts-ignore + const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {}); + const mockLog = jest.spyOn(console, 'log').mockImplementation(() => {}); + + process.env.NEXT_PUBLIC_WORDPRESS_URL = 'https://schemaonly.local'; + process.env.FAUST_APPLICATION_USER = 'subscriber'; + process.env.FAUST_APPLICATION_PASSWORD = 'abcd efgh ijkl mnop'; + + fetchMock.post( + 'https://schemaonly.local/?rest_route=/faustwp/v1/validate_secret_key', + { status: 403 }, + { overwriteRoutes: true }, + ); + + await validateFaustEnvVars(command); + + expect(mockExit).not.toHaveBeenCalled(); + expect(mockLog).toHaveBeenCalledWith( + expect.stringContaining('faustwp_exchange_tokens'), + ); + expect(fetchMock).not.toHaveFetched( + 'https://schemaonly.local/?rest_route=/faustwp/v1/validate_public_wordpress_url', + ); + + mockLog.mockRestore(); + }, + ); + + it("still exits on a user that can't exchange tokens when running dev", async () => { + // @ts-ignore + const mockExit = jest.spyOn(process, 'exit').mockImplementation((code) => { + if (code && code !== 0) { + throw new Error(`Exit code: ${code}`); + } + }); + const mockLog = jest.spyOn(console, 'log').mockImplementation(() => {}); + + process.env.NEXT_PUBLIC_WORDPRESS_URL = 'https://schemaonly.local'; + process.env.FAUST_APPLICATION_USER = 'subscriber'; + process.env.FAUST_APPLICATION_PASSWORD = 'abcd efgh ijkl mnop'; + + fetchMock.post( + 'https://schemaonly.local/?rest_route=/faustwp/v1/validate_secret_key', + { status: 403 }, + { overwriteRoutes: true }, + ); + + try { + await validateFaustEnvVars('dev'); + } catch (err) { + // Expected exit + } + + expect(mockExit).toHaveBeenCalledWith(1); + + mockLog.mockRestore(); + }); }); describe('isWPEngineComTLD', () => { diff --git a/packages/faustwp-cli/tests/utils/getWpAuthHeaders.test.ts b/packages/faustwp-cli/tests/utils/getWpAuthHeaders.test.ts new file mode 100644 index 000000000..269f3e7c2 --- /dev/null +++ b/packages/faustwp-cli/tests/utils/getWpAuthHeaders.test.ts @@ -0,0 +1,54 @@ +import { getWpAuthHeaders, hasWpCredentials } from '../../src/utils'; + +describe('utils/getWpAuthHeaders', () => { + const envBackup = process.env; + + beforeEach(() => { + process.env = { ...envBackup }; + delete process.env.FAUST_SECRET_KEY; + delete process.env.FAUSTWP_SECRET_KEY; + delete process.env.FAUST_APPLICATION_USER; + delete process.env.FAUST_APPLICATION_PASSWORD; + }); + + afterAll(() => { + process.env = envBackup; + }); + + it('sends the secret key when no Application Password is set', () => { + process.env.FAUST_SECRET_KEY = 'secret'; + + expect(getWpAuthHeaders()).toEqual({ 'x-faustwp-secret': 'secret' }); + expect(getWpAuthHeaders('x-faust-secret')).toEqual({ + 'x-faust-secret': 'secret', + }); + }); + + it('sends Basic auth instead of the secret key when an Application Password is set', () => { + process.env.FAUST_SECRET_KEY = 'secret'; + process.env.FAUST_APPLICATION_USER = 'frontend'; + process.env.FAUST_APPLICATION_PASSWORD = 'abcd efgh ijkl mnop'; + + expect(getWpAuthHeaders()).toEqual({ + Authorization: `Basic ${Buffer.from( + 'frontend:abcd efgh ijkl mnop', + ).toString('base64')}`, + }); + }); + + it('ignores an Application Password without a user', () => { + process.env.FAUST_APPLICATION_PASSWORD = 'abcd efgh ijkl mnop'; + + expect(getWpAuthHeaders()).toEqual({}); + expect(hasWpCredentials()).toBe(false); + }); + + it('reports whether any credentials are set', () => { + expect(hasWpCredentials()).toBe(false); + + process.env.FAUST_APPLICATION_USER = 'frontend'; + process.env.FAUST_APPLICATION_PASSWORD = 'abcd efgh ijkl mnop'; + + expect(hasWpCredentials()).toBe(true); + }); +}); diff --git a/packages/faustwp-core/src/lib/getWpApplicationPassword.ts b/packages/faustwp-core/src/lib/getWpApplicationPassword.ts new file mode 100644 index 000000000..5b9cc18d5 --- /dev/null +++ b/packages/faustwp-core/src/lib/getWpApplicationPassword.ts @@ -0,0 +1,16 @@ +import { base64Encode } from '../utils/convert.js'; + +/** + * Returns an `Authorization` header value for the WordPress Application Password + * in the project's env, or undefined if one isn't configured. + */ +export function getWpApplicationPasswordAuthorization() { + const user = process.env.FAUST_APPLICATION_USER; + const password = process.env.FAUST_APPLICATION_PASSWORD; + + if (!user || !password) { + return undefined; + } + + return `Basic ${base64Encode(`${user}:${password}`)}`; +} diff --git a/packages/faustwp-core/src/server/auth/middleware.ts b/packages/faustwp-core/src/server/auth/middleware.ts index fd0d0ad0b..f96984765 100644 --- a/packages/faustwp-core/src/server/auth/middleware.ts +++ b/packages/faustwp-core/src/server/auth/middleware.ts @@ -1,6 +1,7 @@ import 'isomorphic-fetch'; import { IncomingMessage, ServerResponse } from 'http'; import { errorLog, getQueryParam } from '../../utils/index.js'; +import { getWpApplicationPasswordAuthorization } from '../../lib/getWpApplicationPassword.js'; import { getWpSecret } from '../../lib/getWpSecret.js'; import { getWpUrl } from '../../lib/getWpUrl.js'; import { Cookies } from './cookie.js'; @@ -95,18 +96,24 @@ async function getWordPressLogoutUrl( redirectUri: string, ): Promise { const secret = getWpSecret(); + const applicationPassword = getWpApplicationPasswordAuthorization(); - if (!secret) { + if (!secret && !applicationPassword) { return null; } + // An Application Password, when configured, is used instead of the secret key. + const authHeaders: Record = applicationPassword + ? { Authorization: applicationPassword } + : { 'x-faustwp-secret': secret as string }; + try { const response = await fetch( `${getWpUrl()}/?rest_route=/faustwp/v1/logout`, { headers: { 'Content-Type': 'application/json', - 'x-faustwp-secret': secret, + ...authHeaders, }, method: 'POST', body: JSON.stringify({ refreshToken, redirectUri }), diff --git a/packages/faustwp-core/src/server/auth/token.ts b/packages/faustwp-core/src/server/auth/token.ts index 1296cdeec..d8578e7a4 100644 --- a/packages/faustwp-core/src/server/auth/token.ts +++ b/packages/faustwp-core/src/server/auth/token.ts @@ -5,6 +5,7 @@ import isNumber from 'lodash/isNumber.js'; import { Cookies } from './cookie.js'; import { warnLog } from '../../utils/index.js'; import { getWpSecret } from '../../lib/getWpSecret.js'; +import { getWpApplicationPasswordAuthorization } from '../../lib/getWpApplicationPassword.js'; import { getWpUrl } from '../../lib/getWpUrl.js'; export type OAuthTokenResponse = @@ -63,17 +64,23 @@ export class OAuth { public async fetch(code?: string): Promise { const wpUrl = getWpUrl(); const apiClientSecret = getWpSecret(); + const applicationPassword = getWpApplicationPasswordAuthorization(); - if (!apiClientSecret) { + if (!apiClientSecret && !applicationPassword) { throw new Error( 'The apiClientSecret must be specified to use the auth middleware', ); } + // An Application Password, when configured, is used instead of the secret key. + const authHeaders: Record = applicationPassword + ? { Authorization: applicationPassword } + : { 'x-faustwp-secret': apiClientSecret as string }; + let response = await fetch(`${wpUrl}/?rest_route=/faustwp/v1/authorize`, { headers: { 'Content-Type': 'application/json', - 'x-faustwp-secret': apiClientSecret, + ...authHeaders, }, method: 'POST', body: JSON.stringify({ @@ -82,8 +89,8 @@ export class OAuth { }), }); - if (response.status === 404) { - // Check for the deprecated authorize endpoint. + if (response.status === 404 && apiClientSecret) { + // Check for the deprecated authorize endpoint, which only accepts the secret key. response = await fetch(`${wpUrl}/?rest_route=/wpac/v1/authorize`, { headers: { 'Content-Type': 'application/json', @@ -107,6 +114,18 @@ export class OAuth { const result = await response.json(); + if ( + applicationPassword && + (response.status === 401 || response.status === 403) + ) { + warnLog( + 'WordPress rejected the Application Password in FAUST_APPLICATION_USER ' + + 'and FAUST_APPLICATION_PASSWORD. Check that its user can ' + + 'faustwp_exchange_tokens, and that your server passes the ' + + 'Authorization header through to WordPress.', + ); + } + if (!response.ok) { return { error: true, diff --git a/packages/faustwp-core/tests/server/auth/middleware.test.ts b/packages/faustwp-core/tests/server/auth/middleware.test.ts index 8b8d2b44b..9398fbb07 100644 --- a/packages/faustwp-core/tests/server/auth/middleware.test.ts +++ b/packages/faustwp-core/tests/server/auth/middleware.test.ts @@ -210,6 +210,35 @@ describe('auth/middleware', () => { }); }); + test('uses the Application Password instead of the secret key when one is set', async () => { + jest.spyOn(getWpSecret, 'getWpSecret').mockReturnValue(undefined); + process.env.FAUST_APPLICATION_USER = 'frontend'; + process.env.FAUST_APPLICATION_PASSWORD = 'abcd efgh'; + fetchMock.post(`${wpUrl}/?rest_route=/faustwp/v1/logout`, { + status: 200, + body: { logoutUrl: `${wpUrl}/?faust_logout=code` }, + }); + + const res: ServerResponse = { setHeader() {}, end() {} } as any; + + try { + await logoutHandler(logoutRequest(), res); + } finally { + delete process.env.FAUST_APPLICATION_USER; + delete process.env.FAUST_APPLICATION_PASSWORD; + } + + const headers = fetchMock.lastOptions()?.headers as Record< + string, + string + >; + expect(res.statusCode).toBe(200); + expect(headers.Authorization).toBe( + `Basic ${Buffer.from('frontend:abcd efgh').toString('base64')}`, + ); + expect(headers['x-faustwp-secret']).toBeUndefined(); + }); + test('falls back to a 205 when FaustWP has no logout endpoint', async () => { fetchMock.post(`${wpUrl}/?rest_route=/faustwp/v1/logout`, 404); diff --git a/packages/faustwp-core/tests/server/auth/token.test.ts b/packages/faustwp-core/tests/server/auth/token.test.ts index 56908ccd0..44a5d7bc8 100644 --- a/packages/faustwp-core/tests/server/auth/token.test.ts +++ b/packages/faustwp-core/tests/server/auth/token.test.ts @@ -365,4 +365,107 @@ describe('server/auth/token (OAuth)', () => { warningSpy.mockRestore(); }); }); + + describe('fetch with an Application Password', () => { + const tokens: OAuthTokens = { + accessToken: 'app-at', + accessTokenExpiration: 3600, + refreshToken: 'app-rt', + refreshTokenExpiration: 86400, + }; + + beforeEach(() => { + process.env.FAUST_APPLICATION_USER = 'frontend'; + process.env.FAUST_APPLICATION_PASSWORD = 'abcd efgh ijkl mnop'; + }); + + afterEach(() => { + delete process.env.FAUST_APPLICATION_USER; + delete process.env.FAUST_APPLICATION_PASSWORD; + }); + + test('sends Basic auth instead of the secret key', async () => { + const mockCookies = { + getCookie: jest.fn().mockReturnValue(undefined), + } as unknown as Cookies; + + fetchMock.post(`${wpUrl}/?rest_route=/faustwp/v1/authorize`, { + status: 200, + body: JSON.stringify(tokens), + }); + + const result = await new OAuth(mockCookies).fetch('auth-code'); + + expect(result).toEqual(tokens); + expect( + fetchMock.lastCall(`${wpUrl}/?rest_route=/faustwp/v1/authorize`)?.[1] + ?.headers, + ).toEqual({ + 'Content-Type': 'application/json', + Authorization: `Basic ${Buffer.from( + 'frontend:abcd efgh ijkl mnop', + ).toString('base64')}`, + }); + }); + + test('works without a secret key', async () => { + getWpSecretSpy.mockReturnValue(undefined); + const mockCookies = { + getCookie: jest.fn().mockReturnValue(undefined), + } as unknown as Cookies; + + fetchMock.post(`${wpUrl}/?rest_route=/faustwp/v1/authorize`, { + status: 200, + body: JSON.stringify(tokens), + }); + + await expect(new OAuth(mockCookies).fetch('auth-code')).resolves.toEqual( + tokens, + ); + }); + + test('logs a hint when WordPress rejects the Application Password', async () => { + const warningSpy = jest + .spyOn(console, 'log') + .mockImplementation(jest.fn()); + const mockCookies = { + getCookie: jest.fn().mockReturnValue(undefined), + } as unknown as Cookies; + + fetchMock.post(`${wpUrl}/?rest_route=/faustwp/v1/authorize`, { + status: 401, + body: JSON.stringify({ code: 'rest_forbidden' }), + }); + + const result: any = await new OAuth(mockCookies).fetch('auth-code'); + + expect(result.error).toBe(true); + expect(warningSpy.mock.calls.flat().join(' ')).toContain( + 'Authorization header', + ); + + warningSpy.mockRestore(); + }); + + test("doesn't try the deprecated endpoint without a secret key", async () => { + getWpSecretSpy.mockReturnValue(undefined); + const mockCookies = { + getCookie: jest.fn().mockReturnValue(undefined), + } as unknown as Cookies; + + fetchMock + .post(`${wpUrl}/?rest_route=/faustwp/v1/authorize`, { + status: 404, + body: JSON.stringify({ code: 'rest_no_route' }), + }) + .post(`${wpUrl}/?rest_route=/wpac/v1/authorize`, 200); + + const result: any = await new OAuth(mockCookies).fetch('auth-code'); + + expect(result.error).toBe(true); + expect(fetchMock.called(`${wpUrl}/?rest_route=/wpac/v1/authorize`)).toBe( + false, + ); + }); + }); }); diff --git a/plugins/faustwp/includes/auth/callbacks.php b/plugins/faustwp/includes/auth/callbacks.php index 6a223761d..1e03e2c95 100644 --- a/plugins/faustwp/includes/auth/callbacks.php +++ b/plugins/faustwp/includes/auth/callbacks.php @@ -117,3 +117,43 @@ function allowed_redirect_hosts( $hosts, $host ) { return $hosts; } + +add_filter( 'map_meta_cap', __NAMESPACE__ . '\\map_upload_blocks_capability', 10, 2 ); +/** + * Callback for WordPress 'map_meta_cap' filter. + * + * Uploading a blockset replaces the blocks registered for the whole site, so it + * needs the same access as managing the site's options, or the network's on + * Multisite. It can't be granted through a role. + * + * @param string[] $caps Primitive capabilities the user needs. + * @param string $cap Capability being checked. + * + * @return string[] Primitive capabilities the user needs. + */ +function map_upload_blocks_capability( $caps, $cap ) { + if ( 'faustwp_upload_blocks' === $cap ) { + return array( is_multisite() ? 'manage_network_options' : 'manage_options' ); + } + + return $caps; +} + +add_filter( 'user_has_cap', __NAMESPACE__ . '\\grant_exchange_tokens_capability' ); +/** + * Callback for WordPress 'user_has_cap' filter. + * + * Lets administrators exchange authorization codes for tokens with an Application + * Password. Other users need a role that includes 'faustwp_exchange_tokens'. + * + * @param bool[] $allcaps The user's capabilities. + * + * @return bool[] The user's capabilities. + */ +function grant_exchange_tokens_capability( $allcaps ) { + if ( ! empty( $allcaps['manage_options'] ) ) { + $allcaps['faustwp_exchange_tokens'] = true; + } + + return $allcaps; +} diff --git a/plugins/faustwp/includes/auth/functions.php b/plugins/faustwp/includes/auth/functions.php index 133c5ca84..665aca35a 100644 --- a/plugins/faustwp/includes/auth/functions.php +++ b/plugins/faustwp/includes/auth/functions.php @@ -253,3 +253,27 @@ function get_default_key() { return get_secret_key(); } + +/** + * Whether the current request was authenticated with an Application Password. + * + * Cookie sessions and Faust access tokens don't count, so a logged-in browser + * can't pass checks meant for server-to-server credentials. + * + * @return bool True if WordPress authenticated the request with an Application Password. + */ +function is_application_password_request() { + return function_exists( 'rest_get_authenticated_app_password' ) + && null !== rest_get_authenticated_app_password(); +} + +/** + * Whether the current request uses an Application Password whose user has a capability. + * + * @param string $capability Capability to check. + * + * @return bool True if the request used an Application Password and its user has the capability. + */ +function current_application_password_user_can( $capability ) { + return is_application_password_request() && current_user_can( $capability ); +} diff --git a/plugins/faustwp/includes/rest/callbacks.php b/plugins/faustwp/includes/rest/callbacks.php index 3943587d2..974b52539 100644 --- a/plugins/faustwp/includes/rest/callbacks.php +++ b/plugins/faustwp/includes/rest/callbacks.php @@ -8,6 +8,7 @@ namespace WPE\FaustWP\REST; use function WPE\FaustWP\Auth\{ + current_application_password_user_can, get_user_from_access_token, get_user_from_refresh_token, get_user_from_authorization_code, @@ -329,14 +330,16 @@ function handle_rest_process_telemetry_callback( \WP_REST_Request $request ) { /** * Callback to check permissions for requests to `faustwp/v1/blockset`. * - * Authorized if the 'secret_key' settings value and http header 'x-faustwp-secret' match. + * Authorized if the 'secret_key' settings value and http header 'x-faustwp-secret' match, + * or if the request uses an Application Password whose user can 'faustwp_upload_blocks'. * * @param \WP_REST_Request $request The current \WP_REST_Request object. * * @return bool True if current user can, false if else. */ function rest_blockset_permission_callback( \WP_REST_Request $request ) { - return rest_authorize_permission_callback( $request ); + return is_valid_secret_header( $request ) + || current_application_password_user_can( 'faustwp_upload_blocks' ); } /** @@ -444,7 +447,8 @@ function handle_rest_authorize_callback( \WP_REST_Request $request ) { /** * Callback to check permissions for requests to `faustwp/v1/authorize`. * - * Authorized if the 'secret_key' settings value and http header 'x-faustwp-secret' match. + * Authorized if the 'secret_key' settings value and http header 'x-faustwp-secret' match, + * or if the request uses an Application Password whose user can 'faustwp_exchange_tokens'. * * @link https://developer.wordpress.org/reference/functions/register_rest_route/ * @link https://developer.wordpress.org/rest-api/extending-the-rest-api/routes-and-endpoints/#permissions-callback @@ -454,6 +458,18 @@ function handle_rest_authorize_callback( \WP_REST_Request $request ) { * @return bool True if current user can, false if else. */ function rest_authorize_permission_callback( \WP_REST_Request $request ) { + return is_valid_secret_header( $request ) + || current_application_password_user_can( 'faustwp_exchange_tokens' ); +} + +/** + * Whether the request's 'x-faustwp-secret' header matches the 'secret_key' setting. + * + * @param \WP_REST_Request $request The current \WP_REST_Request object. + * + * @return bool True if the header matches, false if else. + */ +function is_valid_secret_header( \WP_REST_Request $request ) { $secret_key = get_secret_key(); $header_key = $request->get_header( 'x-faustwp-secret' ); diff --git a/plugins/faustwp/tests/integration/ApplicationPasswordAuthTests.php b/plugins/faustwp/tests/integration/ApplicationPasswordAuthTests.php new file mode 100644 index 000000000..0dae06e57 --- /dev/null +++ b/plugins/faustwp/tests/integration/ApplicationPasswordAuthTests.php @@ -0,0 +1,149 @@ +user->create( array( 'role' => 'administrator' ) ); + if ( is_multisite() ) { + // Network admins are tested separately; this is a site administrator. + revoke_super_admin( $user_id ); + } + return $user_id; + } + + private function create_user_with_capability( $capability ) { + $user_id = self::factory()->user->create( array( 'role' => 'subscriber' ) ); + get_user_by( 'id', $user_id )->add_cap( $capability ); + return $user_id; + } + + public function test_secret_header_still_authorizes_without_a_user() { + $request = $this->request( '/faustwp/v1/authorize' ); + $request->set_header( 'x-faustwp-secret', self::SECRET ); + + $this->assertTrue( rest_authorize_permission_callback( $request ) ); + $this->assertTrue( rest_blockset_permission_callback( $request ) ); + } + + public function test_authorize_accepts_an_administrators_application_password() { + $this->authenticate_with_application_password( $this->create_admin() ); + + $this->assertTrue( rest_authorize_permission_callback( $this->request( '/faustwp/v1/authorize' ) ) ); + } + + public function test_authorize_accepts_a_user_whose_role_grants_exchange_tokens() { + $this->authenticate_with_application_password( $this->create_user_with_capability( 'faustwp_exchange_tokens' ) ); + + $this->assertTrue( rest_authorize_permission_callback( $this->request( '/faustwp/v1/authorize' ) ) ); + } + + public function test_authorize_rejects_an_application_password_without_the_capability() { + $this->authenticate_with_application_password( self::factory()->user->create( array( 'role' => 'editor' ) ) ); + + $this->assertFalse( rest_authorize_permission_callback( $this->request( '/faustwp/v1/authorize' ) ) ); + } + + public function test_authorize_rejects_an_administrator_logged_in_with_a_cookie() { + wp_set_current_user( $this->create_admin() ); + + $this->assertFalse( rest_authorize_permission_callback( $this->request( '/faustwp/v1/authorize' ) ) ); + $this->assertFalse( rest_blockset_permission_callback( $this->request( '/faustwp/v1/blockset' ) ) ); + } + + public function test_server_routes_share_the_authorize_check() { + $this->authenticate_with_application_password( $this->create_user_with_capability( 'faustwp_exchange_tokens' ) ); + + $this->assertTrue( rest_telemetry_permission_callback( $this->request( '/faustwp/v1/telemetry' ) ) ); + $this->assertTrue( rest_process_telemetry_permission_callback( $this->request( '/faustwp/v1/process_telemetry' ) ) ); + $this->assertTrue( rest_validate_secret_key_permission_callback( $this->request( '/faustwp/v1/validate_secret_key' ) ) ); + } + + public function test_blockset_rejects_a_user_who_can_only_exchange_tokens() { + $this->authenticate_with_application_password( $this->create_user_with_capability( 'faustwp_exchange_tokens' ) ); + + $this->assertFalse( rest_blockset_permission_callback( $this->request( '/faustwp/v1/blockset' ) ) ); + } + + public function test_blockset_capability_cannot_be_granted_through_a_role() { + $this->authenticate_with_application_password( $this->create_user_with_capability( 'faustwp_upload_blocks' ) ); + + $this->assertFalse( rest_blockset_permission_callback( $this->request( '/faustwp/v1/blockset' ) ) ); + } + + public function test_blockset_accepts_an_administrators_application_password_on_a_single_site() { + if ( is_multisite() ) { + $this->markTestSkipped( 'Single site only.' ); + } + + $this->authenticate_with_application_password( $this->create_admin() ); + + $this->assertTrue( rest_blockset_permission_callback( $this->request( '/faustwp/v1/blockset' ) ) ); + } + + public function test_blockset_rejects_a_site_administrator_on_multisite() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite only.' ); + } + + $this->authenticate_with_application_password( $this->create_admin() ); + + $this->assertFalse( rest_blockset_permission_callback( $this->request( '/faustwp/v1/blockset' ) ) ); + } + + public function test_blockset_accepts_a_super_admin_on_multisite() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite only.' ); + } + + $user_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + grant_super_admin( $user_id ); + $this->authenticate_with_application_password( $user_id ); + + $this->assertTrue( rest_blockset_permission_callback( $this->request( '/faustwp/v1/blockset' ) ) ); + } +}