PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.8
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.8
1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 All 34 releases
desktop-mode / includes / oauth-relay.php

oauth-relay.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 0.9.8, at includes/oauth-relay.php

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