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

664 lines 21.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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 `&quot;` — 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 `{&quot;ok&quot;: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