OAuth Handlers
Location: /inc/Core/OAuth/
Overview
Data Machine uses a unified base class architecture for authentication providers, eliminating code duplication across all OAuth 1.0a and OAuth 2.0 implementations. All authentication providers extend standardized base classes that centralize option storage, configuration management, and authentication validation.
Base Authentication Architecture (@since v0.2.6)
BaseAuthProvider
Location: /inc/Core/OAuth/BaseAuthProvider.php
Since: v0.2.6
Abstract base class providing core functionality for all authentication providers.
Key Features:
- Centralized option storage and retrieval via WordPress options
- Unified callback URL generation
- Configuration and authentication state checking
- Account data management (save, retrieve, clear)
Core Methods:
// Abstract methods (must be implemented by child classes)
abstract public function get_config_fields(): array;
abstract public function is_authenticated(): bool;
// Concrete methods (inherited by all providers)
public function __construct(string $provider_slug);
public function is_configured(): bool;
public function get_callback_url(): string;
public function get_site_account(): ?array;
public function save_site_account(array $account): bool;
public function delete_site_account(): bool;
public function get_account_for_user(int $user_id): ?array;
public function save_account_for_user(int $user_id, array $account): bool;
public function delete_account_for_user(int $user_id): bool;
public function get_account_for_agent(int $agent_id): ?array;
public function save_account_for_agent(int $agent_id, array $account): bool;
public function delete_account_for_agent(int $agent_id): bool;
public function get_account_for_context(array $context = array()): ?array;
public function get_account(): array;
public function get_config(): array;
public function save_account(array $data): bool;
public function save_config(array $data): bool;
public function clear_account(): bool;
public function get_account_details(): ?array;Storage Pattern:
All providers store data in WordPress options using a consistent structure:
// Data stored in 'datamachine_auth_data' option
[
'provider_slug' => [
'config' => [
'client_id' => '...',
'client_secret' => '...'
],
'account' => [
'access_token' => '...',
'refresh_token' => '...',
'user_id' => '...'
]
]
]Site-wide account API (@since v0.128.0)
Three concrete methods on BaseAuthProvider let callers operate on the shared
site-wide account slot without consulting auth scope policy:
public function get_site_account(): ?array;
public function save_site_account( array $account ): bool;
public function delete_site_account(): bool;Use these methods for shared bot accounts, scheduled flows, and other cases
where the credential is intentionally not owned by a specific user or agent.
get_site_account() returns null when no site-wide account exists. The
legacy context-free get_account() method continues to return an empty array
for missing accounts during the migration window.
Per-user account API (@since v0.123.0)
Three concrete methods on BaseAuthProvider let callers operate on a specific
human user’s credentials, independent of the site-wide account slot:
public function get_account_for_user( int $user_id ): ?array;
public function save_account_for_user( int $user_id, array $account ): bool;
public function delete_account_for_user( int $user_id ): bool;These share the same on-disk shape as the principal-scoped API
(principals[user:<id>][account]), so an account written via either surface
is readable through the other. They differ from the scope-aware
get_account( array $context ) in two ways:
- No scope policy consultation. A caller invoking
get_account_for_user( 101 )has made a deliberate choice — the return value reflects that user’s slot or null. - No site fallback. A missing per-user account always returns
null, never the shared site account. This prevents cross-user leakage in callers that did not explicitly opt in to a site fallback.
Resolution order inside get_account_for_user():
datamachine_resolve_oauth_account_for_userfilter — platform plugins return a non-null array to plug in their own per-user storage (custom table, external KMS, encrypted blob). Returning null lets the default path run. Filter results are NOT re-decrypted: platform plugins are responsible for returning already-plaintext account data.- Default storage at
principals[user:<id>][account](encrypted at rest, decrypted on read). nullif neither yields an account.
Every successful resolve emits a debug-level entry via the
datamachine_log action with { provider, user_id, source }. Failed
lookups do not log (they would dwarf real signal during normal "no account
yet" probes). The token itself is never logged at any level.
Per-agent account API (@since v0.129.0)
Three concrete methods on BaseAuthProvider let callers operate on credentials
delegated to a specific agent, independent of the site-wide and per-user account
slots:
public function get_account_for_agent( int $agent_id ): ?array;
public function save_account_for_agent( int $agent_id, array $account ): bool;
public function delete_account_for_agent( int $agent_id ): bool;These share the same on-disk shape as the principal-scoped API
(principals[agent:<id>][account]), so an account written via either surface is
readable through the other. Like the per-user API, these methods never consult
auth scope policy and never fall back to site-wide storage.
Policy-resolved account context API (@since v0.130.0)
get_account_for_context() is the explicit account lookup for callers that want
provider policy to decide which credential scope applies to an execution
context:
public function get_account_for_context( array $context = array() ): ?array;The method consults datamachine_auth_scope_policy and the same context inputs
as legacy get_account( array $context ): explicit agent_id, explicit
user_id, acting agent/user IDs from PermissionHelper, and the current WP
user. It preserves the current site fallback behavior during the migration
window, but returns null when neither a scoped account nor a site-wide account
exists.
Passing a non-empty context array to get_account() is deprecated as of
v0.131.0. The legacy method still returns an array for backward compatibility,
but callers should use get_account_for_context() when they want
policy-resolved lookup.
Passing a non-empty context array to save_account() or clear_account() is
deprecated as of v0.132.0. Callers should use the explicit site, user, or agent
methods when they know the target scope.
When to use per-user vs. site-wide credentials
Decision flow:
Does the agent need credentials tied to a specific human user?
│
├── Yes ──→ Use per-user API (get_account_for_user)
│ Caller resolves calling_user_id from $payload first.
│
└── No ──→ Use site-wide API (get_account, no context)
Bot accounts, shared posting accounts, scheduled flows.Use per-user for any workflow where the credential authorizes access to
a specific human’s data: "read my files on the upstream service", "post to
my own account", "summarize my mailbox". The agent must know who is asking
— see the calling_user_id section below.
Use site-wide for shared automation: the platform’s own bot account, a single shared posting account, anything where "whose account is this?" is not a meaningful question.
Vendor-plugin pattern
A vendor plugin (data-machine-business, data-machine-socials, etc.)
registers an ability that reads its own credentials. Inside the ability’s
execute callback:
use function DataMachineEngineAIdatamachine_get_calling_user_id;
public function execute_my_handler( array $input, array $context = array() ) {
$calling_user_id = datamachine_get_calling_user_id( $context['payload'] ?? array() );
$provider = my_get_auth_provider(); // returns a BaseAuthProvider subclass
if ( $calling_user_id > 0 ) {
$account = $provider->get_account_for_user( $calling_user_id );
if ( null === $account ) {
return new WP_Error(
'no_per_user_credentials',
__( 'Connect your account before using this tool.', 'my-plugin' )
);
}
} else {
// No human caller — fall back to site-wide or refuse, depending on
// the handler's policy. Shared bot accounts use this branch.
$account = $provider->get_account();
if ( empty( $account['access_token'] ) ) {
return new WP_Error( 'no_credentials', __( 'Not connected.', 'my-plugin' ) );
}
}
// ... use $account['access_token'] to call upstream.
}Revocation
# Site-wide revoke.
wp datamachine auth revoke <handler>
# Per-user revoke.
wp datamachine auth revoke <handler> --user=42wp datamachine auth disconnect <handler> is kept as a deprecated site-wide
alias for revoke so existing scripts continue to run during the CLI verb
transition. New callsites should use revoke.
Or via the abilities surface:
$result = wp_get_ability( 'datamachine/revoke-auth-for-user' )->execute(
array(
'handler_slug' => 'my_handler',
'user_id' => 42,
)
);If the provider exposes a revoke_token_for_user( int $user_id ) method,
the abilities executor calls it BEFORE local deletion so the upstream
credential is invalidated even if local storage is later restored from
backup. An upstream failure logs a warning but does not block local
deletion — losing local access is preferable to leaking credentials.
Calling-user identity in AI invocations
calling_user_id is a new field on every AI invocation payload, alongside
the existing agent_id. It identifies the human user on whose behalf the
agent is acting during this specific invocation:
| Flow | calling_user_id value |
|---|---|
| Chat session | the chat caller’s user ID |
| Pipeline execution | 0 (no human caller; scheduled work) |
| System task | 0 (alt-text, meta, internal-linking, etc.) |
| REST chat with bearer | the bearer-owner user ID |
| processPing health check | 0 (admin user_id borrowed for storage only) |
Tools and directives read it with the consumer helper:
use function DataMachineEngineAIdatamachine_get_calling_user_id;
$calling_user_id = datamachine_get_calling_user_id( $payload );
// Returns 0 when absent, non-numeric, or non-positive.calling_user_id is intentionally distinct from agent_id (the acting
agent identity, same across invocations for one agent) and from pipeline
user_id (the flow/job owner — typically an admin who scheduled the work,
not someone "calling" the agent right now).
Migration
Existing installs need no action. The legacy site-wide flow
(get_account() / save_account() without a context arg) continues to
work unchanged. Tokens stored before the encryption-at-rest feature landed
in v0.88.0 are lazy-migrated on next save. Per-user storage activates only
when callers opt in via get_account_for_user( $user_id ) or by setting
calling_user_id > 0 in the payload.
For production, define DATAMACHINE_OAUTH_ENCRYPTION_KEY is not
required: keys are derived from wp_salt( 'auth' ), which already pulls
from AUTH_KEY / SECURE_AUTH_KEY / LOGGED_IN_KEY in wp-config.php.
Operators who want explicit key isolation can rotate AUTH_KEY to force
re-encryption on next save (but be aware that doing so invalidates any
encrypted values not re-saved).
The account API is moving toward explicit named scope methods so vendor plugins can choose site, user, agent, or policy-resolved credentials without relying on context-array fallback semantics. See OAuth Account Scope API for the proposed migration path.
BaseOAuth1Provider
Location: /inc/Core/OAuth/BaseOAuth1Provider.php
Since: v0.2.6
Base class for OAuth 1.0a authentication providers extending BaseAuthProvider.
Features:
- OAuth1Handler instance for three-legged flow
- Standardized configuration validation (api_key, api_secret)
- Authentication validation (access_token, access_token_secret)
Abstract Methods:
abstract public function get_authorization_url(): string;
abstract public function handle_oauth_callback();Providers Using BaseOAuth1Provider:
Core data-machine does not ship any concrete OAuth1 providers. Extensions register their own — for example, data-machine-socials ships TwitterAuth for tweet publishing.
Example Implementation:
class TwitterAuth extends BaseOAuth1Provider {
public function __construct() {
parent::__construct('twitter');
}
public function get_config_fields(): array {
return [
'api_key' => [
'label' => __('API Key', 'datamachine'),
'type' => 'text',
'required' => true
],
'api_secret' => [
'label' => __('API Secret', 'datamachine'),
'type' => 'text',
'required' => true
]
];
}
public function get_authorization_url(): string {
$config = $this->get_config();
$request_token = $this->oauth1->get_request_token(
'https://api.twitter.com/oauth/request_token',
$config['api_key'],
$config['api_secret'],
$this->get_callback_url(),
'twitter'
);
return $this->oauth1->get_authorization_url(
'https://api.twitter.com/oauth/authenticate',
$request_token['oauth_token'],
'twitter'
);
}
public function handle_oauth_callback() {
$config = $this->get_config();
$this->oauth1->handle_callback(
'twitter',
'https://api.twitter.com/oauth/access_token',
$config['api_key'],
$config['api_secret'],
function($access_token_data) {
return [
'access_token' => $access_token_data['oauth_token'],
'access_token_secret' => $access_token_data['oauth_token_secret'],
'user_id' => $access_token_data['user_id'],
'screen_name' => $access_token_data['screen_name']
];
},
[$this, 'save_account']
);
}
}BaseOAuth2Provider
Location: /inc/Core/OAuth/BaseOAuth2Provider.php
Since: v0.2.0 (enhanced in v0.2.6)
Base class for OAuth 2.0 authentication providers extending BaseAuthProvider.
Features:
- OAuth2Handler instance for authorization code flow
- Standardized configuration validation (client_id, client_secret)
- Authentication validation (access_token presence)
- Account details formatting with username, scope, refresh timestamps
Abstract Methods:
abstract public function get_config_fields(): array;
abstract public function get_authorization_url(): string;
abstract public function handle_oauth_callback();Optional Methods:
public function refresh_token(): bool; // Token refresh implementationProviders Using BaseOAuth2Provider:
Core data-machine ships only EmailAuth (inc/Core/Steps/Fetch/Handlers/Email/EmailAuth.php). Concrete OAuth2 social/event providers live in extension plugins:
data-machine-socials—RedditAuth,FacebookAuth,ThreadsAuth, etc.data-machine-events—DiceFmAuth,TicketmasterAuth, etc.
Example Implementation:
class RedditAuth extends BaseOAuth2Provider {
public function __construct() {
parent::__construct('reddit');
}
public function get_config_fields(): array {
return [
'client_id' => [
'label' => __('Client ID', 'datamachine'),
'type' => 'text',
'required' => true
],
'client_secret' => [
'label' => __('Client Secret', 'datamachine'),
'type' => 'text',
'required' => true
]
];
}
public function get_authorization_url(): string {
$config = $this->get_config();
$state = $this->oauth2->create_state('reddit');
$params = [
'client_id' => $config['client_id'],
'response_type' => 'code',
'state' => $state,
'redirect_uri' => $this->get_callback_url(),
'duration' => 'permanent',
'scope' => 'identity read'
];
return $this->oauth2->get_authorization_url(
'https://www.reddit.com/api/v1/authorize',
$params
);
}
public function handle_oauth_callback() {
$config = $this->get_config();
$this->oauth2->handle_callback(
'reddit',
'https://www.reddit.com/api/v1/access_token',
[
'grant_type' => 'authorization_code',
'code' => $_GET['code'],
'redirect_uri' => $this->get_callback_url()
],
function($token_data) {
return [
'access_token' => $token_data['access_token'],
'refresh_token' => $token_data['refresh_token'],
'username' => $this->fetch_username($token_data['access_token']),
'scope' => $token_data['scope'],
'token_expires_at' => time() + $token_data['expires_in']
];
},
null,
[$this, 'save_account']
);
}
public function refresh_token(): bool {
$account = $this->get_account();
$config = $this->get_config();
// Refresh logic using $account['refresh_token']
// Update account data via $this->save_account()
return true;
}
}Bluesky Authentication (BaseAuthProvider Direct Extension)
Provider: BlueskyAuth
Location: the Bluesky handler in the data-machine-socials extension plugin
Since: v0.1.0 (updated to extend BaseAuthProvider in v0.2.6)
Bluesky authentication uses app password authentication and extends BaseAuthProvider directly. The implementation lives in the data-machine-socials extension plugin; the example below shows the pattern any extension can follow.
Implementation Pattern:
class BlueskyAuth extends BaseAuthProvider {
public function __construct() {
parent::__construct('bluesky');
}
public function get_config_fields(): array {
return [
'username' => [
'label' => __('Bluesky Handle', 'datamachine'),
'type' => 'text',
'required' => true,
'description' => __('Your Bluesky handle (e.g., user.bsky.social)', 'datamachine')
],
'app_password' => [
'label' => __('App Password', 'datamachine'),
'type' => 'password',
'required' => true,
'description' => __('Generate an app password at bsky.app/settings/app-passwords', 'datamachine')
]
];
}
public function is_authenticated(): bool {
$config = $this->get_config();
return !empty($config) &&
!empty($config['username']) &&
!empty($config['app_password']);
}
public function authenticate(): bool {
$config = $this->get_config();
// Validate credentials with API
$session = $this->create_session(
$config['username'],
$config['app_password']
);
if (is_wp_error($session)) {
return false;
}
// Store session data
return $this->save_config([
'username' => $config['username'],
'app_password' => $config['app_password'],
'session_token' => $session['accessJwt'],
'did' => $session['did']
]);
}
}OAuth Handler Services
OAuth2Handler
Location: /inc/Core/OAuth/OAuth2Handler.php
Centralized OAuth 2.0 authorization code flow handler used by all OAuth2 providers.
Key Methods:
public function create_state(string $provider_key): string;
public function verify_state(string $provider_key, string $state): bool;
public function get_authorization_url(string $auth_url, array $params): string;
public function handle_callback(
string $provider_key,
string $token_url,
array $token_params,
callable $account_details_fn,
?callable $token_transform_fn = null,
?callable $save_fn = null
): bool;State Management:
- CSRF protection via WordPress transients
- 15-minute expiration window
- Automatic cleanup on verification
Token Exchange:
- Authorization code to access token exchange
- Optional token transformation (e.g., Meta long-lived tokens)
- Account details retrieval via callback
- Storage via custom save function or default filter
OAuth1Handler
Location: /inc/Core/OAuth/OAuth1Handler.php
Centralized OAuth 1.0a three-legged flow handler used by OAuth1 providers.
Key Methods:
public function get_request_token(
string $request_token_url,
string $consumer_key,
string $consumer_secret,
string $callback_url,
string $provider_key = 'oauth1'
): array|WP_Error;
public function get_authorization_url(
string $authorize_url,
string $oauth_token,
string $provider_key = 'oauth1'
): string;
public function handle_callback(
string $provider_key,
string $access_token_url,
string $consumer_key,
string $consumer_secret,
callable $account_details_fn,
?callable $save_fn = null
): bool;Temporary Token Management:
- Transient storage with provider and token scoping
- 15-minute expiration for request tokens
- Automatic cleanup after exchange
Provider Registration via Filters
Concrete OAuth providers self-register via the datamachine_auth_providers filter (typically through HandlerRegistrationTrait). Core data-machine does not ship a central providers directory; every concrete provider lives next to its handler in either core or an extension plugin.
In core data-machine:
EmailAuth—inc/Core/Steps/Fetch/Handlers/Email/EmailAuth.php
In extension plugins:
data-machine-socials— Bluesky, Twitter, Reddit, Facebook, Threads, etc.data-machine-events— Dice.fm, Ticketmaster, etc.
Reusable provider pattern:
When multiple handlers in the same plugin need the same OAuth2 provider (e.g. a fetch and publish handler against the same API), implement the auth provider once and inject it into both handlers. The shared instance lives next to its handlers (e.g. <plugin>/inc/Handlers/<Service>/<Service>Auth.php).
class MyServiceFetch extends FetchHandler {
private $auth;
public function __construct() {
$this->auth = new MyServiceAuth();
}
public function fetch($flow_step_config, $job_id) {
$access_token = $this->auth->get_service();
if (is_wp_error($access_token)) {
return $this->errorResponse($access_token->get_error_message());
}
// Use access token for API calls
}
}Provider Integration Patterns
Configuration Storage
All providers use BaseAuthProvider methods for configuration storage:
// Save configuration
$config_data = [
'client_id' => $client_id,
'client_secret' => $client_secret
];
$this->save_config($config_data);
// Retrieve configuration
$config = $this->get_config();
$client_id = $config['client_id'] ?? '';Account Data Storage
All providers use BaseAuthProvider methods for account data:
// Save account data
$account_data = [
'access_token' => $access_token,
'refresh_token' => $refresh_token,
'user_id' => $user_id,
'username' => $username
];
$this->save_account($account_data);
// Retrieve account data
$account = $this->get_account();
$access_token = $account['access_token'] ?? '';
// Clear account data
$this->clear_account();Authentication Checks
Base classes provide standardized authentication checks:
// Check if configured
if (!$this->is_configured()) {
return new WP_Error('not_configured', 'Provider not configured');
}
// Check if authenticated
if (!$this->is_authenticated()) {
return new WP_Error('not_authenticated', 'User not authenticated');
}Security Features
State Nonce Protection (OAuth2):
- CSRF protection via WordPress transient system
- 15-minute expiration window
- Automatic cleanup on verification
Temporary Token Management (OAuth1):
- Transient storage with provider and token scoping
- 15-minute expiration for request tokens
- Automatic cleanup after exchange
Input Sanitization:
- All callback parameters sanitized via
sanitize_text_field() - WordPress
wp_unslash()before sanitization - Comprehensive error handling
Redirect Security:
- Success/error redirects to admin settings page
- Error codes passed via query parameters
- Admin capability required for OAuth URLs
Centralized Storage:
- All credentials stored in WordPress options
- No direct database access from providers
- Consistent encryption and security patterns
Error Handling
OAuth2 Errors:
oauth_denied– User denied authorizationinvalid_state– State verification failedtoken_exchange_failed– Token exchange errortoken_transform_failed– Token transformation erroraccount_fetch_failed– Account details retrieval errorstorage_failed– Account data storage error
OAuth1 Errors:
access_denied– User denied accessmissing_parameters– Missing oauth_token or oauth_verifiertoken_secret_expired– Temporary token secret expiredaccess_token_failed– Access token exchange failedstorage_failed– Account data storage errorrequest_token_failed– Request token retrieval failed
Logging:
All OAuth operations logged via datamachine_log action:
do_action('datamachine_log', 'info', 'OAuth2: Authentication successful', [
'provider' => $provider_key,
'account_id' => $account_data['id']
]);
do_action('datamachine_log', 'error', 'OAuth1: Failed to get access token', [
'provider' => $provider_key,
'http_code' => $http_code
]);Benefits of Base Class Architecture
Code Elimination:
- Removes duplicated storage logic across all providers
- Eliminates redundant configuration validation code
- Centralizes callback URL generation
- Unified authentication state checking
Consistency:
- Identical option storage patterns across all providers
- Standardized error handling and logging
- Uniform security implementation
- Consistent API for all authentication types
Maintainability:
- Single point of update for storage improvements
- Centralized security enhancements
- Easier debugging with consistent patterns
- Reduced testing surface area
Extensibility:
- New providers integrate via simple base class extension
- Minimal boilerplate required for new authentication types
- Inherited functionality ensures feature parity
- Clear extension points for custom behavior
Migration from Legacy Pattern
Before (v0.2.5 and earlier):
class RedditAuth {
public function get_account() {
$all_auth = get_option('datamachine_auth_data', []);
return $all_auth['reddit']['account'] ?? [];
}
public function save_account(array $data) {
$all_auth = get_option('datamachine_auth_data', []);
$all_auth['reddit']['account'] = $data;
return update_option('datamachine_auth_data', $all_auth);
}
public function is_authenticated(): bool {
$account = $this->get_account();
return !empty($account) &&
!empty($account['access_token']);
}
}After (v0.2.6):
class RedditAuth extends BaseOAuth2Provider {
public function __construct() {
parent::__construct('reddit');
}
// Inherits: get_account(), save_account(), is_authenticated()
// Only implements provider-specific logic
}Elimination:
- Removed ~50 lines of storage code per provider
- Removed ~30 lines of validation code per provider
- Eliminated 6 duplicate implementations of identical patterns
- Reduced authentication provider code by approximately 60%
Related Documentation
- Core Filters – OAuth service discovery filters
- Twitter Handler – OAuth1 implementation example
- Reddit Handler – OAuth2 implementation example
- Facebook Handler – OAuth2 with token transformation
- Bluesky Handler – Direct BaseAuthProvider extension example
- Settings Configuration – OAuth credential management
Implementation: /inc/Core/OAuth/ directory ships BaseAuthProvider, BaseOAuth1Provider, and BaseOAuth2Provider base classes plus the OAuth1Handler and OAuth2Handler services. Concrete providers ship in core (EmailAuth) and in extension plugins (data-machine-socials, data-machine-events).
Architecture: Inheritance-based provider system with centralized storage and validation