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

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