Skip to content

Instantly share code, notes, and snippets.

@Bobz-zg
Last active May 1, 2025 08:18
Show Gist options
  • Save Bobz-zg/a239e75cefdce0c0a7e3e2fb826161d2 to your computer and use it in GitHub Desktop.
Save Bobz-zg/a239e75cefdce0c0a7e3e2fb826161d2 to your computer and use it in GitHub Desktop.
WordPress REST Auth with custom cookie
<?php declare( strict_types=1 );
/** Prevents direct file access for security. */
defined( 'ABSPATH' ) || exit;
/**
* Class CodeSoup_CrossDomain_Cookie_Auth
*
* Drop-in class for secure cross-domain cookie authentication in WordPress REST API.
*
* Features:
* - Generates and validates secure authentication cookies across subdomains.
* - Integrates with WordPress session tokens for user validation.
* - Provides login, logout and nonce endpoints for REST API workflows.
* - Designed for easy integration into any plugin, with detailed inline documentation.
*
* Usage:
* 1. Include this file in your plugin.
* 2. Instantiate the class in your plugin bootstrap file:
* $cross_domain_auth = new CodeSoup_CrossDomain_Cookie_Auth();
* 3. Ensure your site uses HTTPS and proper CORS headers for cross-domain requests.
*
* @author Code Soup
* @version 1.1.0
*/
class CodeSoup_CrossDomain_Cookie_Auth {
/**
* Name of the custom authentication cookie.
* @var string
*/
public const COOKIE_NAME = 'pwa_rest_auth';
/**
* Cookie domain for cross-subdomain authentication.
* Example: '.yourdomain.com'
* @var string
*/
public const COOKIE_DOMAIN = '.yourdomain.com'; // Example: '.yourdomain.com' to allow subdomains. TODO: Make this configurable.
/**
* Cookie path.
* @var string
*/
public const COOKIE_PATH = '/';
/**
* Cookie expiration time in seconds (two weeks).
* Uses the WordPress DAY_IN_SECONDS constant for readability.
*
* @see DAY_IN_SECONDS https://codex.wordpress.org/Easier_Expression_of_Time_Constants
* @var int
*/
public const COOKIE_LIFETIME = 14 * DAY_IN_SECONDS; // Two weeks
/**
* Array of allowed domains for CORS (Cross-Origin Resource Sharing).
* These are the origins (domains) permitted to make authenticated requests
* to this WordPress site's REST API using the custom cookie.
* IMPORTANT: Only list trusted domains.
* @var array<string>
*/
public const ALLOWED_DOMAINS = [
'https://app.yourdomain.com',
'https://wp.yourdomain.com',
// Add more domains as needed
];
/**
* Constructor: sets up all hooks.
*/
public function __construct() {
// Register REST API endpoints for login, logout, and nonce
add_action( 'rest_api_init', [ $this, 'register_rest_routes' ] );
// Generate custom cookie value
add_filter( 'auth_cookie', [ $this, 'auth_cookie' ], 20, 5 );
// Authenticate user on REST requests using custom cookie
add_filter( 'determine_current_user', [ $this, 'determine_current_user' ], 20 );
// Set CORS headers for REST API responses using a filter that runs later
add_filter( 'rest_pre_serve_request', [ $this, 'add_cors_headers' ], 15, 4 );
}
/**
* Registers custom REST API routes for login, logout, and nonce retrieval.
* Uses the 'codesoup/v1/auth' namespace.
*
* @return void
*/
public function register_rest_routes(): void {
$namespace = 'codesoup/v1/auth';
register_rest_route( $namespace, '/login', [
'methods' => 'POST',
'callback' => [ $this, 'handle_login' ],
'permission_callback' => '__return_true', // Anyone can attempt to log in.
'args' => [ // Define and validate expected request parameters.
'username' => [
'required' => true,
'type' => 'string',
'description' => __( 'WordPress username or email.', 'my-plugin-name' ),
'sanitize_callback' => 'sanitize_user', // Sanitize username input.
],
'password' => [
'required' => true,
'type' => 'string',
'description' => __( 'WordPress password.', 'my-plugin-name' ),
// No sanitize_callback for password, as it's used directly for authentication.
],
],
'schema' => [ // Provides a schema for the request body (useful for documentation/validation).
// Define the expected request structure
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'login',
'type' => 'object',
'properties' => [
'username' => [
'description' => esc_html__( 'User login name or email address.', 'my-plugin-name' ),
'type' => 'string',
'required' => true,
],
'password' => [
'description' => esc_html__( 'User password.', 'my-plugin-name' ),
'type' => 'string',
'required' => true,
],
],
],
] );
register_rest_route( $namespace, '/logout', [
'methods' => 'POST',
'callback' => [ $this, 'handle_logout' ],
'permission_callback' => [ $this, 'is_user_logged_in_with_valid_nonce' ], // Requires logged-in user and valid nonce.
'schema' => [
// Define the expected request structure (empty for logout)
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'logout',
'type' => 'object',
'properties' => [], // No body parameters expected
],
] );
register_rest_route( $namespace, '/nonce', [
'methods' => 'GET',
'callback' => [ $this, 'handle_nonce' ],
'permission_callback' => 'is_user_logged_in', // Standard login check is sufficient, nonce obtained here.
'schema' => [
// Define the expected request structure (empty for nonce GET)
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'nonce',
'type' => 'object',
'properties' => [], // No parameters expected
],
] );
}
/**
* Handles user login, sets a secure cross-domain cookie, and returns a REST nonce.
*
* @param WP_REST_Request $request The incoming request object.
* @return WP_REST_Response|WP_Error Response object or WP_Error on failure.
*/
public function handle_login( WP_REST_Request $request ): WP_REST_Response|WP_Error {
// Parameters are already validated/sanitized by the 'args' definition
// in register_rest_route, ensuring they meet the required format and type.
$username = $request['username'];
$password = $request['password'];
// Authenticate the user using the standard WordPress function.
// This checks the provided username and password against the WP user database.
$user = wp_authenticate( $username, $password );
if ( is_wp_error( $user ) ) {
// Authentication failed. Return a WP_Error object.
// Use the specific error message from wp_authenticate if available for better feedback.
$error_message = $user->get_error_message();
return new WP_Error(
'auth_failed',
$error_message ?: __( 'Invalid credentials.', 'my-plugin-name' ),
[ 'status' => 401 ]
);
}
// Authentication successful. Set the custom cross-domain authentication cookie.
$this->set_cookie( $user );
// Optionally, set the standard WordPress auth cookie as well.
// This might be necessary if other parts of the site or plugins rely on the standard cookie.
// However, if the frontend relies solely on the custom cookie via REST, this might be omitted.
// wp_set_auth_cookie( $user->ID, true ); // Consider if this is needed for your setup
// Return a success response including the user ID and a fresh REST API nonce.
// The nonce is crucial for subsequent authenticated requests (like logout) to prevent CSRF attacks.
return new WP_REST_Response( [
'success' => true,
'user_id' => $user->ID,
'message' => __( 'Login successful.', 'my-plugin-name' ),
'nonce' => wp_create_nonce( 'wp_rest' )
] );
}
/**
* Handles user logout requests. Clears both standard and custom auth cookies.
* Requires a valid nonce passed via X-WP-Nonce header.
*
* @param WP_REST_Request $request Full data about the request.
* @return WP_REST_Response Response object on success.
*/
public function handle_logout( WP_REST_Request $request ): WP_REST_Response {
// Nonce verification is handled by the 'permission_callback' defined in register_rest_route.
// This ensures the logout request originated from an authenticated session.
// Clear standard WordPress authentication cookies and associated session tokens.
// This logs the user out of the standard WordPress session.
wp_logout();
// Explicitly unset/expire the custom authentication cookie.
// Check if headers have already been sent to avoid errors.
if ( ! headers_sent() ) {
// Use modern array syntax for setcookie options for clarity.
setcookie( self::COOKIE_NAME, '', [
'expires' => time() - YEAR_IN_SECONDS, // Set expiration date far in the past to ensure removal.
'path' => self::COOKIE_PATH, // Must match the path used when setting the cookie.
'domain' => self::COOKIE_DOMAIN, // Must match the domain used when setting the cookie.
'secure' => is_ssl(), // Should be true if site uses HTTPS.
'httponly' => true, // Prevents client-side script access to the cookie.
'samesite' => 'None' // Required for cross-domain usage when 'secure' is true.
] );
} else {
// Log an error if the cookie couldn't be unset because headers were already sent.
// This usually indicates an issue elsewhere in the code outputting content too early.
error_log( 'Could not unset ' . self::COOKIE_NAME . ' cookie because headers were already sent.' );
}
// Return success response indicating successful logout.
return new WP_REST_Response(
[
'success' => true,
'message' => __( 'You have been logged out successfully.', 'my-plugin-name' ),
],
200 // HTTP 200 OK
);
}
/**
* Returns a fresh REST API nonce for the authenticated user.
*
* @return WP_REST_Response|WP_Error WP_REST_Response with nonce on success.
*/
public function handle_nonce(): WP_REST_Response|WP_Error {
// The 'permission_callback' ('is_user_logged_in') ensures this endpoint
// is only accessible to logged-in users (authenticated via standard or custom cookie).
return new WP_REST_Response( [
'success' => true,
// Generate a fresh nonce for the 'wp_rest' action.
// This nonce should be used in the 'X-WP-Nonce' header for subsequent authenticated POST/PUT/DELETE requests.
'nonce' => wp_create_nonce( 'wp_rest' )
] );
}
/**
* Generate custom cookie format: user_id|expiration|token|hmac
* This method filters the standard WordPress auth cookie generation.
* Instead of returning the default cookie, it returns our custom structured cookie.
*
* @param string $cookie Default WP auth cookie value (ignored in our case, except for testcookie).
* @param int $user_id User ID.
* @param int $expiration Cookie expiration timestamp.
* @param string $scheme Authentication scheme ('auth', 'secure_auth', 'logged_in').
* @param string $token User's session token.
* @return string Custom cookie value in the format: "user_id|expiration|token|hmac".
*
* @link https://developer.wordpress.org/reference/hooks/auth_cookie/
*/
public function auth_cookie( string $cookie, int $user_id, int $expiration, string $scheme, string $token ): string {
// The 'testcookie' request parameter is used by WordPress during the standard login process
// to check if the browser supports cookies. We must return the original cookie value
// in this specific case to avoid breaking the standard wp-login.php flow.
// Consider potential implications if both standard and REST logins are used concurrently.
if ( isset( $_REQUEST['testcookie'] ) && $_REQUEST['testcookie'] ) {
return $cookie;
}
// Retrieve the authentication key salt. This is crucial for generating a secure HMAC.
$key = wp_salt( 'auth' );
// Generate a Hash-based Message Authentication Code (HMAC) using SHA256.
// This ensures the cookie data (user ID, expiration, token) hasn't been tampered with.
// The HMAC is created using the user ID, expiration, token, and the secret auth key.
$hmac = hash_hmac( 'sha256', "{$user_id}|{$expiration}|{$token}", $key );
// Combine the user ID, expiration timestamp, session token, and the HMAC,
// separated by pipes, to form the final custom cookie value.
return implode( '|', [ $user_id, $expiration, $token, $hmac ] );
}
/**
* Authenticates the user for REST API requests using the custom cookie.
*
* @param int|false|null $user_id Current user ID determination.
* @return int|false|null User ID if authenticated, otherwise the original value.
*
* @link https://developer.wordpress.org/reference/hooks/determine_current_user/
*/
public function determine_current_user( $user_id ): int|false|null {
// If a user ID is already determined (e.g., by a standard WP auth cookie or another method),
// or if this isn't a REST API request (checked via the REST_REQUEST constant),
// or if our custom cookie isn't present, then we don't need to do anything.
// Let WordPress continue its default user determination process.
if ( $user_id || ! ( defined( 'REST_REQUEST' ) && REST_REQUEST ) || empty( $_COOKIE[ self::COOKIE_NAME ] ) ) {
return $user_id;
}
// Retrieve the custom cookie value. Use wp_unslash as cookie values might be slashed by PHP/WordPress.
$cookie_value = wp_unslash( $_COOKIE[ self::COOKIE_NAME ] );
// Split the cookie value into its constituent parts based on the pipe delimiter.
$parts = explode( '|', $cookie_value );
// The cookie must have exactly 4 parts: user_id, expiration, token, hmac.
if ( count( $parts ) !== 4 ) {
// Invalid cookie format. Ignore the cookie and return the original user ID determination.
return $user_id;
}
// Assign parts to variables for clarity. Perform explicit type casting for numeric values.
list( $uid_str, $expiration_str, $token, $hmac ) = $parts;
$uid = (int) $uid_str;
$expiration = (int) $expiration_str;
// Validate the expiration timestamp. If the current time is past the expiration, the cookie is invalid.
if ( $expiration < time() ) {
// Cookie has expired. Ignore the cookie.
return $user_id;
}
// Re-generate the HMAC using the same data (uid, expiration, token) and the secret auth key.
$key = wp_salt( 'auth' );
$expected_hmac = hash_hmac( 'sha256', "{$uid}|{$expiration}|{$token}", $key );
// Compare the expected HMAC with the HMAC from the cookie using hash_equals for timing attack resistance.
if ( ! hash_equals( $expected_hmac, $hmac ) ) {
// HMAC mismatch indicates the cookie data may have been tampered with. Ignore the cookie.
return $user_id;
}
// Validate the session token using WordPress's session management API.
// This checks if the session token is valid for the given user ID and hasn't been revoked (e.g., by logging out elsewhere).
$manager = WP_Session_Tokens::get_instance( $uid );
if ( ! $manager->verify( $token ) ) {
// Session token is invalid or expired according to WordPress. Ignore the cookie.
return $user_id;
}
// All checks passed: format is correct, cookie hasn't expired, data integrity is verified (HMAC),
// and the session token is valid according to WordPress.
// Therefore, authenticate the user based on this cookie by returning their user ID.
return $uid;
}
/**
* Adds necessary CORS headers to the REST API response.
* Handles OPTIONS pre-flight requests.
* Hooked into 'rest_pre_serve_request'.
*
* @param bool $served Whether the request has already been served.
* @param WP_REST_Response $result Result to send to the client.
* @param WP_REST_Request $request Request used to generate the response.
* @param WP_REST_Server $server Server instance.
* @return bool Whether the request handler should continue processing.
*/
public function add_cors_headers( bool $served, WP_REST_Response $result, WP_REST_Request $request, WP_REST_Server $server ): bool {
// Get the origin of the incoming request (e.g., 'https://app.yourdomain.com').
$origin = get_http_origin();
// Check if the origin is present and is in our list of allowed domains.
if ( $origin && in_array( $origin, self::ALLOWED_DOMAINS ) ) {
// Origin is allowed. Send necessary CORS headers using the WP_REST_Server instance.
// This allows the browser on the specified origin to access the response.
$server->send_header( 'Access-Control-Allow-Origin', $origin );
// Specify allowed HTTP methods for cross-origin requests.
$server->send_header( 'Access-Control-Allow-Methods', 'POST, GET, OPTIONS, PUT, DELETE' );
// Allow credentials (like cookies) to be sent with cross-origin requests.
$server->send_header( 'Access-Control-Allow-Credentials', 'true' );
// Specify allowed headers in cross-origin requests (e.g., for authentication, content type, nonce).
$server->send_header( 'Access-Control-Allow-Headers', 'Authorization, Content-Type, X-WP-Nonce, X-Requested-With' );
// Add Referrer-Policy header for better security, restricting referrer information.
$server->send_header( 'Referrer-Policy', 'origin' );
// Handle OPTIONS pre-flight requests specifically.
// Browsers send an OPTIONS request before a 'complex' cross-origin request (e.g., POST with JSON)
// to check if the actual request is allowed.
if ( 'OPTIONS' === $request->get_method() ) {
// For OPTIONS, just send the CORS headers with a success status (204 No Content is common)
// and stop further WordPress processing for this request.
status_header( 204 );
exit; // Stop processing further for OPTIONS
}
} elseif ( $origin ) {
// If an origin was provided but it's not in the allowed list, log it for debugging/monitoring.
// This helps identify configuration issues or potentially malicious requests.
error_log( 'CORS Origin denied: ' . $origin );
}
// For non-OPTIONS requests or requests from disallowed origins,
// return the original $served value to let WordPress continue its normal response processing.
return $served;
}
/**
* Sets the custom cross-domain authentication cookie.
*
* @param WP_User $user The user object for whom to set the cookie.
* @return void
*/
private function set_cookie( WP_User $user ): void {
// Use WordPress's session token manager to create a secure session token for the user.
// This token is tied to the user's session and login time.
$manager = WP_Session_Tokens::get_instance( $user->ID );
// Set the cookie expiration time based on the defined lifetime.
$expiration = time() + self::COOKIE_LIFETIME;
// Create the session token with the calculated expiration.
$token = $manager->create( $expiration );
// Create a secure HMAC for cookie integrity, using the same method as in auth_cookie.
// This ensures the cookie we set can be verified later.
$key = wp_salt( 'auth' );
$hmac = hash_hmac( 'sha256', "{$user->ID}|{$expiration}|{$token}", $key );
// Compose the final cookie value string.
$cookie_value = implode( '|', [ $user->ID, $expiration, $token, $hmac ] );
// Set the cookie using PHP's setcookie function with appropriate security attributes.
setcookie(
self::COOKIE_NAME,
$cookie_value,
[
'expires' => $expiration,
'path' => self::COOKIE_PATH,
'domain' => self::COOKIE_DOMAIN,
'secure' => is_ssl(), // Dynamically set based on connection
'httponly' => true,
'samesite' => 'None' // Necessary for cross-site usage with Secure flag
]
);
}
/**
* Checks if the user is logged in and has provided a valid nonce.
* Used as a permission callback for actions requiring authentication and CSRF protection.
*
* @param WP_REST_Request $request The request object.
* @return bool|WP_Error True if authorized, WP_Error otherwise.
*/
public function is_user_logged_in_with_valid_nonce( WP_REST_Request $request ): bool|WP_Error {
// First, check if the user is considered logged in (could be via standard or our custom cookie).
if ( ! is_user_logged_in() ) {
// If not logged in, return a WP_Error with a 401 Unauthorized status.
return new WP_Error(
'rest_not_logged_in',
__( 'You are not currently logged in.', 'my-plugin-name' ),
[ 'status' => 401 ] // Unauthorized
);
}
// If logged in, check for the CSRF nonce provided in the 'X-WP-Nonce' request header.
// This header should be sent by the client application using the nonce obtained from /login or /nonce endpoints.
$nonce = $request->get_header( 'X-WP-Nonce' );
// Verify the nonce against the 'wp_rest' action.
// If the nonce is missing or invalid, return a WP_Error with a 403 Forbidden status.
if ( ! $nonce || ! wp_verify_nonce( $nonce, 'wp_rest' ) ) {
return new WP_Error(
'rest_cookie_invalid_nonce',
__( 'Cookie check failed. Missing or invalid nonce.', 'my-plugin-name' ),
[ 'status' => 403 ] // Forbidden
);
}
// Both checks passed: User is logged in and provided a valid nonce.
// Grant permission to proceed with the requested action (e.g., logout).
return true;
}
}
/**
* ==============================================================================
* Developer Documentation: CodeSoup_CrossDomain_Cookie_Auth Class
* ==============================================================================
*
* Purpose:
* --------
* This class provides a robust mechanism for handling user authentication for the
* WordPress REST API, specifically designed for scenarios where the frontend
* application (e.g., a PWA or SPA) resides on a different subdomain than the
* WordPress backend. It replaces the standard WordPress cookie authentication
* with a custom, secure HTTPOnly cookie that can be shared across specified subdomains.
*
* Key Features:
* -------------
* 1. **Cross-Domain Cookie Authentication:**
* - Generates a custom authentication cookie (`wp_rest_auth` by default) during login.
* - This cookie is configured with `SameSite=None; Secure` and a configurable domain
* (e.g., `.yourdomain.com`) allowing it to be sent by browsers from allowed subdomains.
* - Uses WordPress session tokens (`WP_Session_Tokens`) for secure session management.
* - Includes an HMAC (Hash-based Message Authentication Code) within the cookie value
* to ensure data integrity and prevent tampering.
*
* 2. **REST API Endpoints:**
* - Registers the following endpoints under the `codesoup/v1/auth` namespace:
* - `POST /login`: Authenticates user credentials (username/password), sets the custom
* auth cookie, and returns a `wp_rest` nonce for CSRF protection.
* - `POST /logout`: Logs the user out by clearing the standard WP session/cookie
* and explicitly expiring the custom auth cookie. Requires a valid `wp_rest` nonce
* sent via the `X-WP-Nonce` header.
* - `GET /nonce`: Provides a fresh `wp_rest` nonce for authenticated users. Useful for
* clients needing a new nonce for subsequent state-changing requests.
*
* 3. **WordPress Integration:**
* - Hooks into `determine_current_user` to authenticate users based on the custom cookie
* for incoming REST API requests.
* - Hooks into `auth_cookie` to generate the custom cookie format when a user logs in
* (while carefully handling the standard `testcookie` check).
* - Hooks into `rest_pre_serve_request` to correctly handle CORS headers, allowing
* requests from configured origins (`ALLOWED_DOMAINS`) and managing OPTIONS pre-flight requests.
*
* 4. **Security:**
* - Enforces HTTPS for the cookie (`Secure` attribute, `is_ssl()` checks).
* - Makes the cookie `HttpOnly` to prevent access via client-side JavaScript.
* - Requires nonces (`wp_rest`) for state-changing operations (logout) via the
* `is_user_logged_in_with_valid_nonce` permission callback, mitigating CSRF risks.
* - Validates cookie expiration and HMAC integrity during user determination.
* - Uses `wp_salt('auth')` for secure keying of the HMAC.
* - Includes basic input validation and sanitization for login parameters via `register_rest_route` args.
*
* 5. **Configuration & Usage:**
* - Key parameters like `COOKIE_DOMAIN` and `ALLOWED_DOMAINS` are defined as constants.
* (Marked with TODOs for potential external configuration in a real-world plugin).
* - Requires instantiation within a plugin or theme (e.g., `new CodeSoup_CrossDomain_Cookie_Auth();`).
* - Assumes the frontend will handle storing the user ID/details and the nonce received upon login,
* and will send the `X-WP-Nonce` header with subsequent authenticated requests.
*
* How it Works (Flow):
* ---------------------
* 1. **Login:** Client sends POST to `/codesoup/v1/auth/login` with username/password.
* - `handle_login` validates credentials via `wp_authenticate`.
* - If valid, `set_cookie` is called.
* - `set_cookie` generates a WP session token and an HMAC.
* - `set_cookie` sends the `wp_rest_auth` cookie (user_id|expiration|token|hmac) via `setcookie`
* with appropriate domain, path, secure, httponly, samesite attributes.
* - Response includes `success: true`, `user_id`, and a `nonce`.
* 2. **Authenticated Request (e.g., POST to another endpoint):**
* - Client sends request including the `wp_rest_auth` cookie and the `X-WP-Nonce` header.
* - `determine_current_user` filter is triggered.
* - It parses the `wp_rest_auth` cookie, validates expiration, HMAC, and the WP session token.
* - If valid, it returns the user ID, authenticating the request for WordPress.
* - The target endpoint's permission callback likely checks `is_user_logged_in()` and potentially verifies the nonce.
* 3. **Logout:** Client sends POST to `/codesoup/v1/auth/logout` with `X-WP-Nonce` header.
* - `is_user_logged_in_with_valid_nonce` permission callback verifies login status and nonce.
* - If valid, `handle_logout` calls `wp_logout` (clears standard session) and explicitly
* sends an expired `wp_rest_auth` cookie via `setcookie`.
*
* Dependencies:
* -------------
* - WordPress Core functions (REST API, authentication, nonces, session tokens, salts).
* - PHP 7.4+ (due to strict types, arrow functions might be usable if min PHP version allows).
*
* Considerations:
* ---------------
* - Ensure the `COOKIE_DOMAIN` and `ALLOWED_DOMAINS` constants are correctly configured for the target environment.
* - Requires HTTPS on the site for the `Secure` cookie attribute to work correctly.
* - The frontend client is responsible for securely handling the received nonce and including it in subsequent requests.
* - The `testcookie` handling in `auth_cookie` ensures compatibility with standard wp-login but warrants review depending on specific plugin/theme interactions.
*/
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment