| 1 |
<?php |
| 2 |
/** |
| 3 |
* OAuth 2.1 authorization server for the BetterDocs MCP endpoint. |
| 4 |
* |
| 5 |
* @package BetterDocs |
| 6 |
* @since 4.9.0 |
| 7 |
*/ |
| 8 |
|
| 9 |
namespace WPDeveloper\BetterDocs\Mcp; |
| 10 |
|
| 11 |
if ( ! defined( 'ABSPATH' ) ) { |
| 12 |
exit; // Exit if accessed directly. |
| 13 |
} |
| 14 |
|
| 15 |
/** |
| 16 |
* The "paste the site URL and nothing else" connection path. |
| 17 |
* |
| 18 |
* A pairing token covers clients that accept a Bearer token you paste in. |
| 19 |
* This class covers the spec-compliant ones — Claude's remote connectors, ChatGPT |
| 20 |
* — which are given only the MCP endpoint URL and run the OAuth 2.1 |
| 21 |
* authorization-code + PKCE flow themselves: |
| 22 |
* |
| 23 |
* unauthenticated MCP call → 401 + `WWW-Authenticate` → client fetches |
| 24 |
* `/.well-known/oauth-protected-resource` and `…/oauth-authorization-server` → |
| 25 |
* dynamic registration (RFC 7591) → `/betterdocs/authorize` (a person consents, |
| 26 |
* with PKCE) → `/token` (code + verifier → access + refresh) → MCP calls with |
| 27 |
* `Authorization: Bearer …`, validated by {@see self::validate_token()}. |
| 28 |
* |
| 29 |
* The security contract, which the rest of the MCP layer relies on: |
| 30 |
* |
| 31 |
* - **PKCE S256 is required.** OAuth 2.1 has no implicit grant and no plain |
| 32 |
* challenge; a request without S256 is refused at `/authorize`, before anything |
| 33 |
* is issued. |
| 34 |
* - **Codes are single-use, 60 seconds, and bound** to client id, redirect URI, |
| 35 |
* challenge and the approving user. The code is burned *before* verification, |
| 36 |
* so a failed attempt cannot be retried. |
| 37 |
* - **Only hashes are stored.** Authorization codes, access tokens and refresh |
| 38 |
* tokens exist in the clear exactly once — in the redirect back to the client |
| 39 |
* and in the `/token` response; the option holds SHA-256 and nothing else, so a |
| 40 |
* database dump yields no usable credential (ADR-037). Lookups are by hash and |
| 41 |
* comparisons are `hash_equals()`. |
| 42 |
* - **Refresh rotates.** A refresh grant drops the old refresh token *and* the |
| 43 |
* access token it minted, so a stolen refresh token is detectable by the real |
| 44 |
* client suddenly failing. |
| 45 |
* - **Scope decides read vs write** — a `read`-only grant refuses every write |
| 46 |
* tool, exactly like a read-only pairing token (ADR-017). |
| 47 |
* |
| 48 |
* Who may consent is *not* decided here: the consent screen gates on |
| 49 |
* `edit_docs` (ADR-006), and every ability re-checks its own capability on every |
| 50 |
* call, so a grant can never exceed what the granting user could do themselves. |
| 51 |
* |
| 52 |
* All state lives in one non-autoloaded option, pruned lazily on every read. |
| 53 |
* |
| 54 |
* @since 4.9.0 |
| 55 |
*/ |
| 56 |
final class MCPOAuth { |
| 57 |
|
| 58 |
/** |
| 59 |
* Option holding every OAuth server record. |
| 60 |
* |
| 61 |
* @since 4.9.0 |
| 62 |
*/ |
| 63 |
const OPTION = 'betterdocs_mcp_oauth'; |
| 64 |
|
| 65 |
/** |
| 66 |
* Authorization-code lifetime, in seconds. Deliberately short — a real |
| 67 |
* client exchanges within a second or two. |
| 68 |
* |
| 69 |
* @since 4.9.0 |
| 70 |
*/ |
| 71 |
const CODE_TTL = 60; |
| 72 |
|
| 73 |
/** |
| 74 |
* Access-token lifetime, in seconds. One hour, refreshable. |
| 75 |
* |
| 76 |
* @since 4.9.0 |
| 77 |
*/ |
| 78 |
const ACCESS_TTL = 3600; |
| 79 |
|
| 80 |
/** |
| 81 |
* Refresh-token lifetime, in seconds. Thirty days. |
| 82 |
* |
| 83 |
* @since 4.9.0 |
| 84 |
*/ |
| 85 |
const REFRESH_TTL = 2592000; |
| 86 |
|
| 87 |
/** |
| 88 |
* Scopes advertised and honoured. `mcp` is the umbrella scope MCP clients |
| 89 |
* ask for; it means read **and** write. |
| 90 |
* |
| 91 |
* @since 4.9.0 |
| 92 |
*/ |
| 93 |
const SUPPORTED_SCOPES = [ 'mcp', 'read', 'write' ]; |
| 94 |
|
| 95 |
/** |
| 96 |
* Seconds between `last_used` writes for one client. A busy connector would |
| 97 |
* otherwise turn every MCP call into a database write. |
| 98 |
* |
| 99 |
* @since 4.9.0 |
| 100 |
*/ |
| 101 |
const LAST_USED_THROTTLE = 60; |
| 102 |
|
| 103 |
/** |
| 104 |
* How many client registrations to keep. |
| 105 |
* |
| 106 |
* RFC 7591 registration is necessarily open — a client has to register |
| 107 |
* before it can hold any credential — so without a cap anyone on the |
| 108 |
* internet can grow this option without bound, and every read pays for it. |
| 109 |
* A client holding a live grant is never evicted, so the cap only ever |
| 110 |
* discards abandoned registrations. |
| 111 |
* |
| 112 |
* @since 4.9.0 |
| 113 |
*/ |
| 114 |
const MAX_CLIENTS = 50; |
| 115 |
|
| 116 |
/** |
| 117 |
* How long an unused registration survives, in seconds. A client that |
| 118 |
* registers and never completes consent has abandoned the flow; a real one |
| 119 |
* exchanges a code within the minute. |
| 120 |
* |
| 121 |
* @since 4.9.0 |
| 122 |
*/ |
| 123 |
const CLIENT_TTL = 86400; |
| 124 |
|
| 125 |
/** |
| 126 |
* The OAuth issuer. |
| 127 |
* |
| 128 |
* Path-based, which RFC 8414 §2 allows: using the MCP endpoint URL itself |
| 129 |
* means clients derive the path-suffixed well-known URLs |
| 130 |
* (`/.well-known/oauth-authorization-server/betterdocs/mcp`) and stay |
| 131 |
* specific to BetterDocs even when another plugin runs its own MCP OAuth |
| 132 |
* server at the same site root. |
| 133 |
* |
| 134 |
* @since 4.9.0 |
| 135 |
* |
| 136 |
* @return string |
| 137 |
*/ |
| 138 |
public static function issuer() { |
| 139 |
return untrailingslashit( home_url( '/betterdocs/mcp' ) ); |
| 140 |
} |
| 141 |
|
| 142 |
/** |
| 143 |
* The protected resource identifier — the MCP endpoint URL. |
| 144 |
* |
| 145 |
* `MCPPairing` owns the endpoint's address, and the issuer is the same |
| 146 |
* string by construction. |
| 147 |
* |
| 148 |
* @since 4.9.0 |
| 149 |
* |
| 150 |
* @return string |
| 151 |
*/ |
| 152 |
public static function resource() { |
| 153 |
if ( class_exists( __NAMESPACE__ . '\\MCPPairing' ) && method_exists( __NAMESPACE__ . '\\MCPPairing', 'site_endpoint' ) ) { |
| 154 |
return MCPPairing::site_endpoint(); |
| 155 |
} |
| 156 |
|
| 157 |
return self::issuer(); |
| 158 |
} |
| 159 |
|
| 160 |
/** |
| 161 |
* The browser-facing consent page. |
| 162 |
* |
| 163 |
* Served outside the REST API, through a rewrite, so ordinary cookie |
| 164 |
* authentication works after the wp-login round trip. A REST route would see |
| 165 |
* the cookie without a nonce, treat the visitor as logged out, and loop back |
| 166 |
* to the login screen. |
| 167 |
* |
| 168 |
* @since 4.9.0 |
| 169 |
* |
| 170 |
* @return string |
| 171 |
*/ |
| 172 |
public static function authorize_url() { |
| 173 |
return home_url( '/betterdocs/authorize' ); |
| 174 |
} |
| 175 |
|
| 176 |
/** |
| 177 |
* The token endpoint. |
| 178 |
* |
| 179 |
* @since 4.9.0 |
| 180 |
* |
| 181 |
* @return string |
| 182 |
*/ |
| 183 |
public static function token_url() { |
| 184 |
return rest_url( 'betterdocs/v1/mcp/oauth/token' ); |
| 185 |
} |
| 186 |
|
| 187 |
/** |
| 188 |
* The dynamic client registration endpoint. |
| 189 |
* |
| 190 |
* @since 4.9.0 |
| 191 |
* |
| 192 |
* @return string |
| 193 |
*/ |
| 194 |
public static function register_url() { |
| 195 |
return rest_url( 'betterdocs/v1/mcp/oauth/register' ); |
| 196 |
} |
| 197 |
|
| 198 |
/** |
| 199 |
* Where the 401 challenge points for protected-resource metadata. |
| 200 |
* |
| 201 |
* The REST alias rather than the `/.well-known/` path, because a |
| 202 |
* non-trivial number of hosts, security plugins and CDNs intercept |
| 203 |
* `/.well-known/` for ACME and never let WordPress see it (ADR-014). Both |
| 204 |
* forms are served; this is the one advertised, and it is filterable for the |
| 205 |
* site where neither is reachable and the document has to come from |
| 206 |
* somewhere else entirely. |
| 207 |
* |
| 208 |
* @since 4.9.0 |
| 209 |
* |
| 210 |
* @return string |
| 211 |
*/ |
| 212 |
public static function resource_metadata_url() { |
| 213 |
/** |
| 214 |
* Filters the protected-resource metadata URL advertised in the |
| 215 |
* `WWW-Authenticate` challenge. |
| 216 |
* |
| 217 |
* @since 4.9.0 |
| 218 |
* |
| 219 |
* @param string $url The advertised URL. |
| 220 |
*/ |
| 221 |
return apply_filters( |
| 222 |
'betterdocs_mcp_resource_metadata_url', |
| 223 |
rest_url( 'betterdocs/v1/mcp/oauth/protected-resource' ) |
| 224 |
); |
| 225 |
} |
| 226 |
|
| 227 |
/** |
| 228 |
* RFC 9728 protected-resource metadata: which authorization server protects |
| 229 |
* the MCP endpoint. |
| 230 |
* |
| 231 |
* @since 4.9.0 |
| 232 |
* |
| 233 |
* @return array |
| 234 |
*/ |
| 235 |
public static function protected_resource_metadata() { |
| 236 |
return [ |
| 237 |
'resource' => self::resource(), |
| 238 |
'authorization_servers' => [ self::issuer() ], |
| 239 |
'scopes_supported' => self::SUPPORTED_SCOPES, |
| 240 |
'bearer_methods_supported' => [ 'header' ] |
| 241 |
]; |
| 242 |
} |
| 243 |
|
| 244 |
/** |
| 245 |
* RFC 8414 authorization-server metadata: the endpoint map, and only the |
| 246 |
* capabilities actually implemented. |
| 247 |
* |
| 248 |
* @since 4.9.0 |
| 249 |
* |
| 250 |
* @return array |
| 251 |
*/ |
| 252 |
public static function authorization_server_metadata() { |
| 253 |
return [ |
| 254 |
'issuer' => self::issuer(), |
| 255 |
'authorization_endpoint' => self::authorize_url(), |
| 256 |
'token_endpoint' => self::token_url(), |
| 257 |
'registration_endpoint' => self::register_url(), |
| 258 |
'scopes_supported' => self::SUPPORTED_SCOPES, |
| 259 |
'response_types_supported' => [ 'code' ], |
| 260 |
'grant_types_supported' => [ 'authorization_code', 'refresh_token' ], |
| 261 |
'code_challenge_methods_supported' => [ 'S256' ], |
| 262 |
'token_endpoint_auth_methods_supported' => [ 'none' ] |
| 263 |
]; |
| 264 |
} |
| 265 |
|
| 266 |
/** |
| 267 |
* Register a public client (RFC 7591). |
| 268 |
* |
| 269 |
* No client secret is issued: these are public clients and PKCE is what |
| 270 |
* binds the code to them. Everything in the request is attacker-controlled |
| 271 |
* and gets persisted, so the record is bounded on every axis — how many |
| 272 |
* redirect URIs, how long each may be, how long the name may be — and the |
| 273 |
* client list is pruned on the way in. |
| 274 |
* |
| 275 |
* @since 4.9.0 |
| 276 |
* |
| 277 |
* @param array $body Parsed JSON registration request. |
| 278 |
* @return array|\WP_Error The RFC 7591 registration response. |
| 279 |
*/ |
| 280 |
public static function register_client( array $body ) { |
| 281 |
$redirect_uris = isset( $body['redirect_uris'] ) && is_array( $body['redirect_uris'] ) |
| 282 |
? array_values( array_filter( array_map( 'strval', $body['redirect_uris'] ), [ self::class, 'is_valid_redirect_uri' ] ) ) |
| 283 |
: []; |
| 284 |
|
| 285 |
$redirect_uris = array_values( |
| 286 |
array_unique( |
| 287 |
array_filter( |
| 288 |
$redirect_uris, |
| 289 |
static function ( $uri ) { |
| 290 |
return strlen( $uri ) <= 2048; |
| 291 |
} |
| 292 |
) |
| 293 |
) |
| 294 |
); |
| 295 |
$redirect_uris = array_slice( $redirect_uris, 0, 5 ); |
| 296 |
|
| 297 |
if ( empty( $redirect_uris ) ) { |
| 298 |
return new \WP_Error( |
| 299 |
'invalid_redirect_uri', |
| 300 |
__( 'At least one valid redirect_uri is required.', 'betterdocs' ), |
| 301 |
[ 'status' => 400 ] |
| 302 |
); |
| 303 |
} |
| 304 |
|
| 305 |
$name = isset( $body['client_name'] ) ? sanitize_text_field( (string) $body['client_name'] ) : __( 'MCP Client', 'betterdocs' ); |
| 306 |
|
| 307 |
if ( strlen( $name ) > 128 ) { |
| 308 |
$name = substr( $name, 0, 128 ); |
| 309 |
} |
| 310 |
|
| 311 |
$client_id = 'bd_' . bin2hex( random_bytes( 16 ) ); |
| 312 |
|
| 313 |
$state = self::state(); |
| 314 |
$state['clients'][ $client_id ] = [ |
| 315 |
'redirect_uris' => $redirect_uris, |
| 316 |
'name' => $name, |
| 317 |
'created' => time() |
| 318 |
]; |
| 319 |
$state['clients'] = self::prune_clients( $state ); |
| 320 |
|
| 321 |
self::save( $state ); |
| 322 |
|
| 323 |
return [ |
| 324 |
'client_id' => $client_id, |
| 325 |
'client_id_issued_at' => time(), |
| 326 |
'redirect_uris' => $redirect_uris, |
| 327 |
'client_name' => $name, |
| 328 |
'token_endpoint_auth_method' => 'none', |
| 329 |
'grant_types' => [ 'authorization_code', 'refresh_token' ], |
| 330 |
'response_types' => [ 'code' ] |
| 331 |
]; |
| 332 |
} |
| 333 |
|
| 334 |
/** |
| 335 |
* Check an `/authorize` request without issuing anything. |
| 336 |
* |
| 337 |
* Returns a sanitised parameter bag, or a `WP_Error`. Whether an error may |
| 338 |
* be reported back to the client by redirect is in the error's |
| 339 |
* `redirectable` data: a bad `redirect_uri` or an unknown client must **not** |
| 340 |
* redirect, because at that point the destination is not one this site has |
| 341 |
* ever trusted — that is the open-redirect guard. |
| 342 |
* |
| 343 |
* @since 4.9.0 |
| 344 |
* |
| 345 |
* @param array $params Query parameters. |
| 346 |
* @return array|\WP_Error |
| 347 |
*/ |
| 348 |
public static function validate_authorize_request( array $params ) { |
| 349 |
$client_id = isset( $params['client_id'] ) ? (string) $params['client_id'] : ''; |
| 350 |
$redirect_uri = isset( $params['redirect_uri'] ) ? (string) $params['redirect_uri'] : ''; |
| 351 |
$response_type = isset( $params['response_type'] ) ? (string) $params['response_type'] : ''; |
| 352 |
$challenge = isset( $params['code_challenge'] ) ? (string) $params['code_challenge'] : ''; |
| 353 |
$method = isset( $params['code_challenge_method'] ) ? (string) $params['code_challenge_method'] : ''; |
| 354 |
$scope = isset( $params['scope'] ) ? (string) $params['scope'] : 'mcp'; |
| 355 |
$state = isset( $params['state'] ) ? (string) $params['state'] : ''; |
| 356 |
|
| 357 |
$client = self::client( $client_id ); |
| 358 |
|
| 359 |
if ( null === $client ) { |
| 360 |
return new \WP_Error( 'invalid_client', __( 'Unknown client_id.', 'betterdocs' ), [ 'status' => 400 ] ); |
| 361 |
} |
| 362 |
|
| 363 |
if ( ! in_array( $redirect_uri, $client['redirect_uris'], true ) ) { |
| 364 |
return new \WP_Error( 'invalid_redirect_uri', __( 'redirect_uri does not match a registered value.', 'betterdocs' ), [ 'status' => 400 ] ); |
| 365 |
} |
| 366 |
|
| 367 |
if ( 'code' !== $response_type ) { |
| 368 |
return new \WP_Error( |
| 369 |
'unsupported_response_type', |
| 370 |
__( 'Only response_type=code is supported.', 'betterdocs' ), |
| 371 |
[ |
| 372 |
'status' => 400, |
| 373 |
'redirectable' => true |
| 374 |
] |
| 375 |
); |
| 376 |
} |
| 377 |
|
| 378 |
// OAuth 2.1: PKCE with S256 is mandatory for public clients. `plain` |
| 379 |
// is refused too — it protects nothing against an attacker who can see |
| 380 |
// the authorization request. |
| 381 |
if ( 'S256' !== $method || '' === $challenge ) { |
| 382 |
return new \WP_Error( |
| 383 |
'invalid_request', |
| 384 |
__( 'PKCE with code_challenge_method=S256 is required.', 'betterdocs' ), |
| 385 |
[ |
| 386 |
'status' => 400, |
| 387 |
'redirectable' => true |
| 388 |
] |
| 389 |
); |
| 390 |
} |
| 391 |
|
| 392 |
return [ |
| 393 |
'client_id' => $client_id, |
| 394 |
'client_name' => $client['name'], |
| 395 |
'redirect_uri' => $redirect_uri, |
| 396 |
'code_challenge' => $challenge, |
| 397 |
'scope' => self::normalize_scope( $scope ), |
| 398 |
'state' => $state |
| 399 |
]; |
| 400 |
} |
| 401 |
|
| 402 |
/** |
| 403 |
* Issue an authorization code, once a person has consented. |
| 404 |
* |
| 405 |
* Bound to the client, the redirect URI, the PKCE challenge, the granted |
| 406 |
* scope and the approving user. Single-use, 60 seconds. |
| 407 |
* |
| 408 |
* The record is keyed by `hash( 'sha256', $code )`, never by the code |
| 409 |
* itself, so the option holds no usable credential even during that minute |
| 410 |
* (ADR-037). The caller receives the only plaintext copy. |
| 411 |
* |
| 412 |
* @since 4.9.0 |
| 413 |
* |
| 414 |
* @param array $req Output of {@see self::validate_authorize_request()}. |
| 415 |
* @param int $user_id The user who approved. |
| 416 |
* @return string The authorization code. |
| 417 |
*/ |
| 418 |
public static function issue_code( array $req, $user_id ) { |
| 419 |
$code = bin2hex( random_bytes( 32 ) ); |
| 420 |
$state = self::state(); |
| 421 |
|
| 422 |
$state['codes'][ self::hash( $code ) ] = [ |
| 423 |
'client_id' => isset( $req['client_id'] ) ? (string) $req['client_id'] : '', |
| 424 |
'redirect_uri' => isset( $req['redirect_uri'] ) ? (string) $req['redirect_uri'] : '', |
| 425 |
'challenge' => isset( $req['code_challenge'] ) ? (string) $req['code_challenge'] : '', |
| 426 |
'scope' => isset( $req['scope'] ) ? (string) $req['scope'] : 'mcp', |
| 427 |
'user_id' => (int) $user_id, |
| 428 |
'expires' => time() + self::CODE_TTL |
| 429 |
]; |
| 430 |
|
| 431 |
self::save( $state ); |
| 432 |
|
| 433 |
return $code; |
| 434 |
} |
| 435 |
|
| 436 |
/** |
| 437 |
* The token endpoint's two grants. |
| 438 |
* |
| 439 |
* @since 4.9.0 |
| 440 |
* |
| 441 |
* @param array $body POST body parameters. |
| 442 |
* @return array|\WP_Error RFC 6749 token response, or an OAuth error. |
| 443 |
*/ |
| 444 |
public static function exchange_token( array $body ) { |
| 445 |
$grant = isset( $body['grant_type'] ) ? (string) $body['grant_type'] : ''; |
| 446 |
|
| 447 |
if ( 'authorization_code' === $grant ) { |
| 448 |
return self::grant_authorization_code( $body ); |
| 449 |
} |
| 450 |
|
| 451 |
if ( 'refresh_token' === $grant ) { |
| 452 |
return self::grant_refresh_token( $body ); |
| 453 |
} |
| 454 |
|
| 455 |
return self::oauth_error( 'unsupported_grant_type', __( 'Unsupported grant_type.', 'betterdocs' ) ); |
| 456 |
} |
| 457 |
|
| 458 |
/** |
| 459 |
* Validate a bearer access token presented to the MCP endpoint. |
| 460 |
* |
| 461 |
* @since 4.9.0 |
| 462 |
* |
| 463 |
* @param string $token Raw access token from the Authorization header. |
| 464 |
* @return array|null `{client_id, scope, user_id}` when valid, else null. |
| 465 |
*/ |
| 466 |
public static function validate_token( $token ) { |
| 467 |
$token = (string) $token; |
| 468 |
|
| 469 |
if ( '' === $token ) { |
| 470 |
return null; |
| 471 |
} |
| 472 |
|
| 473 |
$state = self::state(); |
| 474 |
$hash = self::hash( $token ); |
| 475 |
|
| 476 |
if ( ! isset( $state['tokens'][ $hash ] ) ) { |
| 477 |
return null; |
| 478 |
} |
| 479 |
|
| 480 |
$entry = $state['tokens'][ $hash ]; |
| 481 |
|
| 482 |
if ( (int) $entry['expires'] < time() ) { |
| 483 |
return null; |
| 484 |
} |
| 485 |
|
| 486 |
// Record activity against the client, so "Connected apps" can show a |
| 487 |
// last-used date. Throttled, and kept on the client record so it |
| 488 |
// survives access-token rotation. |
| 489 |
$client_id = (string) $entry['client_id']; |
| 490 |
$now = time(); |
| 491 |
|
| 492 |
if ( isset( $state['clients'][ $client_id ] ) && is_array( $state['clients'][ $client_id ] ) ) { |
| 493 |
$last = isset( $state['clients'][ $client_id ]['last_used'] ) ? (int) $state['clients'][ $client_id ]['last_used'] : 0; |
| 494 |
|
| 495 |
if ( $now - $last >= self::LAST_USED_THROTTLE ) { |
| 496 |
$state['clients'][ $client_id ]['last_used'] = $now; |
| 497 |
self::save( $state ); |
| 498 |
} |
| 499 |
} |
| 500 |
|
| 501 |
return [ |
| 502 |
'client_id' => $client_id, |
| 503 |
'scope' => (string) $entry['scope'], |
| 504 |
'user_id' => (int) $entry['user_id'] |
| 505 |
]; |
| 506 |
} |
| 507 |
|
| 508 |
/** |
| 509 |
* Whether a granted scope is read-only. |
| 510 |
* |
| 511 |
* `mcp` is the umbrella scope and includes writing, so only a grant |
| 512 |
* carrying neither `write` nor `mcp` — `read` alone — is read-only. |
| 513 |
* |
| 514 |
* @since 4.9.0 |
| 515 |
* |
| 516 |
* @param string $scope Space-separated scope string. |
| 517 |
* @return bool |
| 518 |
*/ |
| 519 |
public static function scope_is_read_only( $scope ) { |
| 520 |
$parts = self::scope_parts( (string) $scope ); |
| 521 |
|
| 522 |
return ! in_array( 'write', $parts, true ) && ! in_array( 'mcp', $parts, true ); |
| 523 |
} |
| 524 |
|
| 525 |
/** |
| 526 |
* Drop every OAuth record: clients, codes, tokens, refresh grants. |
| 527 |
* |
| 528 |
* @since 4.9.0 |
| 529 |
* |
| 530 |
* @return void |
| 531 |
*/ |
| 532 |
public static function revoke_all() { |
| 533 |
delete_option( self::OPTION ); |
| 534 |
} |
| 535 |
|
| 536 |
/** |
| 537 |
* Cut one app off. |
| 538 |
* |
| 539 |
* Drops its access tokens, refresh tokens and pending codes, so it |
| 540 |
* disappears from {@see self::connected_apps()} immediately while every |
| 541 |
* other connection carries on. |
| 542 |
* |
| 543 |
* The registration itself is deliberately **kept**. MCP clients cache the |
| 544 |
* `client_id` from their first registration and reuse it when reconnecting |
| 545 |
* rather than registering afresh; deleting the record would answer that |
| 546 |
* reconnect with "Unknown client_id". Keeping it lets the app come back — |
| 547 |
* which still needs fresh consent and mints brand-new tokens, so revocation |
| 548 |
* loses nothing. |
| 549 |
* |
| 550 |
* @since 4.9.0 |
| 551 |
* |
| 552 |
* @param string $client_id Client to revoke. |
| 553 |
* @return bool Whether any live grant was removed. |
| 554 |
*/ |
| 555 |
public static function revoke_client( $client_id ) { |
| 556 |
$client_id = (string) $client_id; |
| 557 |
|
| 558 |
if ( '' === $client_id ) { |
| 559 |
return false; |
| 560 |
} |
| 561 |
|
| 562 |
return self::revoke_where( |
| 563 |
static function ( array $entry ) use ( $client_id ) { |
| 564 |
return isset( $entry['client_id'] ) && (string) $entry['client_id'] === $client_id; |
| 565 |
} |
| 566 |
); |
| 567 |
} |
| 568 |
|
| 569 |
/** |
| 570 |
* Drop every grant a given user approved. |
| 571 |
* |
| 572 |
* A grant can never exceed what the granting user may do, so when that user |
| 573 |
* is deleted or demoted their grants have to go with them — otherwise a |
| 574 |
* token keeps acting as a person who no longer has the capability, or no |
| 575 |
* longer exists. `MCPManager` hooks `deleted_user` and `set_user_role` here. |
| 576 |
* |
| 577 |
* The client registrations stay, for the same reason as |
| 578 |
* {@see self::revoke_client()}: another user may reconnect the same app. |
| 579 |
* |
| 580 |
* @since 4.9.0 |
| 581 |
* |
| 582 |
* @param int $user_id User whose grants to revoke. |
| 583 |
* @return bool Whether anything was removed. |
| 584 |
*/ |
| 585 |
public static function revoke_user( $user_id ) { |
| 586 |
$user_id = (int) $user_id; |
| 587 |
|
| 588 |
if ( $user_id <= 0 ) { |
| 589 |
return false; |
| 590 |
} |
| 591 |
|
| 592 |
return self::revoke_where( |
| 593 |
static function ( array $entry ) use ( $user_id ) { |
| 594 |
return isset( $entry['user_id'] ) && (int) $entry['user_id'] === $user_id; |
| 595 |
} |
| 596 |
); |
| 597 |
} |
| 598 |
|
| 599 |
/** |
| 600 |
* The apps currently holding a live grant, for the admin's "Connected apps" |
| 601 |
* list. |
| 602 |
* |
| 603 |
* A client counts as connected while it holds an unexpired refresh token — |
| 604 |
* the durable thirty-day grant — or an access token. One that registered but |
| 605 |
* never completed consent is not connected and is not listed. Newest first. |
| 606 |
* |
| 607 |
* Returns no hash and no token, ever: this feeds an admin screen, and there |
| 608 |
* is nothing here a person needs a credential to see. |
| 609 |
* |
| 610 |
* @since 4.9.0 |
| 611 |
* |
| 612 |
* @return array[] |
| 613 |
*/ |
| 614 |
public static function connected_apps() { |
| 615 |
$state = self::state(); |
| 616 |
|
| 617 |
// Refresh tokens are the durable grant, so they are read first; access |
| 618 |
// tokens fill in a client whose refresh has already expired. |
| 619 |
$active = []; |
| 620 |
|
| 621 |
foreach ( [ 'refresh', 'tokens' ] as $bucket ) { |
| 622 |
foreach ( $state[ $bucket ] as $entry ) { |
| 623 |
if ( ! is_array( $entry ) ) { |
| 624 |
continue; |
| 625 |
} |
| 626 |
|
| 627 |
$cid = isset( $entry['client_id'] ) ? (string) $entry['client_id'] : ''; |
| 628 |
|
| 629 |
if ( '' === $cid ) { |
| 630 |
continue; |
| 631 |
} |
| 632 |
|
| 633 |
$expires = isset( $entry['expires'] ) ? (int) $entry['expires'] : 0; |
| 634 |
|
| 635 |
if ( ! isset( $active[ $cid ] ) ) { |
| 636 |
$active[ $cid ] = [ |
| 637 |
'scope' => isset( $entry['scope'] ) ? (string) $entry['scope'] : 'mcp', |
| 638 |
'user_id' => isset( $entry['user_id'] ) ? (int) $entry['user_id'] : 0, |
| 639 |
'expires' => $expires |
| 640 |
]; |
| 641 |
|
| 642 |
continue; |
| 643 |
} |
| 644 |
|
| 645 |
// Several grants for one app: report the one that lasts longest. |
| 646 |
if ( $expires > $active[ $cid ]['expires'] ) { |
| 647 |
$active[ $cid ]['expires'] = $expires; |
| 648 |
} |
| 649 |
} |
| 650 |
} |
| 651 |
|
| 652 |
$apps = []; |
| 653 |
|
| 654 |
foreach ( $active as $cid => $info ) { |
| 655 |
$client = isset( $state['clients'][ $cid ] ) && is_array( $state['clients'][ $cid ] ) ? $state['clients'][ $cid ] : []; |
| 656 |
|
| 657 |
$apps[] = [ |
| 658 |
'client_id' => $cid, |
| 659 |
'name' => isset( $client['name'] ) ? (string) $client['name'] : __( 'MCP Client', 'betterdocs' ), |
| 660 |
'scope' => $info['scope'], |
| 661 |
'read_only' => self::scope_is_read_only( $info['scope'] ), |
| 662 |
'user_id' => $info['user_id'], |
| 663 |
'user_login' => self::user_login( $info['user_id'] ), |
| 664 |
// The one field an attacker cannot fake: client registration is |
| 665 |
// open (RFC 7591) and `name` is whatever the registrant typed, |
| 666 |
// so the admin page shows where a grant actually sends access. |
| 667 |
'redirect_uris' => isset( $client['redirect_uris'] ) && is_array( $client['redirect_uris'] ) |
| 668 |
? array_values( array_map( 'strval', $client['redirect_uris'] ) ) |
| 669 |
: [], |
| 670 |
'created' => isset( $client['created'] ) ? (int) $client['created'] : 0, |
| 671 |
'last_used' => isset( $client['last_used'] ) ? (int) $client['last_used'] : 0, |
| 672 |
'expires' => $info['expires'] |
| 673 |
]; |
| 674 |
} |
| 675 |
|
| 676 |
usort( |
| 677 |
$apps, |
| 678 |
static function ( array $a, array $b ) { |
| 679 |
return $b['created'] <=> $a['created']; |
| 680 |
} |
| 681 |
); |
| 682 |
|
| 683 |
return $apps; |
| 684 |
} |
| 685 |
|
| 686 |
/** |
| 687 |
* BASE64URL(SHA256(verifier)) — the PKCE S256 transformation. |
| 688 |
* |
| 689 |
* Public so the unit suite can pin it against RFC 7636's own vector: get |
| 690 |
* this wrong and every connection fails, or worse, succeeds without really |
| 691 |
* checking anything. |
| 692 |
* |
| 693 |
* @since 4.9.0 |
| 694 |
* |
| 695 |
* @param string $verifier PKCE code verifier. |
| 696 |
* @return string |
| 697 |
*/ |
| 698 |
public static function s256( $verifier ) { |
| 699 |
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- base64url is the encoding RFC 7636 defines for the S256 challenge; nothing here is being hidden. |
| 700 |
return rtrim( strtr( base64_encode( hash( 'sha256', (string) $verifier, true ) ), '+/', '-_' ), '=' ); |
| 701 |
} |
| 702 |
|
| 703 |
/** |
| 704 |
* The authorization_code grant: verify the code and PKCE, mint tokens. |
| 705 |
* |
| 706 |
* @since 4.9.0 |
| 707 |
* |
| 708 |
* @param array $body POST body. |
| 709 |
* @return array|\WP_Error |
| 710 |
*/ |
| 711 |
private static function grant_authorization_code( array $body ) { |
| 712 |
$code = isset( $body['code'] ) ? (string) $body['code'] : ''; |
| 713 |
$client_id = isset( $body['client_id'] ) ? (string) $body['client_id'] : ''; |
| 714 |
$redirect_uri = isset( $body['redirect_uri'] ) ? (string) $body['redirect_uri'] : ''; |
| 715 |
$verifier = isset( $body['code_verifier'] ) ? (string) $body['code_verifier'] : ''; |
| 716 |
|
| 717 |
$state = self::state(); |
| 718 |
$chash = self::hash( $code ); |
| 719 |
|
| 720 |
if ( '' === $code || ! isset( $state['codes'][ $chash ] ) ) { |
| 721 |
return self::oauth_error( 'invalid_grant', __( 'Unknown or expired authorization code.', 'betterdocs' ) ); |
| 722 |
} |
| 723 |
|
| 724 |
$entry = $state['codes'][ $chash ]; |
| 725 |
|
| 726 |
// Single-use, and burned *before* verification: a code that failed |
| 727 |
// PKCE must not be available for a second attempt. |
| 728 |
unset( $state['codes'][ $chash ] ); |
| 729 |
self::save( $state ); |
| 730 |
|
| 731 |
if ( (int) $entry['expires'] < time() ) { |
| 732 |
return self::oauth_error( 'invalid_grant', __( 'Authorization code expired.', 'betterdocs' ) ); |
| 733 |
} |
| 734 |
|
| 735 |
if ( ! hash_equals( (string) $entry['client_id'], $client_id ) ) { |
| 736 |
return self::oauth_error( 'invalid_grant', __( 'client_id mismatch.', 'betterdocs' ) ); |
| 737 |
} |
| 738 |
|
| 739 |
if ( ! hash_equals( (string) $entry['redirect_uri'], $redirect_uri ) ) { |
| 740 |
return self::oauth_error( 'invalid_grant', __( 'redirect_uri mismatch.', 'betterdocs' ) ); |
| 741 |
} |
| 742 |
|
| 743 |
if ( '' === $verifier || ! hash_equals( (string) $entry['challenge'], self::s256( $verifier ) ) ) { |
| 744 |
return self::oauth_error( 'invalid_grant', __( 'PKCE verification failed.', 'betterdocs' ) ); |
| 745 |
} |
| 746 |
|
| 747 |
return self::mint_tokens( (string) $entry['client_id'], (string) $entry['scope'], (int) $entry['user_id'] ); |
| 748 |
} |
| 749 |
|
| 750 |
/** |
| 751 |
* The refresh_token grant: rotate the refresh token, issue a fresh access |
| 752 |
* token, and revoke both of the old ones. |
| 753 |
* |
| 754 |
* @since 4.9.0 |
| 755 |
* |
| 756 |
* @param array $body POST body. |
| 757 |
* @return array|\WP_Error |
| 758 |
*/ |
| 759 |
private static function grant_refresh_token( array $body ) { |
| 760 |
$refresh = isset( $body['refresh_token'] ) ? (string) $body['refresh_token'] : ''; |
| 761 |
$client_id = isset( $body['client_id'] ) ? (string) $body['client_id'] : ''; |
| 762 |
|
| 763 |
$state = self::state(); |
| 764 |
$rhash = self::hash( $refresh ); |
| 765 |
|
| 766 |
if ( '' === $refresh || ! isset( $state['refresh'][ $rhash ] ) ) { |
| 767 |
return self::oauth_error( 'invalid_grant', __( 'Unknown refresh token.', 'betterdocs' ) ); |
| 768 |
} |
| 769 |
|
| 770 |
$entry = $state['refresh'][ $rhash ]; |
| 771 |
|
| 772 |
if ( '' !== $client_id && ! hash_equals( (string) $entry['client_id'], $client_id ) ) { |
| 773 |
return self::oauth_error( 'invalid_grant', __( 'client_id mismatch.', 'betterdocs' ) ); |
| 774 |
} |
| 775 |
|
| 776 |
unset( $state['refresh'][ $rhash ] ); |
| 777 |
|
| 778 |
if ( isset( $entry['access_hash'] ) ) { |
| 779 |
unset( $state['tokens'][ $entry['access_hash'] ] ); |
| 780 |
} |
| 781 |
|
| 782 |
self::save( $state ); |
| 783 |
|
| 784 |
return self::mint_tokens( (string) $entry['client_id'], (string) $entry['scope'], (int) $entry['user_id'] ); |
| 785 |
} |
| 786 |
|
| 787 |
/** |
| 788 |
* Mint an access + refresh pair, store the hashes, return the raw values. |
| 789 |
* |
| 790 |
* This is the only moment either token exists in the clear. |
| 791 |
* |
| 792 |
* @since 4.9.0 |
| 793 |
* |
| 794 |
* @param string $client_id Client id. |
| 795 |
* @param string $scope Granted scope. |
| 796 |
* @param int $user_id Resource owner. |
| 797 |
* @return array |
| 798 |
*/ |
| 799 |
private static function mint_tokens( $client_id, $scope, $user_id ) { |
| 800 |
$access = bin2hex( random_bytes( 32 ) ); |
| 801 |
$refresh = bin2hex( random_bytes( 32 ) ); |
| 802 |
$ahash = self::hash( $access ); |
| 803 |
$rhash = self::hash( $refresh ); |
| 804 |
|
| 805 |
$state = self::state(); |
| 806 |
|
| 807 |
$state['tokens'][ $ahash ] = [ |
| 808 |
'client_id' => $client_id, |
| 809 |
'scope' => $scope, |
| 810 |
'user_id' => $user_id, |
| 811 |
'expires' => time() + self::ACCESS_TTL, |
| 812 |
'refresh' => $rhash |
| 813 |
]; |
| 814 |
|
| 815 |
$state['refresh'][ $rhash ] = [ |
| 816 |
'access_hash' => $ahash, |
| 817 |
'client_id' => $client_id, |
| 818 |
'scope' => $scope, |
| 819 |
'user_id' => $user_id, |
| 820 |
'expires' => time() + self::REFRESH_TTL |
| 821 |
]; |
| 822 |
|
| 823 |
self::save( $state ); |
| 824 |
|
| 825 |
return [ |
| 826 |
'access_token' => $access, |
| 827 |
'token_type' => 'Bearer', |
| 828 |
'expires_in' => self::ACCESS_TTL, |
| 829 |
'refresh_token' => $refresh, |
| 830 |
'scope' => $scope |
| 831 |
]; |
| 832 |
} |
| 833 |
|
| 834 |
/** |
| 835 |
* Remove every code, token and refresh grant matching a predicate. |
| 836 |
* |
| 837 |
* @since 4.9.0 |
| 838 |
* |
| 839 |
* @param callable $matches Receives one record, returns whether to drop it. |
| 840 |
* @return bool Whether anything was removed. |
| 841 |
*/ |
| 842 |
private static function revoke_where( callable $matches ) { |
| 843 |
$state = self::state(); |
| 844 |
$removed = false; |
| 845 |
|
| 846 |
foreach ( [ 'tokens', 'refresh', 'codes' ] as $bucket ) { |
| 847 |
foreach ( $state[ $bucket ] as $key => $entry ) { |
| 848 |
if ( is_array( $entry ) && $matches( $entry ) ) { |
| 849 |
unset( $state[ $bucket ][ $key ] ); |
| 850 |
$removed = true; |
| 851 |
} |
| 852 |
} |
| 853 |
} |
| 854 |
|
| 855 |
if ( $removed ) { |
| 856 |
self::save( $state ); |
| 857 |
} |
| 858 |
|
| 859 |
return $removed; |
| 860 |
} |
| 861 |
|
| 862 |
/** |
| 863 |
* Load state with defaults, pruning anything expired on the way out so the |
| 864 |
* option cannot grow without bound. |
| 865 |
* |
| 866 |
* @since 4.9.0 |
| 867 |
* |
| 868 |
* @return array |
| 869 |
*/ |
| 870 |
private static function state() { |
| 871 |
$stored = get_option( self::OPTION, [] ); |
| 872 |
|
| 873 |
if ( ! is_array( $stored ) ) { |
| 874 |
$stored = []; |
| 875 |
} |
| 876 |
|
| 877 |
$state = [ |
| 878 |
'clients' => isset( $stored['clients'] ) && is_array( $stored['clients'] ) ? $stored['clients'] : [], |
| 879 |
'codes' => isset( $stored['codes'] ) && is_array( $stored['codes'] ) ? $stored['codes'] : [], |
| 880 |
'tokens' => isset( $stored['tokens'] ) && is_array( $stored['tokens'] ) ? $stored['tokens'] : [], |
| 881 |
'refresh' => isset( $stored['refresh'] ) && is_array( $stored['refresh'] ) ? $stored['refresh'] : [] |
| 882 |
]; |
| 883 |
|
| 884 |
$now = time(); |
| 885 |
|
| 886 |
foreach ( [ 'codes', 'tokens' ] as $bucket ) { |
| 887 |
foreach ( $state[ $bucket ] as $key => $entry ) { |
| 888 |
if ( ! isset( $entry['expires'] ) || (int) $entry['expires'] < $now ) { |
| 889 |
unset( $state[ $bucket ][ $key ] ); |
| 890 |
} |
| 891 |
} |
| 892 |
} |
| 893 |
|
| 894 |
foreach ( $state['refresh'] as $key => $entry ) { |
| 895 |
if ( isset( $entry['expires'] ) && (int) $entry['expires'] < $now ) { |
| 896 |
unset( $state['refresh'][ $key ] ); |
| 897 |
} |
| 898 |
} |
| 899 |
|
| 900 |
return $state; |
| 901 |
} |
| 902 |
|
| 903 |
/** |
| 904 |
* Persist state. Autoload off — this is a hot, request-scoped option. |
| 905 |
* |
| 906 |
* @since 4.9.0 |
| 907 |
* |
| 908 |
* @param array $state State to store. |
| 909 |
* @return void |
| 910 |
*/ |
| 911 |
private static function save( array $state ) { |
| 912 |
update_option( self::OPTION, $state, false ); |
| 913 |
} |
| 914 |
|
| 915 |
/** |
| 916 |
* One registered client. |
| 917 |
* |
| 918 |
* @since 4.9.0 |
| 919 |
* |
| 920 |
* @param string $client_id Client id. |
| 921 |
* @return array|null |
| 922 |
*/ |
| 923 |
private static function client( $client_id ) { |
| 924 |
$client_id = (string) $client_id; |
| 925 |
|
| 926 |
if ( '' === $client_id ) { |
| 927 |
return null; |
| 928 |
} |
| 929 |
|
| 930 |
$clients = self::state()['clients']; |
| 931 |
|
| 932 |
if ( ! isset( $clients[ $client_id ] ) || ! is_array( $clients[ $client_id ] ) ) { |
| 933 |
return null; |
| 934 |
} |
| 935 |
|
| 936 |
$client = $clients[ $client_id ]; |
| 937 |
|
| 938 |
return [ |
| 939 |
'redirect_uris' => isset( $client['redirect_uris'] ) && is_array( $client['redirect_uris'] ) ? array_map( 'strval', $client['redirect_uris'] ) : [], |
| 940 |
'name' => isset( $client['name'] ) ? (string) $client['name'] : 'MCP Client', |
| 941 |
'created' => isset( $client['created'] ) ? (int) $client['created'] : 0 |
| 942 |
]; |
| 943 |
} |
| 944 |
|
| 945 |
/** |
| 946 |
* Bound the registered-client list. |
| 947 |
* |
| 948 |
* Abandoned registrations past `CLIENT_TTL` go first; if that is not enough, |
| 949 |
* the oldest of what is left. A client referenced by a live code, access |
| 950 |
* token or refresh token is **never** dropped — evicting one breaks a |
| 951 |
* working connection — so a site legitimately holding more than |
| 952 |
* `MAX_CLIENTS` live grants keeps every one of them and the cap simply stops |
| 953 |
* applying to that remainder. |
| 954 |
* |
| 955 |
* @since 4.9.0 |
| 956 |
* |
| 957 |
* @param array $state Full state. |
| 958 |
* @return array The clients array to store. |
| 959 |
*/ |
| 960 |
private static function prune_clients( array $state ) { |
| 961 |
$clients = $state['clients']; |
| 962 |
$in_use = []; |
| 963 |
|
| 964 |
foreach ( [ 'codes', 'tokens', 'refresh' ] as $bucket ) { |
| 965 |
foreach ( $state[ $bucket ] as $entry ) { |
| 966 |
if ( is_array( $entry ) && isset( $entry['client_id'] ) ) { |
| 967 |
$in_use[ (string) $entry['client_id'] ] = true; |
| 968 |
} |
| 969 |
} |
| 970 |
} |
| 971 |
|
| 972 |
$now = time(); |
| 973 |
|
| 974 |
foreach ( $clients as $id => $client ) { |
| 975 |
$created = isset( $client['created'] ) ? (int) $client['created'] : 0; |
| 976 |
|
| 977 |
if ( ! isset( $in_use[ $id ] ) && $created + self::CLIENT_TTL < $now ) { |
| 978 |
unset( $clients[ $id ] ); |
| 979 |
} |
| 980 |
} |
| 981 |
|
| 982 |
if ( count( $clients ) <= self::MAX_CLIENTS ) { |
| 983 |
return $clients; |
| 984 |
} |
| 985 |
|
| 986 |
$evictable = array_filter( |
| 987 |
$clients, |
| 988 |
static function ( $id ) use ( $in_use ) { |
| 989 |
return ! isset( $in_use[ $id ] ); |
| 990 |
}, |
| 991 |
ARRAY_FILTER_USE_KEY |
| 992 |
); |
| 993 |
|
| 994 |
uasort( |
| 995 |
$evictable, |
| 996 |
static function ( $a, $b ) { |
| 997 |
return ( isset( $a['created'] ) ? (int) $a['created'] : 0 ) <=> ( isset( $b['created'] ) ? (int) $b['created'] : 0 ); |
| 998 |
} |
| 999 |
); |
| 1000 |
|
| 1001 |
foreach ( array_keys( $evictable ) as $id ) { |
| 1002 |
if ( count( $clients ) <= self::MAX_CLIENTS ) { |
| 1003 |
break; |
| 1004 |
} |
| 1005 |
|
| 1006 |
unset( $clients[ $id ] ); |
| 1007 |
} |
| 1008 |
|
| 1009 |
return $clients; |
| 1010 |
} |
| 1011 |
|
| 1012 |
/** |
| 1013 |
* SHA-256, the form every token is stored in. |
| 1014 |
* |
| 1015 |
* @since 4.9.0 |
| 1016 |
* |
| 1017 |
* @param string $value Raw secret. |
| 1018 |
* @return string |
| 1019 |
*/ |
| 1020 |
private static function hash( $value ) { |
| 1021 |
return hash( 'sha256', (string) $value ); |
| 1022 |
} |
| 1023 |
|
| 1024 |
/** |
| 1025 |
* Split a scope string into its parts. |
| 1026 |
* |
| 1027 |
* @since 4.9.0 |
| 1028 |
* |
| 1029 |
* @param string $scope Space-separated scopes. |
| 1030 |
* @return string[] |
| 1031 |
*/ |
| 1032 |
private static function scope_parts( $scope ) { |
| 1033 |
$parts = preg_split( '/\s+/', trim( (string) $scope ) ); |
| 1034 |
|
| 1035 |
return is_array( $parts ) ? $parts : []; |
| 1036 |
} |
| 1037 |
|
| 1038 |
/** |
| 1039 |
* Constrain a requested scope to what is supported. Defaults to `mcp`. |
| 1040 |
* |
| 1041 |
* @since 4.9.0 |
| 1042 |
* |
| 1043 |
* @param string $requested Requested scope string. |
| 1044 |
* @return string |
| 1045 |
*/ |
| 1046 |
private static function normalize_scope( $requested ) { |
| 1047 |
$parts = array_values( array_intersect( self::scope_parts( $requested ), self::SUPPORTED_SCOPES ) ); |
| 1048 |
|
| 1049 |
if ( empty( $parts ) ) { |
| 1050 |
return 'mcp'; |
| 1051 |
} |
| 1052 |
|
| 1053 |
return implode( ' ', $parts ); |
| 1054 |
} |
| 1055 |
|
| 1056 |
/** |
| 1057 |
* The login name behind a user id, for the connected-apps list. |
| 1058 |
* |
| 1059 |
* @since 4.9.0 |
| 1060 |
* |
| 1061 |
* @param int $user_id User id. |
| 1062 |
* @return string Empty when the user no longer exists. |
| 1063 |
*/ |
| 1064 |
private static function user_login( $user_id ) { |
| 1065 |
$user_id = (int) $user_id; |
| 1066 |
|
| 1067 |
if ( $user_id <= 0 || ! function_exists( 'get_userdata' ) ) { |
| 1068 |
return ''; |
| 1069 |
} |
| 1070 |
|
| 1071 |
$user = get_userdata( $user_id ); |
| 1072 |
|
| 1073 |
return $user && isset( $user->user_login ) ? (string) $user->user_login : ''; |
| 1074 |
} |
| 1075 |
|
| 1076 |
/** |
| 1077 |
* Whether a redirect URI is structurally acceptable: http(s), or a native |
| 1078 |
* client's custom scheme. |
| 1079 |
* |
| 1080 |
* @since 4.9.0 |
| 1081 |
* |
| 1082 |
* @param string $uri Candidate redirect URI. |
| 1083 |
* @return bool |
| 1084 |
*/ |
| 1085 |
private static function is_valid_redirect_uri( $uri ) { |
| 1086 |
$uri = trim( (string) $uri ); |
| 1087 |
|
| 1088 |
if ( '' === $uri ) { |
| 1089 |
return false; |
| 1090 |
} |
| 1091 |
|
| 1092 |
if ( ! preg_match( '#^([a-zA-Z][a-zA-Z0-9+.\-]*)://#', $uri, $matches ) ) { |
| 1093 |
return false; |
| 1094 |
} |
| 1095 |
|
| 1096 |
// Registration is unauthenticated, so the scheme is attacker-chosen. |
| 1097 |
// `javascript://…`, `data://…` and `vbscript://…` all satisfy the shape |
| 1098 |
// above, and this value is later handed to `wp_redirect()` and rendered |
| 1099 |
// on the consent screen. Browsers refuse to navigate to those, so this |
| 1100 |
// is not the last line of defence — but a credential callback has no |
| 1101 |
// business being one of them either. |
| 1102 |
$scheme = strtolower( $matches[1] ); |
| 1103 |
|
| 1104 |
return ! in_array( $scheme, [ 'javascript', 'data', 'vbscript', 'file' ], true ); |
| 1105 |
} |
| 1106 |
|
| 1107 |
/** |
| 1108 |
* A `WP_Error` whose data carries an RFC 6749 error code, so the token route |
| 1109 |
* can render the OAuth error body verbatim. |
| 1110 |
* |
| 1111 |
* @since 4.9.0 |
| 1112 |
* |
| 1113 |
* @param string $code OAuth error code. |
| 1114 |
* @param string $message Human-readable description. |
| 1115 |
* @return \WP_Error |
| 1116 |
*/ |
| 1117 |
private static function oauth_error( $code, $message ) { |
| 1118 |
return new \WP_Error( |
| 1119 |
$code, |
| 1120 |
$message, |
| 1121 |
[ |
| 1122 |
'status' => 400, |
| 1123 |
'error' => $code, |
| 1124 |
'error_description' => $message |
| 1125 |
] |
| 1126 |
); |
| 1127 |
} |
| 1128 |
} |
| 1129 |
|