-
Notifications
You must be signed in to change notification settings - Fork 41
example oauth relay
Every plugin that integrates with an external service (Tumblr, Mastodon, Bluesky, Spotify, Discord, …) reaches for the same five-step OAuth dance: generate a state nonce, persist it in a transient, open a popup, listen for the callback's postMessage, exchange the auth code for tokens, hand them to the plugin to store. ~120 LOC of fiddly lifecycle plumbing per plugin.
The framework ships a relay that owns those five steps. Plugins declare only what's plugin-specific: the authorize / token URLs, the client credentials, and a token-storage callback.
Stable.
add_action( 'init', function () {
openstation_register_oauth_relay( 'tumblrlike', array(
'authorize_url' => 'https://www.example.com/oauth2/authorize',
'token_url' => 'https://api.example.com/oauth2/token',
'client_id' => MYPLUGIN_CLIENT_ID,
'client_secret' => MYPLUGIN_CLIENT_SECRET,
'scope' => 'basic write',
'on_success' => function ( $user_id, $tokens, $service ) {
// Persist tokens however your plugin needs.
update_user_meta( $user_id, 'myplugin_tokens', $tokens );
},
) );
} );Need to undo it? openstation_unregister_oauth_relay( $service ) removes a previously registered relay — the mirror of openstation_register_oauth_relay(), handy for plugins that register conditionally and for PHPUnit teardowns.
document.getElementById( 'connect-button' )
.addEventListener( 'click', async () => {
try {
const result = await wp.os.startOAuth( 'tumblrlike' );
// result === { ok: true, service: 'tumblrlike' }
wp.os.showToast( { message: 'Connected!' } );
} catch ( err ) {
// err.cause carries the failure payload:
// { ok: false, reason: 'invalid_state' | 'authorize_denied' |
// 'token_exchange_failed' | … , message: '…' }
wp.os.showToast( { message: err.message } );
}
} );-
POST /desktop-mode/v1/oauth/start— issues a 32-charstatenonce, persists it in a 10-minute transient keyed by the state value (with the issuinguser_idstored in the transient payload), returns the assembled authorize URL with the state appended. -
window.open( authorize_url, … )— centred popup with sensible window features. -
postMessagelistener — origin-checked againstwindow.location.origin, type-discriminated on'os-oauth-callback'. Cross-origin or wrong-type messages are ignored. -
GET /desktop-mode/v1/oauth/callback?code=…&state=…— server validates and consumes the state (single-use), POSTs totoken_urlwithgrant_type=authorization_code, parses JSON, calls the plugin'son_success, then renders an HTML page thatpostMessages the opener and closes itself. -
Promise resolves with the success payload on the opener side; rejects with a tagged
Error(with the failure payload ascause) on every error path.
- The state nonce — server-issued, server-validated, single-use, transient-backed (no DB writes).
- The popup orchestration — windowing math, a per-service named window target, popup-blocked detection. The popup deliberately keeps its
window.openerreference (nonoopener) so the callback page canpostMessagethe result back. - The opener's
postMessagelistener — origin check, type check, single-fire detachment. - The callback page that
postMessages the opener and closes — framework renders it. - The token-exchange POST — framework
wp_remote_posts thetoken_urlwith the standard parameters and parses JSON.
| Hook | Type | Payload | Use |
|---|---|---|---|
openstation_oauth_relay_registered |
action |
( string $service, array $entry ) (secrets redacted)
|
Observability — log every relay that gets wired up. |
openstation_oauth_relay_connected |
action | ( string $service, int $user_id ) |
Refresh badges, surface a "connected" toast in sibling windows via the activity bus. |
openstation_oauth_authorize_query |
filter | ( array $query, string $service, array $entry ) |
Inject service-specific extras like access_type=offline, prompt=consent, etc. |
The payload.reason discriminator lets your client-side code branch on what went wrong:
| Reason | When |
|---|---|
invalid_state |
State nonce missing, expired, or already consumed (replay attempt). |
authorize_denied |
Provider returned ?error=… — user clicked "deny" on the authorize page. |
unknown_service |
Relay was unregistered between the start and the callback. |
missing_code |
Provider returned a successful redirect without an authorization code (broken provider). |
token_request_failed |
wp_remote_post to the token_url errored at the transport layer (network, DNS, TLS). |
token_exchange_failed |
Token endpoint returned a non-2xx status or non-JSON body. |
on_success_threw |
The plugin's on_success callback threw — tokens may have been received but not persisted. |
Default: any logged-in user can start a relay. Pass capabilities to require specific caps:
openstation_register_oauth_relay( 'admin-only-service', array(
/* … URLs and creds … */
'capabilities' => array( 'manage_options' ),
) );A user without the cap who tries to start the flow gets a openstation_oauth_capability_denied REST error and the popup never opens.
-
State nonces are single-use. The first successful
consume_statedeletes the transient — a replay with the same state fails. -
Origin check on the listener. The opener-side listener only honours
postMessageevents whoseorigin === window.location.origin. A malicious cross-origin tab that knows the user's state can't impersonate the callback page. -
Secrets stay server-side.
client_secretis never passed to the client — the token exchange runs entirely in the REST callback. The redaction also applies to theopenstation_oauth_relay_registeredaction payload so observability logs don't leak credentials. - Capabilities are checked on the start endpoint, NOT on the callback. The callback's gate is the state nonce — which only the user who started the flow has.
-
docs/hooks-reference.md— the public PHP hooks above, each under its own heading. -
api-index.md—wp.os.startOAuthin the JS API table.
This wiki is generated from the docs/ directory — edits made here are overwritten by the next sync.
To change a page, open a pull request against docs/.
Guides
- Development guide
- Releasing openstation
- Agents security model
- API Index
- Architecture
- Bridge protocol — wiring overview
- <os-*> component reference
- Native Desktop Host — Experimental
- Desktop themes
- Dock customization — two registries, one mental model
- The event-driven framework
- Files on the Desktop
- Folder sharing
- Getting Started
- Hooks Reference
- Icons
- JavaScript Reference
- The Living Tree — algorithm definition
- Mio
- Native Windows & Framework Interop
- Plugin compatibility layer
- Progressive Web App (PWA)
- Station Home
- Using openstation from your own plugin
Migration notes
- Migration: built-in activity channels move to the os/ namespace
- Migration: window, wallpaper and widget bundles load on demand
- Migration — the navigation model
- Migration: a native window's tabs move to the window chrome
All examples
- AI Agents — extend and invoke from a plugin
- wp.os.ai.ask() — programmatic AI Copilot
- Tune the AI model config
- Custom arrange-menu action
- Open a child window its owner can't cover
- Style a specific admin page inside the iframe
- Code Blue — register your plugin's log file
- Open a file in the Code editor (deep-link from any window)
- Connect to a window — title-bar button + iframe pub/sub
- Content changes — live-refresh every window listing your type
- Custom window chrome (Experimental)
- Register a custom unfocused-window effect
- Example: render a data table
- Real file storage — react to uploads, gate policy, share from PHP
- React to a window being set free onto the real desktop
- Cross-window devtools — instrumentation primitives
- Add a dock item with a badge
- Decorate the dock without forking the renderer
- Replace the dock rail entirely
- Retune the Drafts widget's AI writing assistant
- Gate OpenStation by role
- Iframe-initiated window opens
- Build a feed reader without the bookkeeping
- Inject data into openStationConfig
- Render a list without losing clicks — renderKeyedList()
- Example: layout primitives (body → panel → row → col)
- Use <os-*> components from a plugin that ships as a zip
- Restyle and drive Mio
- Add an action that works on a whole selection
- WP Explorer — custom post types and their folder
- Add an action button to a WP Explorer preview pane
- Example: native Posts window
- Example: native window with tabs
- Native windows
- Customize note → post conversion
- Send a notification
- OAuth relay — connect to an external service
- OS-file drop
- <os-flyout> — window-scoped sliding card
- Plugins window — extras
- Track who's around — wp.os.presence
- Example: progress bar
- PWA install — surface your own button
- React to window events
- Example: extend the Trash
- Register a slash-command
- Register a desktop theme from a plugin
- Register a game
- Example: register a desktop icon (Jorvy)
- Register a wallpaper
- Register a widget
- Related entities — extend the title bar's "Related" menu
- The native-window render ctx
- Programmatic folder sharing
- Share state across multi-bundle plugins — wp.os.createSharedStore()
- Example: loading spinner
- Add an opt-in card to Station Home
- Accept drops on your desktop icon
- Give a tile two icons, one per state
- Add a row to a window's ⋯ menu
- Example: window activity & the status ring
- Window controls
- Subscribe to window lifecycle events
- Window links — relate windows and restyle the ties (Experimental)
- Window loading state — spinner overlay & ready signal
- Show a banner at the top of a window
- Pulse a window's icon — Window.requestAttention()
- Register a custom window reveal
- Window slots
- Window themes
- Native window with bundle-bound config