| 1 |
<?php |
| 2 |
/** |
| 3 |
* OpenStation — OAuth relay scaffolding. |
| 4 |
* |
| 5 |
* Every plugin that integrates with an external service (Tumblr, |
| 6 |
* Mastodon, Bluesky, Spotify, Discord, …) reinvents the same |
| 7 |
* fiddly OAuth dance: generate a `state` nonce, persist it in a |
| 8 |
* transient, open a popup for the user to authorize, the callback |
| 9 |
* URL `postMessage`s back to the opener, the opener resolves a |
| 10 |
* Promise on receiving the success message. ~120 LOC of lifecycle |
| 11 |
* plumbing per plugin. |
| 12 |
* |
| 13 |
* This module bundles the dance into one helper so each plugin |
| 14 |
* declares only what's plugin-specific (the authorize / token |
| 15 |
* URLs and the token-storage callback). The rest — state nonce |
| 16 |
* + transient + popup + postMessage + opener listener — lives |
| 17 |
* here and is identical across consumers. |
| 18 |
* |
| 19 |
* Public PHP surface: |
| 20 |
* |
| 21 |
* openstation_register_oauth_relay( $service, [ |
| 22 |
* 'authorize_url' => 'https://www.example.com/oauth2/authorize', |
| 23 |
* 'token_url' => 'https://api.example.com/oauth2/token', |
| 24 |
* 'client_id' => 'CLIENT_ID', |
| 25 |
* 'client_secret' => 'CLIENT_SECRET', |
| 26 |
* 'scope' => 'read write', |
| 27 |
* 'on_success' => function ( $user_id, $tokens, $service ) { |
| 28 |
* // Persist tokens however your plugin needs. |
| 29 |
* }, |
| 30 |
* ] ); |
| 31 |
* |
| 32 |
* Public JS surface: |
| 33 |
* |
| 34 |
* const { ok, service } = await wp.os.startOAuth( 'example' ); |
| 35 |
* // Tokens stay server-side — persisted by your `on_success` callback. |
| 36 |
* |
| 37 |
* REST routes: |
| 38 |
* |
| 39 |
* POST /desktop-mode/v1/oauth/start body: { service: string } |
| 40 |
* → { authorize_url: string, state: string } |
| 41 |
* GET /desktop-mode/v1/oauth/callback ?code&state&error |
| 42 |
* → HTML page that postMessages the opener and closes |
| 43 |
* |
| 44 |
* @package OpenStation |
| 45 |
*/ |
| 46 |
|
| 47 |
defined( 'ABSPATH' ) || exit; |
| 48 |
|
| 49 |
/** |
| 50 |
* The VALUE keeps its pre-rebrand spelling on purpose: it is a |
| 51 |
* persisted or externally-visible identifier, so renaming it would |
| 52 |
* orphan data already written by live installs (or break a live |
| 53 |
* URL). The mismatch between this constant's name and its value is |
| 54 |
* deliberate — it is NOT a half-finished rename. |
| 55 |
*/ |
| 56 |
const OPENSTATION_OAUTH_TRANSIENT_PREFIX = 'desktop_mode_oauth_state_'; |
| 57 |
const OPENSTATION_OAUTH_STATE_TTL = 600; // 10 minutes. |
| 58 |
|
| 59 |
/** |
| 60 |
* Register an OAuth relay for `$service`. |
| 61 |
* |
| 62 |
* @param string $service Slug identifying the service. Lowercased, |
| 63 |
* sanitized via `sanitize_key`. |
| 64 |
* @param array $args { |
| 65 |
* OAuth relay configuration. |
| 66 |
* |
| 67 |
* @type string $authorize_url Authorization URL the user is |
| 68 |
* redirected to in the popup. The |
| 69 |
* framework appends `client_id`, |
| 70 |
* `redirect_uri`, `scope`, and |
| 71 |
* `state` query-args automatically. |
| 72 |
* Required. |
| 73 |
* @type string $token_url Token-exchange URL. The framework |
| 74 |
* POSTs `grant_type=authorization_code` |
| 75 |
* + `code` + `client_id` + `client_secret` |
| 76 |
* + `redirect_uri` and parses the JSON |
| 77 |
* response. Required. |
| 78 |
* @type string $client_id OAuth client id. Required. |
| 79 |
* @type string $client_secret OAuth client secret. Required. |
| 80 |
* @type string $scope OAuth scope string. Optional. |
| 81 |
* @type callable $on_success `function ( int $user_id, array $tokens, |
| 82 |
* string $service ): void`. Called after a |
| 83 |
* successful token exchange so the plugin |
| 84 |
* can persist tokens however it needs |
| 85 |
* (user meta, options, custom table). |
| 86 |
* Required. |
| 87 |
* @type string[] $capabilities Caps the user must hold to start the |
| 88 |
* flow. Default: `[ 'read' ]` (any |
| 89 |
* logged-in user). |
| 90 |
* } |
| 91 |
* @return true|WP_Error `true` on success, `WP_Error` on validation failure. |
| 92 |
*/ |
| 93 |
function openstation_register_oauth_relay( $service, $args = array() ) { |
| 94 |
$service = sanitize_key( (string) $service ); |
| 95 |
if ( '' === $service ) { |
| 96 |
return new WP_Error( |
| 97 |
'openstation_oauth_missing_service', |
| 98 |
__( 'OAuth relay registration requires a non-empty service slug.', 'desktop-mode' ) |
| 99 |
); |
| 100 |
} |
| 101 |
|
| 102 |
$defaults = array( |
| 103 |
'authorize_url' => '', |
| 104 |
'token_url' => '', |
| 105 |
'client_id' => '', |
| 106 |
'client_secret' => '', |
| 107 |
'scope' => '', |
| 108 |
'on_success' => null, |
| 109 |
'capabilities' => array( 'read' ), |
| 110 |
); |
| 111 |
$args = wp_parse_args( $args, $defaults ); |
| 112 |
|
| 113 |
foreach ( array( 'authorize_url', 'token_url', 'client_id', 'client_secret' ) as $required ) { |
| 114 |
if ( '' === (string) $args[ $required ] ) { |
| 115 |
return new WP_Error( |
| 116 |
'openstation_oauth_missing_' . $required, |
| 117 |
/* translators: %s: missing field name. */ |
| 118 |
sprintf( __( 'OAuth relay registration requires a non-empty `%s`.', 'desktop-mode' ), $required ), |
| 119 |
array( 'service' => $service ) |
| 120 |
); |
| 121 |
} |
| 122 |
} |
| 123 |
|
| 124 |
if ( ! is_callable( $args['on_success'] ) ) { |
| 125 |
return new WP_Error( |
| 126 |
'openstation_oauth_missing_on_success', |
| 127 |
__( 'OAuth relay registration requires a callable `on_success` handler.', 'desktop-mode' ), |
| 128 |
array( 'service' => $service ) |
| 129 |
); |
| 130 |
} |
| 131 |
|
| 132 |
$authorize_url = esc_url_raw( (string) $args['authorize_url'], array( 'http', 'https' ) ); |
| 133 |
$token_url = esc_url_raw( (string) $args['token_url'], array( 'http', 'https' ) ); |
| 134 |
if ( '' === $authorize_url || '' === $token_url ) { |
| 135 |
return new WP_Error( |
| 136 |
'openstation_oauth_invalid_url', |
| 137 |
__( 'OAuth relay `authorize_url` and `token_url` must be valid http(s) URLs.', 'desktop-mode' ), |
| 138 |
array( 'service' => $service ) |
| 139 |
); |
| 140 |
} |
| 141 |
|
| 142 |
$entry = array( |
| 143 |
'service' => $service, |
| 144 |
'authorize_url' => $authorize_url, |
| 145 |
'token_url' => $token_url, |
| 146 |
'client_id' => (string) $args['client_id'], |
| 147 |
'client_secret' => (string) $args['client_secret'], |
| 148 |
'scope' => (string) $args['scope'], |
| 149 |
'on_success' => $args['on_success'], |
| 150 |
'capabilities' => array_values( array_filter( array_map( 'strval', (array) $args['capabilities'] ) ) ), |
| 151 |
); |
| 152 |
openstation_oauth_relay_registry( $service, $entry ); |
| 153 |
|
| 154 |
/** |
| 155 |
* Fires after an OAuth relay is registered. Use this to layer |
| 156 |
* observability or to extend behaviour. |
| 157 |
* |
| 158 |
* @param string $service The service slug. |
| 159 |
* @param array $entry The stored registry entry minus the secrets |
| 160 |
* (`client_secret` is masked). |
| 161 |
*/ |
| 162 |
do_action( |
| 163 |
'openstation_oauth_relay_registered', |
| 164 |
$service, |
| 165 |
array_merge( $entry, array( 'client_secret' => '[redacted]' ) ) |
| 166 |
); |
| 167 |
|
| 168 |
return true; |
| 169 |
} |
| 170 |
|
| 171 |
/** |
| 172 |
* Static registry for OAuth relays. Mirror of the icon / native- |
| 173 |
* window / wallpaper registries. |
| 174 |
* |
| 175 |
* @internal |
| 176 |
*/ |
| 177 |
function openstation_oauth_relay_registry( $service = '', $entry = null ) { |
| 178 |
static $store = array(); |
| 179 |
|
| 180 |
if ( '' === (string) $service ) { |
| 181 |
return $store; |
| 182 |
} |
| 183 |
if ( '__unset__' === $entry ) { |
| 184 |
unset( $store[ $service ] ); |
| 185 |
return null; |
| 186 |
} |
| 187 |
if ( null !== $entry ) { |
| 188 |
$store[ $service ] = $entry; |
| 189 |
} |
| 190 |
return isset( $store[ $service ] ) ? $store[ $service ] : null; |
| 191 |
} |
| 192 |
|
| 193 |
/** |
| 194 |
* Remove a previously registered OAuth relay. Mirror of |
| 195 |
* `openstation_register_oauth_relay()` — handy for plugins that |
| 196 |
* register conditionally and for PHPUnit teardowns. |
| 197 |
* |
| 198 |
* @param string $service Service slug passed to register. |
| 199 |
* @return void |
| 200 |
*/ |
| 201 |
function openstation_unregister_oauth_relay( $service ) { |
| 202 |
$service = sanitize_key( (string) $service ); |
| 203 |
if ( '' === $service ) { |
| 204 |
return; |
| 205 |
} |
| 206 |
openstation_oauth_relay_registry( $service, '__unset__' ); |
| 207 |
} |
| 208 |
|
| 209 |
/** |
| 210 |
* The redirect URI the popup posts back to. Same for every service — |
| 211 |
* the framework recovers the service from the state transient, so no |
| 212 |
* service query arg is needed. |
| 213 |
* |
| 214 |
* @return string |
| 215 |
*/ |
| 216 |
function openstation_oauth_redirect_uri() { |
| 217 |
return rest_url( 'desktop-mode/v1/oauth/callback' ); |
| 218 |
} |
| 219 |
|
| 220 |
/** |
| 221 |
* Generate a fresh state nonce, persist it in a transient keyed by |
| 222 |
* the state value (with `user_id` + `service` stored in the transient |
| 223 |
* payload), and return the value the popup will round-trip. |
| 224 |
* |
| 225 |
* @param int $user_id The user starting the flow. |
| 226 |
* @param string $service The service slug being authorized. |
| 227 |
* @return string The state value to embed in the authorize URL. |
| 228 |
*/ |
| 229 |
function openstation_oauth_issue_state( $user_id, $service ) { |
| 230 |
// 32 chars of letters+digits — `wp_generate_password` with the |
| 231 |
// no-special-chars flag is the canonical WP shape. |
| 232 |
$state = wp_generate_password( 32, false ); |
| 233 |
set_transient( |
| 234 |
OPENSTATION_OAUTH_TRANSIENT_PREFIX . $state, |
| 235 |
array( |
| 236 |
'user_id' => (int) $user_id, |
| 237 |
'service' => (string) $service, |
| 238 |
'issued' => time(), |
| 239 |
), |
| 240 |
OPENSTATION_OAUTH_STATE_TTL |
| 241 |
); |
| 242 |
return $state; |
| 243 |
} |
| 244 |
|
| 245 |
/** |
| 246 |
* Validate + consume an issued state nonce. Returns the stored |
| 247 |
* `{ user_id, service }` payload on a hit, `null` on a miss / expired. |
| 248 |
* |
| 249 |
* Single-use: a successful read deletes the transient so a replay |
| 250 |
* with the same state fails. |
| 251 |
* |
| 252 |
* @param string $state State value from the callback query. |
| 253 |
* @return array{user_id:int,service:string,issued:int}|null |
| 254 |
*/ |
| 255 |
function openstation_oauth_consume_state( $state ) { |
| 256 |
$state = (string) $state; |
| 257 |
if ( '' === $state ) { |
| 258 |
return null; |
| 259 |
} |
| 260 |
$key = OPENSTATION_OAUTH_TRANSIENT_PREFIX . $state; |
| 261 |
$entry = get_transient( $key ); |
| 262 |
if ( ! is_array( $entry ) || empty( $entry['user_id'] ) || empty( $entry['service'] ) ) { |
| 263 |
return null; |
| 264 |
} |
| 265 |
delete_transient( $key ); |
| 266 |
return array( |
| 267 |
'user_id' => (int) $entry['user_id'], |
| 268 |
'service' => (string) $entry['service'], |
| 269 |
'issued' => isset( $entry['issued'] ) ? (int) $entry['issued'] : 0, |
| 270 |
); |
| 271 |
} |
| 272 |
|
| 273 |
/** |
| 274 |
* REST: `POST /desktop-mode/v1/oauth/start` — issue a state and |
| 275 |
* return the assembled authorize URL. |
| 276 |
* |
| 277 |
* @param WP_REST_Request $request |
| 278 |
* @return WP_REST_Response|WP_Error |
| 279 |
*/ |
| 280 |
function openstation_rest_oauth_start( WP_REST_Request $request ) { |
| 281 |
$service = sanitize_key( (string) $request->get_param( 'service' ) ); |
| 282 |
$entry = openstation_oauth_relay_registry( $service ); |
| 283 |
if ( ! is_array( $entry ) ) { |
| 284 |
return new WP_Error( |
| 285 |
'openstation_oauth_unknown_service', |
| 286 |
__( 'No OAuth relay is registered for that service.', 'desktop-mode' ), |
| 287 |
array( 'status' => 404 ) |
| 288 |
); |
| 289 |
} |
| 290 |
|
| 291 |
foreach ( $entry['capabilities'] as $cap ) { |
| 292 |
if ( ! current_user_can( (string) $cap ) ) { |
| 293 |
return new WP_Error( |
| 294 |
'openstation_oauth_capability_denied', |
| 295 |
__( 'Current user lacks the capability required to start this OAuth flow.', 'desktop-mode' ), |
| 296 |
array( 'status' => 403 ) |
| 297 |
); |
| 298 |
} |
| 299 |
} |
| 300 |
|
| 301 |
$user_id = get_current_user_id(); |
| 302 |
$state = openstation_oauth_issue_state( $user_id, $service ); |
| 303 |
|
| 304 |
$query = array( |
| 305 |
'response_type' => 'code', |
| 306 |
'client_id' => $entry['client_id'], |
| 307 |
'redirect_uri' => openstation_oauth_redirect_uri(), |
| 308 |
'state' => $state, |
| 309 |
); |
| 310 |
if ( '' !== $entry['scope'] ) { |
| 311 |
$query['scope'] = $entry['scope']; |
| 312 |
} |
| 313 |
|
| 314 |
/** |
| 315 |
* Filter the query parameters appended to the authorize URL. |
| 316 |
* Lets plugins inject service-specific extras (`access_type=offline` |
| 317 |
* for Google, `force_login=true` for Twitter, `prompt=consent`, |
| 318 |
* etc.) without having to fork the relay. |
| 319 |
* |
| 320 |
* @param array $query Default query params. |
| 321 |
* @param string $service Service slug. |
| 322 |
* @param array $entry Registry entry (with secrets redacted). |
| 323 |
*/ |
| 324 |
$query = apply_filters( |
| 325 |
'openstation_oauth_authorize_query', |
| 326 |
$query, |
| 327 |
$service, |
| 328 |
array_merge( $entry, array( 'client_secret' => '[redacted]' ) ) |
| 329 |
); |
| 330 |
|
| 331 |
$authorize_url = add_query_arg( array_map( 'rawurlencode', $query ), $entry['authorize_url'] ); |
| 332 |
|
| 333 |
return rest_ensure_response( |
| 334 |
array( |
| 335 |
'authorize_url' => $authorize_url, |
| 336 |
'state' => $state, |
| 337 |
) |
| 338 |
); |
| 339 |
} |
| 340 |
|
| 341 |
/** |
| 342 |
* REST: `GET /desktop-mode/v1/oauth/callback` — exchange the auth |
| 343 |
* code for tokens, fire the registered `on_success` handler, then |
| 344 |
* render an HTML page that `postMessage`s the opener and closes. |
| 345 |
* |
| 346 |
* @param WP_REST_Request $request |
| 347 |
* @return WP_REST_Response|WP_Error |
| 348 |
*/ |
| 349 |
function openstation_rest_oauth_callback( WP_REST_Request $request ) { |
| 350 |
$state = (string) $request->get_param( 'state' ); |
| 351 |
$code = (string) $request->get_param( 'code' ); |
| 352 |
$error = (string) $request->get_param( 'error' ); |
| 353 |
|
| 354 |
$consumed = openstation_oauth_consume_state( $state ); |
| 355 |
if ( null === $consumed ) { |
| 356 |
return openstation_oauth_render_callback_html( |
| 357 |
array( |
| 358 |
'ok' => false, |
| 359 |
'reason' => 'invalid_state', |
| 360 |
'message' => __( 'OAuth state nonce missing, expired, or already used.', 'desktop-mode' ), |
| 361 |
) |
| 362 |
); |
| 363 |
} |
| 364 |
$service = $consumed['service']; |
| 365 |
$user_id = $consumed['user_id']; |
| 366 |
|
| 367 |
if ( '' !== $error ) { |
| 368 |
return openstation_oauth_render_callback_html( |
| 369 |
array( |
| 370 |
'ok' => false, |
| 371 |
'service' => $service, |
| 372 |
'reason' => 'authorize_denied', |
| 373 |
'message' => $error, |
| 374 |
) |
| 375 |
); |
| 376 |
} |
| 377 |
|
| 378 |
$entry = openstation_oauth_relay_registry( $service ); |
| 379 |
if ( ! is_array( $entry ) ) { |
| 380 |
return openstation_oauth_render_callback_html( |
| 381 |
array( |
| 382 |
'ok' => false, |
| 383 |
'reason' => 'unknown_service', |
| 384 |
'message' => __( 'OAuth relay is no longer registered for that service.', 'desktop-mode' ), |
| 385 |
) |
| 386 |
); |
| 387 |
} |
| 388 |
|
| 389 |
if ( '' === $code ) { |
| 390 |
return openstation_oauth_render_callback_html( |
| 391 |
array( |
| 392 |
'ok' => false, |
| 393 |
'service' => $service, |
| 394 |
'reason' => 'missing_code', |
| 395 |
'message' => __( 'OAuth callback did not return an authorization code.', 'desktop-mode' ), |
| 396 |
) |
| 397 |
); |
| 398 |
} |
| 399 |
|
| 400 |
$response = wp_remote_post( |
| 401 |
$entry['token_url'], |
| 402 |
array( |
| 403 |
'timeout' => 15, |
| 404 |
'body' => array( |
| 405 |
'grant_type' => 'authorization_code', |
| 406 |
'code' => $code, |
| 407 |
'client_id' => $entry['client_id'], |
| 408 |
'client_secret' => $entry['client_secret'], |
| 409 |
'redirect_uri' => openstation_oauth_redirect_uri(), |
| 410 |
), |
| 411 |
'headers' => array( 'Accept' => 'application/json' ), |
| 412 |
) |
| 413 |
); |
| 414 |
if ( is_wp_error( $response ) ) { |
| 415 |
return openstation_oauth_render_callback_html( |
| 416 |
array( |
| 417 |
'ok' => false, |
| 418 |
'service' => $service, |
| 419 |
'reason' => 'token_request_failed', |
| 420 |
'message' => $response->get_error_message(), |
| 421 |
) |
| 422 |
); |
| 423 |
} |
| 424 |
$status = (int) wp_remote_retrieve_response_code( $response ); |
| 425 |
$body = wp_remote_retrieve_body( $response ); |
| 426 |
$tokens = json_decode( $body, true ); |
| 427 |
if ( $status < 200 || $status >= 300 || ! is_array( $tokens ) ) { |
| 428 |
return openstation_oauth_render_callback_html( |
| 429 |
array( |
| 430 |
'ok' => false, |
| 431 |
'service' => $service, |
| 432 |
'reason' => 'token_exchange_failed', |
| 433 |
'message' => sprintf( |
| 434 |
/* translators: %d: HTTP status code. */ |
| 435 |
__( 'Token exchange failed with HTTP %d.', 'desktop-mode' ), |
| 436 |
$status |
| 437 |
), |
| 438 |
) |
| 439 |
); |
| 440 |
} |
| 441 |
|
| 442 |
try { |
| 443 |
call_user_func( $entry['on_success'], $user_id, $tokens, $service ); |
| 444 |
} catch ( \Throwable $e ) { |
| 445 |
return openstation_oauth_render_callback_html( |
| 446 |
array( |
| 447 |
'ok' => false, |
| 448 |
'service' => $service, |
| 449 |
'reason' => 'on_success_threw', |
| 450 |
'message' => $e->getMessage(), |
| 451 |
) |
| 452 |
); |
| 453 |
} |
| 454 |
|
| 455 |
/** |
| 456 |
* Fires after a successful OAuth round-trip — after `on_success` |
| 457 |
* persists the tokens. Plugins use this to refresh badges, |
| 458 |
* re-render dock items, or surface a "connected" toast in |
| 459 |
* sibling windows via the activity bus. |
| 460 |
* |
| 461 |
* @param string $service Service slug. |
| 462 |
* @param int $user_id User who connected. |
| 463 |
*/ |
| 464 |
do_action( 'openstation_oauth_relay_connected', $service, $user_id ); |
| 465 |
|
| 466 |
return openstation_oauth_render_callback_html( |
| 467 |
array( |
| 468 |
'ok' => true, |
| 469 |
'service' => $service, |
| 470 |
) |
| 471 |
); |
| 472 |
} |
| 473 |
|
| 474 |
/** |
| 475 |
* Build the HTML string the OAuth callback popup renders. |
| 476 |
* |
| 477 |
* Pure function — no side effects. Split out from |
| 478 |
* {@see openstation_oauth_render_callback_html()} so unit tests |
| 479 |
* can exercise the markup directly without going through a REST |
| 480 |
* dispatch + output-buffer dance. |
| 481 |
* |
| 482 |
* **Why `wp_json_encode` and not `esc_js` for the inlined values.** |
| 483 |
* `esc_js` HTML-encodes `"` to `"` — fine for JS embedded |
| 484 |
* inside an HTML *attribute* (where the parser decodes entities |
| 485 |
* before the JS engine sees the value), wrong for JS embedded |
| 486 |
* inside a `<script>` element (where HTML entities are NOT |
| 487 |
* decoded — the JS engine reads `{"ok":true}` literally |
| 488 |
* and throws a syntax error). The canonical safe shape for |
| 489 |
* embedding JSON in a script block is to drop the value as a |
| 490 |
* direct JS literal (JSON is a subset of JS) with `JSON_HEX_TAG` |
| 491 |
* neutralising any `</script>` substrings in string values |
| 492 |
* (defence-in-depth — our payload values are server-built, but |
| 493 |
* filters could mutate them). |
| 494 |
* |
| 495 |
* @internal |
| 496 |
* |
| 497 |
* @param array $payload `{ ok: bool, service?: string, reason?: string, message?: string }`. |
| 498 |
* @return string |
| 499 |
*/ |
| 500 |
function openstation_oauth_build_callback_html( array $payload ) { |
| 501 |
// `JSON_HEX_TAG` escapes `<` and `>` as `\u003C` / `\u003E` so a |
| 502 |
// `</script>` smuggled into any string value can't terminate the |
| 503 |
// script block early. `JSON_UNESCAPED_SLASHES` keeps URLs |
| 504 |
// readable in DevTools. |
| 505 |
$payload_literal = wp_json_encode( $payload, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ); |
| 506 |
$origin_literal = wp_json_encode( site_url(), JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ); |
| 507 |
|
| 508 |
return "<!doctype html> |
| 509 |
<html lang=\"en\"> |
| 510 |
<head> |
| 511 |
<meta charset=\"utf-8\"> |
| 512 |
<title>OAuth Callback</title> |
| 513 |
<style> |
| 514 |
body { font-family: -apple-system, system-ui, sans-serif; padding: 24px; color: #1d2327; } |
| 515 |
</style> |
| 516 |
</head> |
| 517 |
<body> |
| 518 |
<p>Authorization complete. You can close this window.</p> |
| 519 |
<script> |
| 520 |
( function () { |
| 521 |
try { |
| 522 |
if ( window.opener ) { |
| 523 |
window.opener.postMessage( |
| 524 |
{ type: 'os-oauth-callback', payload: {$payload_literal} }, |
| 525 |
{$origin_literal} |
| 526 |
); |
| 527 |
} |
| 528 |
} catch ( e ) {} |
| 529 |
setTimeout( function () { window.close(); }, 250 ); |
| 530 |
} )(); |
| 531 |
</script> |
| 532 |
</body> |
| 533 |
</html>"; |
| 534 |
} |
| 535 |
|
| 536 |
/** |
| 537 |
* Render the popup's HTML response. |
| 538 |
* |
| 539 |
* **Why this is more than `new WP_REST_Response( $html )`.** |
| 540 |
* `WP_REST_Server::serve_request()` runs every response's data |
| 541 |
* through `wp_json_encode()` regardless of the Content-Type |
| 542 |
* header. The naive form ships the HTML as a JSON-encoded string |
| 543 |
* with `Content-Type: text/html`, the browser renders it as |
| 544 |
* literal text (with the `<script>` block as inert page content), |
| 545 |
* and the popup's `postMessage` to its opener never fires. |
| 546 |
* |
| 547 |
* The fix: register a `rest_pre_serve_request` filter scoped to |
| 548 |
* this exact route that echoes the HTML directly and short- |
| 549 |
* circuits the JSON serializer. The filter self-removes after |
| 550 |
* firing so a subsequent REST request can't replay the cached |
| 551 |
* HTML closure. |
| 552 |
* |
| 553 |
* The returned `WP_REST_Response` carries the HTML as `data` so |
| 554 |
* unit tests reading `$response->get_data()` still see the body |
| 555 |
* (the filter only fires when the response is actually served). |
| 556 |
* |
| 557 |
* @param array $payload `{ ok: bool, service?: string, reason?: string, message?: string }`. |
| 558 |
* @return WP_REST_Response |
| 559 |
*/ |
| 560 |
function openstation_oauth_render_callback_html( array $payload ) { |
| 561 |
$html = openstation_oauth_build_callback_html( $payload ); |
| 562 |
|
| 563 |
$filter_cb = null; |
| 564 |
$filter_cb = static function ( $served, $result, $request ) use ( $html, &$filter_cb ) { |
| 565 |
// Scope tightly to the OAuth callback route — never affect |
| 566 |
// other REST endpoints' serialization. A misconfigured filter |
| 567 |
// here could break every REST response on the site. |
| 568 |
if ( |
| 569 |
! $request instanceof WP_REST_Request |
| 570 |
|| '/desktop-mode/v1/oauth/callback' !== $request->get_route() |
| 571 |
) { |
| 572 |
return $served; |
| 573 |
} |
| 574 |
// Self-remove so the closure (which captures the HTML for THIS |
| 575 |
// request) doesn't echo it again on a subsequent REST call. |
| 576 |
if ( $filter_cb ) { |
| 577 |
remove_filter( 'rest_pre_serve_request', $filter_cb, 10 ); |
| 578 |
} |
| 579 |
if ( ! headers_sent() ) { |
| 580 |
header( 'Content-Type: text/html; charset=utf-8' ); |
| 581 |
} |
| 582 |
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- statically-built HTML; embedded payload is wp_json_encode( …, JSON_HEX_TAG )-escaped during build in `openstation_oauth_build_callback_html`. |
| 583 |
echo $html; |
| 584 |
// `true` tells WP_REST_Server we already served the response, |
| 585 |
// short-circuiting the json-encode + `echo` path that follows. |
| 586 |
return true; |
| 587 |
}; |
| 588 |
add_filter( 'rest_pre_serve_request', $filter_cb, 10, 3 ); |
| 589 |
|
| 590 |
$response = new WP_REST_Response( $html ); |
| 591 |
$response->header( 'Content-Type', 'text/html; charset=utf-8' ); |
| 592 |
return $response; |
| 593 |
} |
| 594 |
|
| 595 |
/** |
| 596 |
* Permission check for the start endpoint — any logged-in user. |
| 597 |
* The per-relay `capabilities` gate runs in the callback itself |
| 598 |
* so capability denial returns the canonical service-not-allowed |
| 599 |
* error rather than the REST-level "forbidden". |
| 600 |
* |
| 601 |
* @return true|WP_Error |
| 602 |
*/ |
| 603 |
function openstation_rest_oauth_start_permission() { |
| 604 |
if ( ! is_user_logged_in() ) { |
| 605 |
return new WP_Error( |
| 606 |
'rest_forbidden', |
| 607 |
__( 'You must be logged in to start an OAuth flow.', 'desktop-mode' ), |
| 608 |
array( 'status' => 401 ) |
| 609 |
); |
| 610 |
} |
| 611 |
return true; |
| 612 |
} |
| 613 |
|
| 614 |
/** |
| 615 |
* Register the OAuth REST routes on `rest_api_init`. |
| 616 |
* |
| 617 |
* @return void |
| 618 |
*/ |
| 619 |
function openstation_register_oauth_rest_routes() { |
| 620 |
register_rest_route( |
| 621 |
'desktop-mode/v1', |
| 622 |
'/oauth/start', |
| 623 |
array( |
| 624 |
'methods' => WP_REST_Server::CREATABLE, |
| 625 |
'callback' => 'openstation_rest_oauth_start', |
| 626 |
'permission_callback' => 'openstation_rest_oauth_start_permission', |
| 627 |
'args' => array( |
| 628 |
'service' => array( |
| 629 |
'required' => true, |
| 630 |
'type' => 'string', |
| 631 |
), |
| 632 |
), |
| 633 |
) |
| 634 |
); |
| 635 |
|
| 636 |
register_rest_route( |
| 637 |
'desktop-mode/v1', |
| 638 |
'/oauth/callback', |
| 639 |
array( |
| 640 |
'methods' => WP_REST_Server::READABLE, |
| 641 |
'callback' => 'openstation_rest_oauth_callback', |
| 642 |
// Public — the route is reached via a redirect from the |
| 643 |
// remote service. Auth is the state nonce + (later) the |
| 644 |
// per-service capabilities check on the start side. |
| 645 |
'permission_callback' => '__return_true', |
| 646 |
'args' => array( |
| 647 |
'state' => array( |
| 648 |
'required' => true, |
| 649 |
'type' => 'string', |
| 650 |
), |
| 651 |
'code' => array( |
| 652 |
'required' => false, |
| 653 |
'type' => 'string', |
| 654 |
), |
| 655 |
'error' => array( |
| 656 |
'required' => false, |
| 657 |
'type' => 'string', |
| 658 |
), |
| 659 |
), |
| 660 |
) |
| 661 |
); |
| 662 |
} |
| 663 |
add_action( 'rest_api_init', 'openstation_register_oauth_rest_routes' ); |
| 664 |
|