328 lines
9.8 KiB
PHP
328 lines
9.8 KiB
PHP
<?php
|
|
/**
|
|
* OAuth 2.0 Authorization Code model for ActivityPub C2S.
|
|
*
|
|
* @package Activitypub
|
|
*/
|
|
|
|
namespace Activitypub\OAuth;
|
|
|
|
use Activitypub\Sanitize;
|
|
|
|
/**
|
|
* Authorization_Code class for managing OAuth 2.0 authorization codes.
|
|
*
|
|
* Authorization codes are short-lived (10 minutes) and stored as transients.
|
|
* This is more efficient than CPT for temporary data.
|
|
*/
|
|
class Authorization_Code {
|
|
/**
|
|
* Transient prefix for authorization codes.
|
|
*/
|
|
const TRANSIENT_PREFIX = 'activitypub_oauth_code_';
|
|
|
|
/**
|
|
* Authorization code expiration in seconds (10 minutes).
|
|
*/
|
|
const EXPIRATION = 600;
|
|
|
|
/**
|
|
* Create a new authorization code.
|
|
*
|
|
* @param int $user_id WordPress user ID.
|
|
* @param string $client_id OAuth client ID.
|
|
* @param string $redirect_uri The redirect URI.
|
|
* @param array $scopes Requested scopes.
|
|
* @param string $code_challenge PKCE code challenge.
|
|
* @param string $code_challenge_method PKCE method (only S256 is supported).
|
|
* @return string|\WP_Error The authorization code or error.
|
|
*/
|
|
public static function create(
|
|
$user_id,
|
|
$client_id,
|
|
$redirect_uri,
|
|
$scopes,
|
|
$code_challenge,
|
|
$code_challenge_method = 'S256'
|
|
) {
|
|
$redirect_uri = Sanitize::redirect_uri( $redirect_uri );
|
|
|
|
// Validate client.
|
|
$client = Client::get( $client_id );
|
|
if ( \is_wp_error( $client ) ) {
|
|
return $client;
|
|
}
|
|
|
|
// Validate redirect URI.
|
|
if ( ! $client->is_valid_redirect_uri( $redirect_uri ) ) {
|
|
return new \WP_Error(
|
|
'activitypub_invalid_redirect_uri',
|
|
\__( 'Invalid redirect URI for this client.', 'activitypub' ),
|
|
array( 'status' => 400 )
|
|
);
|
|
}
|
|
|
|
/*
|
|
* PKCE is strongly recommended for public clients (RFC 7636) and
|
|
* mandatory in the OAuth 2.1 draft. It is enforced by default; site
|
|
* operators who must support pre-PKCE clients can opt out via the
|
|
* `activitypub_oauth_require_pkce` filter.
|
|
*/
|
|
if ( empty( $code_challenge ) && $client->is_public() ) {
|
|
/**
|
|
* Filter whether PKCE is required for public OAuth clients.
|
|
*
|
|
* Return false to relax the default and allow public clients to
|
|
* complete the authorization code grant without PKCE. This is
|
|
* not recommended.
|
|
*
|
|
* @since 8.1.0
|
|
* @since 8.2.0 Default changed from false to true.
|
|
*
|
|
* @param bool $require Whether to require PKCE. Default true.
|
|
* @param string $client_id The OAuth client ID.
|
|
*/
|
|
if ( \apply_filters( 'activitypub_oauth_require_pkce', true, $client_id ) ) {
|
|
return new \WP_Error(
|
|
'activitypub_pkce_required',
|
|
\__( 'PKCE is required for public clients. Please include a code_challenge parameter.', 'activitypub' ),
|
|
array( 'status' => 400 )
|
|
);
|
|
}
|
|
}
|
|
|
|
// Filter scopes to only allowed ones.
|
|
$filtered_scopes = $client->filter_scopes( Scope::validate( $scopes ) );
|
|
|
|
// Generate the code.
|
|
$code = self::generate_code();
|
|
$code_hash = self::hash_code( $code );
|
|
$expires_at = time() + self::EXPIRATION;
|
|
|
|
// Store code data in transient.
|
|
$code_data = array(
|
|
'user_id' => $user_id,
|
|
'client_id' => $client_id,
|
|
'redirect_uri' => $redirect_uri,
|
|
'scopes' => $filtered_scopes,
|
|
'code_challenge' => $code_challenge,
|
|
'code_challenge_method' => $code_challenge_method,
|
|
'expires_at' => $expires_at,
|
|
'created_at' => time(),
|
|
);
|
|
|
|
$stored = \set_transient(
|
|
self::TRANSIENT_PREFIX . $code_hash,
|
|
$code_data,
|
|
self::EXPIRATION
|
|
);
|
|
|
|
if ( ! $stored ) {
|
|
return new \WP_Error(
|
|
'activitypub_code_storage_failed',
|
|
\__( 'Failed to store authorization code.', 'activitypub' ),
|
|
array( 'status' => 500 )
|
|
);
|
|
}
|
|
|
|
return $code;
|
|
}
|
|
|
|
/**
|
|
* Exchange authorization code for tokens.
|
|
*
|
|
* @param string $code The authorization code.
|
|
* @param string $client_id The client ID.
|
|
* @param string $redirect_uri The redirect URI (must match original).
|
|
* @param string $code_verifier The PKCE code verifier.
|
|
* @return array|\WP_Error Token data or error.
|
|
*/
|
|
public static function exchange( $code, $client_id, $redirect_uri, $code_verifier ) {
|
|
$redirect_uri = Sanitize::redirect_uri( $redirect_uri );
|
|
$code_hash = self::hash_code( $code );
|
|
$transient = self::TRANSIENT_PREFIX . $code_hash;
|
|
$code_data = \get_transient( $transient );
|
|
|
|
if ( false === $code_data ) {
|
|
return new \WP_Error(
|
|
'activitypub_invalid_code',
|
|
\__( 'Invalid or expired authorization code.', 'activitypub' ),
|
|
array( 'status' => 400 )
|
|
);
|
|
}
|
|
|
|
// Immediately delete the code (single use).
|
|
\delete_transient( $transient );
|
|
|
|
// Check expiration (belt and suspenders - transient should auto-expire).
|
|
if ( isset( $code_data['expires_at'] ) && $code_data['expires_at'] < time() ) {
|
|
return new \WP_Error(
|
|
'activitypub_code_expired',
|
|
\__( 'Authorization code has expired.', 'activitypub' ),
|
|
array( 'status' => 400 )
|
|
);
|
|
}
|
|
|
|
// Verify client ID matches.
|
|
if ( $code_data['client_id'] !== $client_id ) {
|
|
return new \WP_Error(
|
|
'activitypub_client_mismatch',
|
|
\__( 'Client ID does not match.', 'activitypub' ),
|
|
array( 'status' => 400 )
|
|
);
|
|
}
|
|
|
|
// Verify redirect URI matches.
|
|
if ( $code_data['redirect_uri'] !== $redirect_uri ) {
|
|
return new \WP_Error(
|
|
'activitypub_redirect_uri_mismatch',
|
|
\__( 'Redirect URI does not match.', 'activitypub' ),
|
|
array( 'status' => 400 )
|
|
);
|
|
}
|
|
|
|
// Verify PKCE.
|
|
$code_challenge = $code_data['code_challenge'] ?? '';
|
|
$code_challenge_method = $code_data['code_challenge_method'] ?? 'S256';
|
|
|
|
if ( ! self::verify_pkce( $code_verifier, $code_challenge, $code_challenge_method ) ) {
|
|
return new \WP_Error(
|
|
'activitypub_invalid_pkce',
|
|
\__( 'Invalid PKCE code verifier.', 'activitypub' ),
|
|
array( 'status' => 400 )
|
|
);
|
|
}
|
|
|
|
// Create and return the tokens.
|
|
return Token::create(
|
|
$code_data['user_id'],
|
|
$client_id,
|
|
$code_data['scopes']
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Verify PKCE code_verifier against code_challenge.
|
|
*
|
|
* @param string $code_verifier The PKCE code verifier.
|
|
* @param string $code_challenge The stored code challenge.
|
|
* @param string $method The challenge method (only S256 is supported).
|
|
* @return bool True if valid.
|
|
*/
|
|
public static function verify_pkce( $code_verifier, $code_challenge, $method = 'S256' ) {
|
|
// If PKCE wasn't used during authorization (no challenge stored), skip verification.
|
|
if ( empty( $code_challenge ) ) {
|
|
return true;
|
|
}
|
|
|
|
// If challenge was provided but verifier is missing, fail.
|
|
if ( empty( $code_verifier ) ) {
|
|
return false;
|
|
}
|
|
|
|
// Only S256 is supported; reject anything else.
|
|
if ( 'S256' !== $method ) {
|
|
return false;
|
|
}
|
|
|
|
// S256: BASE64URL(SHA256(code_verifier)) == code_challenge.
|
|
$computed = self::compute_code_challenge( $code_verifier );
|
|
|
|
return hash_equals( $code_challenge, $computed );
|
|
}
|
|
|
|
/**
|
|
* Compute a PKCE code challenge from a code verifier.
|
|
*
|
|
* @param string $code_verifier The code verifier.
|
|
* @return string The code challenge (BASE64URL encoded SHA256 hash).
|
|
*/
|
|
public static function compute_code_challenge( $code_verifier ) {
|
|
$hash = hash( 'sha256', $code_verifier, true );
|
|
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Required for PKCE BASE64URL encoding per RFC 7636.
|
|
return rtrim( strtr( base64_encode( $hash ), '+/', '-_' ), '=' );
|
|
}
|
|
|
|
/**
|
|
* Generate a random authorization code.
|
|
*
|
|
* @return string The authorization code.
|
|
*/
|
|
public static function generate_code() {
|
|
return bin2hex( random_bytes( 32 ) );
|
|
}
|
|
|
|
/**
|
|
* Hash an authorization code for storage lookup.
|
|
*
|
|
* @param string $code The authorization code.
|
|
* @return string The SHA-256 hash.
|
|
*/
|
|
public static function hash_code( $code ) {
|
|
return hash( 'sha256', $code );
|
|
}
|
|
|
|
/**
|
|
* Clean up expired authorization codes.
|
|
*
|
|
* Only deletes transients that have actually expired, to avoid breaking
|
|
* in-progress authorization flows.
|
|
*
|
|
* Note: Transients auto-expire, but this cleans up any orphaned ones.
|
|
* Should be called periodically via cron.
|
|
*
|
|
* @return int Number of codes deleted.
|
|
*/
|
|
public static function cleanup() {
|
|
global $wpdb;
|
|
|
|
/*
|
|
* When an external object cache is active, transients are stored in
|
|
* the cache backend (Redis, Memcached, etc.) and auto-expire there.
|
|
* The direct SQL below only targets the options table, so skip it.
|
|
*/
|
|
if ( \wp_using_ext_object_cache() ) {
|
|
return 0;
|
|
}
|
|
|
|
$timeout_prefix = '_transient_timeout_' . self::TRANSIENT_PREFIX;
|
|
$now = time();
|
|
|
|
// Find expired timeout rows for this prefix.
|
|
$timeout_option_names = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
|
$wpdb->prepare(
|
|
"SELECT option_name FROM {$wpdb->options}
|
|
WHERE option_name LIKE %s
|
|
AND option_value < %d",
|
|
$wpdb->esc_like( $timeout_prefix ) . '%',
|
|
$now
|
|
)
|
|
);
|
|
|
|
if ( empty( $timeout_option_names ) ) {
|
|
return 0;
|
|
}
|
|
|
|
// Build list of timeout and corresponding value option names to delete.
|
|
$option_names_to_delete = array();
|
|
foreach ( $timeout_option_names as $timeout_name ) {
|
|
$option_names_to_delete[] = $timeout_name;
|
|
$option_names_to_delete[] = str_replace( '_transient_timeout_', '_transient_', $timeout_name );
|
|
}
|
|
|
|
$placeholders = implode( ', ', array_fill( 0, count( $option_names_to_delete ), '%s' ) );
|
|
|
|
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery
|
|
$count = $wpdb->query(
|
|
$wpdb->prepare(
|
|
"DELETE FROM {$wpdb->options} WHERE option_name IN ( {$placeholders} )",
|
|
$option_names_to_delete
|
|
)
|
|
);
|
|
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery
|
|
|
|
// Each transient has 2 rows (value + timeout).
|
|
return $count ? (int) ( $count / 2 ) : 0;
|
|
}
|
|
}
|