PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.1
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.1
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.1, at includes/oauth-relay.php

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