| 1 |
<?php |
| 2 |
/** |
| 3 |
* Minimal OAuth 2.1 authorization server for the NotificationX MCP endpoint. |
| 4 |
* |
| 5 |
* Implements exactly what MCP clients such as Claude need for a one-click |
| 6 |
* connection: |
| 7 |
* - RFC 8414 authorization-server metadata + RFC 9728 protected-resource |
| 8 |
* metadata (served by {@see Manager}), |
| 9 |
* - RFC 7591 dynamic client registration (public clients, no secret), |
| 10 |
* - the authorization-code grant with mandatory PKCE (S256), |
| 11 |
* - refresh-token rotation. |
| 12 |
* |
| 13 |
* Access/refresh tokens are stored only as SHA-256 hashes; the raw value is |
| 14 |
* returned to the client once and never persisted. Every issued grant is bound |
| 15 |
* to the WordPress admin who approved it. |
| 16 |
* |
| 17 |
* @package NotificationX\MCP |
| 18 |
*/ |
| 19 |
|
| 20 |
namespace NotificationX\MCP; |
| 21 |
|
| 22 |
use NotificationX\GetInstance; |
| 23 |
|
| 24 |
if ( ! defined( 'ABSPATH' ) ) { |
| 25 |
exit; |
| 26 |
} |
| 27 |
|
| 28 |
/** |
| 29 |
* @method static OAuth get_instance( $args = null ) |
| 30 |
*/ |
| 31 |
class OAuth { |
| 32 |
|
| 33 |
use GetInstance; |
| 34 |
|
| 35 |
const OPTION = 'notificationx_mcp_oauth'; |
| 36 |
const ACCESS_TTL = 3600; // 1 hour. |
| 37 |
const REFRESH_TTL = 2592000; // 30 days. |
| 38 |
const CODE_TTL = 60; // 1 minute, single use. |
| 39 |
const MAX_CLIENTS = 50; |
| 40 |
const CLIENT_TTL = 86400; // 24 hours for unused clients. |
| 41 |
|
| 42 |
/** |
| 43 |
* The OAuth issuer/authorization identifier. Path-based so it stays |
| 44 |
* specific to this plugin and can coexist with another plugin's MCP OAuth. |
| 45 |
* |
| 46 |
* @return string |
| 47 |
*/ |
| 48 |
public function issuer() { |
| 49 |
return home_url( '/notificationx/mcp' ); |
| 50 |
} |
| 51 |
|
| 52 |
/** |
| 53 |
* The protected resource identifier (the MCP endpoint URL). |
| 54 |
* |
| 55 |
* @return string |
| 56 |
*/ |
| 57 |
public function resource() { |
| 58 |
return home_url( '/notificationx/mcp' ); |
| 59 |
} |
| 60 |
|
| 61 |
/** |
| 62 |
* RFC 8414 authorization-server metadata document. |
| 63 |
* |
| 64 |
* @return array |
| 65 |
*/ |
| 66 |
public function authorization_server_metadata() { |
| 67 |
$base = home_url( '/notificationx/mcp' ); |
| 68 |
return array( |
| 69 |
'issuer' => $this->issuer(), |
| 70 |
'authorization_endpoint' => home_url( '/notificationx/authorize' ), |
| 71 |
'token_endpoint' => rest_url( 'notificationx/v1/mcp/oauth/token' ), |
| 72 |
'registration_endpoint' => rest_url( 'notificationx/v1/mcp/oauth/register' ), |
| 73 |
'scopes_supported' => array( 'read', 'write', 'mcp' ), |
| 74 |
'response_types_supported' => array( 'code' ), |
| 75 |
'grant_types_supported' => array( 'authorization_code', 'refresh_token' ), |
| 76 |
'code_challenge_methods_supported' => array( 'S256' ), |
| 77 |
'token_endpoint_auth_methods_supported' => array( 'none' ), |
| 78 |
); |
| 79 |
} |
| 80 |
|
| 81 |
/** |
| 82 |
* RFC 9728 protected-resource metadata document. |
| 83 |
* |
| 84 |
* @return array |
| 85 |
*/ |
| 86 |
public function protected_resource_metadata() { |
| 87 |
return array( |
| 88 |
'resource' => $this->resource(), |
| 89 |
'authorization_servers' => array( $this->issuer() ), |
| 90 |
'scopes_supported' => array( 'read', 'write', 'mcp' ), |
| 91 |
'bearer_methods_supported' => array( 'header' ), |
| 92 |
); |
| 93 |
} |
| 94 |
|
| 95 |
/* --------------------------------------------------------------------- */ |
| 96 |
/* Dynamic client registration */ |
| 97 |
/* --------------------------------------------------------------------- */ |
| 98 |
|
| 99 |
/** |
| 100 |
* Register a public client (RFC 7591). |
| 101 |
* |
| 102 |
* @param array $params Registration request body. |
| 103 |
* @return array|\WP_Error Registration response or error. |
| 104 |
*/ |
| 105 |
public function register_client( $params ) { |
| 106 |
$redirect_uris = isset( $params['redirect_uris'] ) ? (array) $params['redirect_uris'] : array(); |
| 107 |
$redirect_uris = array_values( array_filter( array_map( array( $this, 'clean_redirect_uri' ), $redirect_uris ) ) ); |
| 108 |
|
| 109 |
if ( empty( $redirect_uris ) ) { |
| 110 |
return new \WP_Error( 'invalid_redirect_uri', __( 'At least one valid redirect_uri is required.', 'notificationx' ), array( 'status' => 400 ) ); |
| 111 |
} |
| 112 |
|
| 113 |
$state = $this->store(); |
| 114 |
$this->prune_clients( $state ); |
| 115 |
|
| 116 |
if ( count( $state['clients'] ) >= self::MAX_CLIENTS ) { |
| 117 |
return new \WP_Error( 'too_many_clients', __( 'Client registration limit reached.', 'notificationx' ), array( 'status' => 400 ) ); |
| 118 |
} |
| 119 |
|
| 120 |
$client_id = 'nx_' . bin2hex( random_bytes( 16 ) ); |
| 121 |
|
| 122 |
$state['clients'][ $client_id ] = array( |
| 123 |
'redirect_uris' => $redirect_uris, |
| 124 |
'client_name' => isset( $params['client_name'] ) ? sanitize_text_field( $params['client_name'] ) : '', |
| 125 |
'created_at' => time(), |
| 126 |
); |
| 127 |
$this->save( $state ); |
| 128 |
|
| 129 |
return array( |
| 130 |
'client_id' => $client_id, |
| 131 |
'redirect_uris' => $redirect_uris, |
| 132 |
'token_endpoint_auth_method' => 'none', |
| 133 |
'grant_types' => array( 'authorization_code', 'refresh_token' ), |
| 134 |
'response_types' => array( 'code' ), |
| 135 |
); |
| 136 |
} |
| 137 |
|
| 138 |
/* --------------------------------------------------------------------- */ |
| 139 |
/* Authorization code */ |
| 140 |
/* --------------------------------------------------------------------- */ |
| 141 |
|
| 142 |
/** |
| 143 |
* Validate an incoming /authorize request. |
| 144 |
* |
| 145 |
* @param array $params Query params. |
| 146 |
* @return array|\WP_Error Cleaned request or error. |
| 147 |
*/ |
| 148 |
public function validate_authorize_request( $params ) { |
| 149 |
$client_id = isset( $params['client_id'] ) ? sanitize_text_field( $params['client_id'] ) : ''; |
| 150 |
$redirect_uri = isset( $params['redirect_uri'] ) ? $this->clean_redirect_uri( $params['redirect_uri'] ) : ''; |
| 151 |
$response_type = isset( $params['response_type'] ) ? sanitize_text_field( $params['response_type'] ) : ''; |
| 152 |
$challenge = isset( $params['code_challenge'] ) ? sanitize_text_field( $params['code_challenge'] ) : ''; |
| 153 |
$method = isset( $params['code_challenge_method'] ) ? sanitize_text_field( $params['code_challenge_method'] ) : ''; |
| 154 |
$scope = isset( $params['scope'] ) ? sanitize_text_field( $params['scope'] ) : 'read write'; |
| 155 |
$state = isset( $params['state'] ) ? sanitize_text_field( $params['state'] ) : ''; |
| 156 |
|
| 157 |
$store = $this->store(); |
| 158 |
$client = isset( $store['clients'][ $client_id ] ) ? $store['clients'][ $client_id ] : null; |
| 159 |
|
| 160 |
if ( ! $client ) { |
| 161 |
return new \WP_Error( 'invalid_client', __( 'Unknown client.', 'notificationx' ), array( 'status' => 400 ) ); |
| 162 |
} |
| 163 |
if ( ! $redirect_uri || ! in_array( $redirect_uri, $client['redirect_uris'], true ) ) { |
| 164 |
return new \WP_Error( 'invalid_redirect_uri', __( 'redirect_uri mismatch.', 'notificationx' ), array( 'status' => 400 ) ); |
| 165 |
} |
| 166 |
if ( 'code' !== $response_type ) { |
| 167 |
return new \WP_Error( 'unsupported_response_type', __( 'Only the authorization code flow is supported.', 'notificationx' ), array( 'status' => 400 ) ); |
| 168 |
} |
| 169 |
if ( '' === $challenge || 'S256' !== $method ) { |
| 170 |
return new \WP_Error( 'invalid_request', __( 'PKCE with S256 is required.', 'notificationx' ), array( 'status' => 400 ) ); |
| 171 |
} |
| 172 |
|
| 173 |
return array( |
| 174 |
'client_id' => $client_id, |
| 175 |
'redirect_uri' => $redirect_uri, |
| 176 |
'code_challenge' => $challenge, |
| 177 |
'scope' => $scope, |
| 178 |
'state' => $state, |
| 179 |
); |
| 180 |
} |
| 181 |
|
| 182 |
/** |
| 183 |
* Issue a single-use authorization code bound to the approving user. |
| 184 |
* |
| 185 |
* @param array $request Validated authorize request. |
| 186 |
* @param int $user_id Approving admin user id. |
| 187 |
* @return string The authorization code. |
| 188 |
*/ |
| 189 |
public function issue_code( $request, $user_id ) { |
| 190 |
$code = bin2hex( random_bytes( 24 ) ); |
| 191 |
$store = $this->store(); |
| 192 |
|
| 193 |
$store['codes'][ $this->hash( $code ) ] = array( |
| 194 |
'client_id' => $request['client_id'], |
| 195 |
'redirect_uri' => $request['redirect_uri'], |
| 196 |
'code_challenge' => $request['code_challenge'], |
| 197 |
'scope' => $request['scope'], |
| 198 |
'user_id' => (int) $user_id, |
| 199 |
'expires' => time() + self::CODE_TTL, |
| 200 |
); |
| 201 |
$this->save( $store ); |
| 202 |
|
| 203 |
return $code; |
| 204 |
} |
| 205 |
|
| 206 |
/* --------------------------------------------------------------------- */ |
| 207 |
/* Token endpoint */ |
| 208 |
/* --------------------------------------------------------------------- */ |
| 209 |
|
| 210 |
/** |
| 211 |
* Handle a token request (authorization_code or refresh_token grant). |
| 212 |
* |
| 213 |
* @param array $params Token request body. |
| 214 |
* @return array|\WP_Error Token response or error. |
| 215 |
*/ |
| 216 |
public function handle_token_request( $params ) { |
| 217 |
$grant_type = isset( $params['grant_type'] ) ? sanitize_text_field( $params['grant_type'] ) : ''; |
| 218 |
|
| 219 |
if ( 'authorization_code' === $grant_type ) { |
| 220 |
return $this->grant_authorization_code( $params ); |
| 221 |
} |
| 222 |
if ( 'refresh_token' === $grant_type ) { |
| 223 |
return $this->grant_refresh_token( $params ); |
| 224 |
} |
| 225 |
return new \WP_Error( 'unsupported_grant_type', __( 'Unsupported grant type.', 'notificationx' ), array( 'status' => 400 ) ); |
| 226 |
} |
| 227 |
|
| 228 |
/** |
| 229 |
* Exchange an authorization code (+ PKCE verifier) for tokens. |
| 230 |
* |
| 231 |
* @param array $params Token request body. |
| 232 |
* @return array|\WP_Error |
| 233 |
*/ |
| 234 |
protected function grant_authorization_code( $params ) { |
| 235 |
$code = isset( $params['code'] ) ? (string) $params['code'] : ''; |
| 236 |
$client_id = isset( $params['client_id'] ) ? sanitize_text_field( $params['client_id'] ) : ''; |
| 237 |
$redirect_uri = isset( $params['redirect_uri'] ) ? $this->clean_redirect_uri( $params['redirect_uri'] ) : ''; |
| 238 |
$verifier = isset( $params['code_verifier'] ) ? (string) $params['code_verifier'] : ''; |
| 239 |
|
| 240 |
$store = $this->store(); |
| 241 |
$code_key = $this->hash( $code ); |
| 242 |
$record = isset( $store['codes'][ $code_key ] ) ? $store['codes'][ $code_key ] : null; |
| 243 |
|
| 244 |
// Single use: delete on lookup regardless of outcome. |
| 245 |
if ( $record ) { |
| 246 |
unset( $store['codes'][ $code_key ] ); |
| 247 |
$this->save( $store ); |
| 248 |
} |
| 249 |
|
| 250 |
if ( ! $record || $record['expires'] < time() ) { |
| 251 |
return new \WP_Error( 'invalid_grant', __( 'Authorization code is invalid or expired.', 'notificationx' ), array( 'status' => 400 ) ); |
| 252 |
} |
| 253 |
if ( $record['client_id'] !== $client_id || $record['redirect_uri'] !== $redirect_uri ) { |
| 254 |
return new \WP_Error( 'invalid_grant', __( 'Authorization code does not match this client.', 'notificationx' ), array( 'status' => 400 ) ); |
| 255 |
} |
| 256 |
|
| 257 |
// PKCE: BASE64URL(SHA256(verifier)) === stored challenge. |
| 258 |
$computed = $this->base64url( hash( 'sha256', $verifier, true ) ); |
| 259 |
if ( '' === $verifier || ! hash_equals( $record['code_challenge'], $computed ) ) { |
| 260 |
return new \WP_Error( 'invalid_grant', __( 'PKCE verification failed.', 'notificationx' ), array( 'status' => 400 ) ); |
| 261 |
} |
| 262 |
|
| 263 |
return $this->issue_tokens( $record['user_id'], $client_id, $record['scope'] ); |
| 264 |
} |
| 265 |
|
| 266 |
/** |
| 267 |
* Rotate a refresh token for a new access/refresh pair. |
| 268 |
* |
| 269 |
* @param array $params Token request body. |
| 270 |
* @return array|\WP_Error |
| 271 |
*/ |
| 272 |
protected function grant_refresh_token( $params ) { |
| 273 |
$refresh = isset( $params['refresh_token'] ) ? (string) $params['refresh_token'] : ''; |
| 274 |
$client_id = isset( $params['client_id'] ) ? sanitize_text_field( $params['client_id'] ) : ''; |
| 275 |
|
| 276 |
$store = $this->store(); |
| 277 |
$key = $this->hash( $refresh ); |
| 278 |
$record = isset( $store['refresh'][ $key ] ) ? $store['refresh'][ $key ] : null; |
| 279 |
|
| 280 |
if ( $record ) { |
| 281 |
unset( $store['refresh'][ $key ] ); |
| 282 |
$this->save( $store ); |
| 283 |
} |
| 284 |
|
| 285 |
if ( ! $record || $record['expires'] < time() || $record['client_id'] !== $client_id ) { |
| 286 |
return new \WP_Error( 'invalid_grant', __( 'Refresh token is invalid or expired.', 'notificationx' ), array( 'status' => 400 ) ); |
| 287 |
} |
| 288 |
|
| 289 |
return $this->issue_tokens( $record['user_id'], $client_id, $record['scope'] ); |
| 290 |
} |
| 291 |
|
| 292 |
/** |
| 293 |
* Mint and store a new access + refresh token pair (hashed at rest). |
| 294 |
* |
| 295 |
* @param int $user_id Bound admin user id. |
| 296 |
* @param string $client_id Client id. |
| 297 |
* @param string $scope Space-separated scope string. |
| 298 |
* @return array Token response. |
| 299 |
*/ |
| 300 |
protected function issue_tokens( $user_id, $client_id, $scope ) { |
| 301 |
$access = bin2hex( random_bytes( 32 ) ); |
| 302 |
$refresh = bin2hex( random_bytes( 32 ) ); |
| 303 |
|
| 304 |
$store = $this->store(); |
| 305 |
$this->prune_tokens( $store ); |
| 306 |
|
| 307 |
$store['tokens'][ $this->hash( $access ) ] = array( |
| 308 |
'user_id' => (int) $user_id, |
| 309 |
'client_id' => $client_id, |
| 310 |
'scope' => $scope, |
| 311 |
'expires' => time() + self::ACCESS_TTL, |
| 312 |
); |
| 313 |
$store['refresh'][ $this->hash( $refresh ) ] = array( |
| 314 |
'user_id' => (int) $user_id, |
| 315 |
'client_id' => $client_id, |
| 316 |
'scope' => $scope, |
| 317 |
'expires' => time() + self::REFRESH_TTL, |
| 318 |
); |
| 319 |
$this->save( $store ); |
| 320 |
|
| 321 |
return array( |
| 322 |
'access_token' => $access, |
| 323 |
'token_type' => 'Bearer', |
| 324 |
'expires_in' => self::ACCESS_TTL, |
| 325 |
'refresh_token' => $refresh, |
| 326 |
'scope' => $scope, |
| 327 |
); |
| 328 |
} |
| 329 |
|
| 330 |
/** |
| 331 |
* Validate a bearer access token. |
| 332 |
* |
| 333 |
* @param string $token Presented access token. |
| 334 |
* @return array|false Grant record on success, false otherwise. |
| 335 |
*/ |
| 336 |
public function validate_token( $token ) { |
| 337 |
if ( '' === (string) $token ) { |
| 338 |
return false; |
| 339 |
} |
| 340 |
$store = $this->store(); |
| 341 |
$record = isset( $store['tokens'][ $this->hash( $token ) ] ) ? $store['tokens'][ $this->hash( $token ) ] : null; |
| 342 |
if ( ! $record || $record['expires'] < time() ) { |
| 343 |
return false; |
| 344 |
} |
| 345 |
return $record; |
| 346 |
} |
| 347 |
|
| 348 |
/** |
| 349 |
* Whether a scope string grants only read access. |
| 350 |
* |
| 351 |
* @param string $scope Space-separated scope string. |
| 352 |
* @return bool |
| 353 |
*/ |
| 354 |
public function scope_is_read_only( $scope ) { |
| 355 |
$scopes = preg_split( '/\s+/', trim( (string) $scope ) ); |
| 356 |
return ! in_array( 'write', $scopes, true ) && ! in_array( 'mcp', $scopes, true ); |
| 357 |
} |
| 358 |
|
| 359 |
/** |
| 360 |
* Revoke every issued token, refresh token and code. Used by disconnect. |
| 361 |
* |
| 362 |
* @return void |
| 363 |
*/ |
| 364 |
public function revoke_all() { |
| 365 |
$store = $this->store(); |
| 366 |
$store['tokens'] = array(); |
| 367 |
$store['refresh'] = array(); |
| 368 |
$store['codes'] = array(); |
| 369 |
$this->save( $store ); |
| 370 |
} |
| 371 |
|
| 372 |
/** |
| 373 |
* List OAuth clients that currently hold a live access or refresh token, |
| 374 |
* for the "Connected apps" admin list. |
| 375 |
* |
| 376 |
* @return array[] Each: { client_id, name, scope, read_only, last_seen }. |
| 377 |
*/ |
| 378 |
public function list_active_clients() { |
| 379 |
$store = $this->store(); |
| 380 |
$this->prune_tokens( $store ); |
| 381 |
|
| 382 |
$by_client = array(); |
| 383 |
foreach ( array( 'tokens', 'refresh' ) as $bucket ) { |
| 384 |
foreach ( $store[ $bucket ] as $record ) { |
| 385 |
$cid = isset( $record['client_id'] ) ? $record['client_id'] : ''; |
| 386 |
if ( '' === $cid ) { |
| 387 |
continue; |
| 388 |
} |
| 389 |
if ( ! isset( $by_client[ $cid ] ) ) { |
| 390 |
$name = isset( $store['clients'][ $cid ]['client_name'] ) && $store['clients'][ $cid ]['client_name'] |
| 391 |
? $store['clients'][ $cid ]['client_name'] |
| 392 |
: $cid; |
| 393 |
$by_client[ $cid ] = array( |
| 394 |
'client_id' => $cid, |
| 395 |
'name' => $name, |
| 396 |
'scope' => isset( $record['scope'] ) ? $record['scope'] : 'read', |
| 397 |
'read_only' => $this->scope_is_read_only( isset( $record['scope'] ) ? $record['scope'] : '' ), |
| 398 |
); |
| 399 |
} |
| 400 |
} |
| 401 |
} |
| 402 |
|
| 403 |
return array_values( $by_client ); |
| 404 |
} |
| 405 |
|
| 406 |
/** |
| 407 |
* Revoke all tokens issued to a single OAuth client. |
| 408 |
* |
| 409 |
* @param string $client_id Client id. |
| 410 |
* @return void |
| 411 |
*/ |
| 412 |
public function revoke_client( $client_id ) { |
| 413 |
$store = $this->store(); |
| 414 |
foreach ( array( 'tokens', 'refresh', 'codes' ) as $bucket ) { |
| 415 |
foreach ( $store[ $bucket ] as $key => $record ) { |
| 416 |
if ( isset( $record['client_id'] ) && $record['client_id'] === $client_id ) { |
| 417 |
unset( $store[ $bucket ][ $key ] ); |
| 418 |
} |
| 419 |
} |
| 420 |
} |
| 421 |
$this->save( $store ); |
| 422 |
} |
| 423 |
|
| 424 |
/* --------------------------------------------------------------------- */ |
| 425 |
/* Storage helpers */ |
| 426 |
/* --------------------------------------------------------------------- */ |
| 427 |
|
| 428 |
/** |
| 429 |
* Load the OAuth store, pruning expired entries lazily. |
| 430 |
* |
| 431 |
* @return array |
| 432 |
*/ |
| 433 |
protected function store() { |
| 434 |
$store = get_option( self::OPTION, array() ); |
| 435 |
if ( ! is_array( $store ) ) { |
| 436 |
$store = array(); |
| 437 |
} |
| 438 |
$store += array( |
| 439 |
'clients' => array(), |
| 440 |
'codes' => array(), |
| 441 |
'tokens' => array(), |
| 442 |
'refresh' => array(), |
| 443 |
); |
| 444 |
return $store; |
| 445 |
} |
| 446 |
|
| 447 |
/** |
| 448 |
* Persist the OAuth store. |
| 449 |
* |
| 450 |
* @param array $store Store array. |
| 451 |
* @return void |
| 452 |
*/ |
| 453 |
protected function save( $store ) { |
| 454 |
update_option( self::OPTION, $store, false ); |
| 455 |
} |
| 456 |
|
| 457 |
/** |
| 458 |
* Drop expired tokens/refresh/codes. |
| 459 |
* |
| 460 |
* @param array $store Store array (by reference). |
| 461 |
* @return void |
| 462 |
*/ |
| 463 |
protected function prune_tokens( &$store ) { |
| 464 |
$now = time(); |
| 465 |
foreach ( array( 'tokens', 'refresh', 'codes' ) as $bucket ) { |
| 466 |
foreach ( $store[ $bucket ] as $key => $record ) { |
| 467 |
if ( empty( $record['expires'] ) || $record['expires'] < $now ) { |
| 468 |
unset( $store[ $bucket ][ $key ] ); |
| 469 |
} |
| 470 |
} |
| 471 |
} |
| 472 |
} |
| 473 |
|
| 474 |
/** |
| 475 |
* Evict old, unused registered clients. |
| 476 |
* |
| 477 |
* @param array $store Store array (by reference). |
| 478 |
* @return void |
| 479 |
*/ |
| 480 |
protected function prune_clients( &$store ) { |
| 481 |
$now = time(); |
| 482 |
foreach ( $store['clients'] as $client_id => $client ) { |
| 483 |
if ( ! empty( $client['created_at'] ) && ( $now - $client['created_at'] ) > self::CLIENT_TTL ) { |
| 484 |
// Keep clients that still have live tokens. |
| 485 |
$has_token = false; |
| 486 |
foreach ( array( 'tokens', 'refresh' ) as $bucket ) { |
| 487 |
foreach ( $store[ $bucket ] as $record ) { |
| 488 |
if ( isset( $record['client_id'] ) && $record['client_id'] === $client_id ) { |
| 489 |
$has_token = true; |
| 490 |
break 2; |
| 491 |
} |
| 492 |
} |
| 493 |
} |
| 494 |
if ( ! $has_token ) { |
| 495 |
unset( $store['clients'][ $client_id ] ); |
| 496 |
} |
| 497 |
} |
| 498 |
} |
| 499 |
} |
| 500 |
|
| 501 |
/** |
| 502 |
* SHA-256 hash used for token storage. |
| 503 |
* |
| 504 |
* @param string $value Raw value. |
| 505 |
* @return string |
| 506 |
*/ |
| 507 |
protected function hash( $value ) { |
| 508 |
return hash( 'sha256', (string) $value ); |
| 509 |
} |
| 510 |
|
| 511 |
/** |
| 512 |
* Base64url encoding (no padding). |
| 513 |
* |
| 514 |
* @param string $data Raw bytes. |
| 515 |
* @return string |
| 516 |
*/ |
| 517 |
protected function base64url( $data ) { |
| 518 |
return rtrim( strtr( base64_encode( $data ), '+/', '-_' ), '=' ); |
| 519 |
} |
| 520 |
|
| 521 |
/** |
| 522 |
* Validate and normalise a redirect URI. |
| 523 |
* |
| 524 |
* @param string $uri Candidate redirect URI. |
| 525 |
* @return string Cleaned URI or empty string if invalid. |
| 526 |
*/ |
| 527 |
protected function clean_redirect_uri( $uri ) { |
| 528 |
$uri = esc_url_raw( trim( (string) $uri ), array( 'https', 'http' ) ); |
| 529 |
return $uri ? $uri : ''; |
| 530 |
} |
| 531 |
} |
| 532 |
|