WP Manifestindependent plugin directory
manifest / users / wa-identity-bridge

WA Identity Bridge

Signed magic-link primitives + optional WP_User resolver so any WordPress plugin can offer a WhatsApp-as-auth flow. Extracted from Mantia.

★ 0stars

Install

Download source zip No release zip yet

This is the repository archive. It installs, but the folder name will carry the branch suffix and updates will not flow.

Install straight from GitHub with WP-CLI:

wp plugin install https://github.com/lezama/wa-identity-bridge/archive/refs/heads/main.zip
No built-in updater. New versions won't appear under Dashboard → Updates. Install new releases yourself, or let Git Updater track the repository.

WordPress plugin that gives consumers (other plugins, themes, custom code) the building blocks for a WhatsApp-as-auth flow: signed magic links from a bot's outgoing message to a logged-in browser session, plus an optional WP_User resolver keyed by phone, plus a hardened role that can't log in via password or reach wp-admin.

Pairs naturally with openclawp (WhatsApp Cloud API ingress) and any plugin that wants chat-to-web continuity.

Status

Standalone plugin extracted from Mantia's includes/wa-identity-bridge/ in 2026-05. Mantia and WP Carpeta are the two reference consumers — both use the same boot pattern with their own role slug and endpoint path. Anything else built on agents-api + openclawp can adopt the same shape.

Install

Drop into wp-content/plugins/ and activate. Then in your consumer plugin's bootstrap:

add_filter( 'wa_identity_bridge_role_slug',      fn () => 'my_app_user' );
add_filter( 'wa_identity_bridge_endpoint_path',  fn () => 'my-app/auth' );
add_filter( 'wa_identity_bridge_path_whitelist', fn () => array( '/my-app/' ) );

WA_Identity_Bridge::boot();

Activation is consumer-driven by design — calling ::boot() from your plugin's bootstrap reads the configured filters and wires the role + endpoint. Calling it twice from two different consumers is safe (idempotent).

Full integration patterns (Mantia, WP Carpeta, read-only signed URLs): see docs/consumer-integration.md.

What it does

Two layers, both optional:

  1. Signed URL primitive — sign_link(payload, path) / verify_link(token). Consumer-defined opaque payload, library handles HMAC, expiry, path-binding, single-use enforcement.

  2. User resolver — resolve_or_create(phone, name) returns a WP_User keyed by phone, creating one with the configured role + placeholder email if it doesn't exist yet. Consumers without a canonical identity model get find-or-create + lazy-on-click for free; consumers with one (a CPT, custom table) hook the redemption action and call login_as() themselves.

Plus: a dedicated WP role (default whatsapp_user) with password login

  • wp-admin blocked, so users entering via magic link can't escape into the dashboard.

Two ways to use it

Simple consumer (no existing identity model):

add_filter( 'wa_identity_bridge_role_slug', fn () => 'bot_user' );
add_filter( 'wa_identity_bridge_path_whitelist', fn () => array( '/app/' ) );
WA_Identity_Bridge::boot();

// Bot sends a link — payload includes 'phone' so the default redemption
// handler will find-or-create + log in automatically on click:
$url = WA_Identity_Bridge::sign_link(
    array( 'phone' => $phone, 'name' => $name ),
    '/app/dashboard/'
);

Consumer with existing identity:

WA_Identity_Bridge::boot();

// Attach your own redemption handler at default priority so it wins
// over the library's auto-resolve fallback:
add_action( 'wa_identity_bridge_redemption', function ( $payload, $path ) {
    $phone = (string) ( $payload['phone'] ?? '' );
    $user  = my_plugin_find_or_create_wp_user_by_phone( $phone );
    if ( $user ) {
        WA_Identity_Bridge::login_as( $user->ID );
    }
}, 10, 2 );

Why the resolver is optional

Most managed WP hosts (wpcom Atomic among them) reject inserts with non-public TLDs in user_email — .local, .invalid, etc. silently fail with "Not enough data to create this user". The library defaults the placeholder domain to wa.<your-site-host> to inherit a valid TLD; override via wa_identity_bridge_email_domain if your host is stricter.

Eagerly materialising a WP_User on every WhatsApp inbound is fine for most workloads, but if you have bursty unauthenticated traffic and care about every byte of wp_users table, leave the user resolver off and keep your canonical identity in a CPT or custom table.

Public API

WA_Identity_Bridge::boot();
// Idempotent. Call from your plugin's bootstrap.

$url = WA_Identity_Bridge::sign_link(
    [ 'phone' => '598...', 'name' => 'Tincho' ],  // payload (opaque)
    '/penca/me/',                                   // path to redirect to
    [ 'ttl' => DAY_IN_SECONDS, 'single_use' => false ]
);
// → 'https://site/wa-auth/?wa_auth_t=<token>&wa_auth_go=%2Fpenca%2Fme%2F'

$payload = WA_Identity_Bridge::verify_link( $token, $expected_path );
// → array (your payload) on success, WP_Error on failure. Callers
//   should treat all errors as "expired or invalid" when surfacing
//   to end users; do not leak the specific code.

WA_Identity_Bridge::login_as( $user_id );
// wp_set_current_user + wp_set_auth_cookie + fires
// 'wa_identity_bridge_logged_in' for audit hooks.

// Optional user resolver — use either at chat time (eager) or let the
// library's default redemption handler call it lazily on click:
$user = WA_Identity_Bridge::resolve_or_create( $phone, $name );
$user = WA_Identity_Bridge::find_by_phone( $phone ); // never creates

The redemption hook

The library's endpoint validates the token + path and then fires:

do_action( 'wa_identity_bridge_redemption', array $payload, string $path );

Consumers attach to this and do their identity resolution + login:

add_action( 'wa_identity_bridge_redemption', function ( $payload, $path ) {
    $phone = (string) ( $payload['phone'] ?? '' );
    $name  = (string) ( $payload['name'] ?? '' );
    if ( '' === $phone ) {
        return;
    }
    // Consumer-owned: find an existing wp_user by phone meta, or create one.
    $user = my_plugin_find_or_create_wp_user( $phone, $name );
    if ( $user instanceof WP_User ) {
        WA_Identity_Bridge::login_as( $user->ID );
    }
}, 10, 2 );

After the action chain finishes the library redirects to $path with a 302 — so any layer that fails to log the user in still lands the visitor on the destination (which may be a public-by-token page).

Configuration filters

All optional; defaults are sane for single-consumer installs.

Filter Default Purpose
wa_identity_bridge_role_slug 'whatsapp_user' Slug for the registered role.
wa_identity_bridge_role_caps ['read' => true] Caps map for add_role.
wa_identity_bridge_endpoint_path 'wa-auth' URL path for the auth endpoint. Slashes allowed ('penca/auth').
wa_identity_bridge_path_whitelist [] (permissive) Leading-slash path prefixes that ?go= is allowed to redirect to. Strongly recommended.
wa_identity_bridge_default_ttl DAY_IN_SECONDS TTL for sign_link() calls without ttl.
wa_identity_bridge_default_single_use false Single-use for sign_link() calls without single_use.
wa_identity_bridge_block_wp_login true Block password login + wp-admin for the role.
wa_identity_bridge_expired_redirect_url home_url('/') Where to send users whose token failed verification.
wa_identity_bridge_user_login_prefix '' Prefix prepended to phone before storing as user_login (use to namespace if multiple consumers share an install).
wa_identity_bridge_email_domain wa.<site-host> Domain for the placeholder user_email. Avoid non-public TLDs (.local, .invalid) — they're rejected by Atomic-platform-style filters.

Actions

Action Args Purpose
wa_identity_bridge_redemption $payload, $path Fires on every valid token redemption. Consumer hooks at priority ≤ 999 to pre-empt the default auto-resolve-and-login behaviour.
wa_identity_bridge_logged_in $user_id Fires after login_as() succeeds. Useful for audit logs.
wa_identity_bridge_user_created $user_id, $phone_e164, $display_name Fires once per user, immediately after a successful wp_insert_user from the resolver. Use to seed domain-specific state.

Token format

payload_b64 . '.' . hmac_sha256(payload_b64, wp_salt('auth') . '|wa_identity_bridge')
envelope = { d: <your payload>, exp: <unix>, nonce: <hex>, su: 0|1, path: <path> }

The path is part of the signed envelope, so a valid token cannot be reused against a different ?go= destination.

Security model

  • Phone-at-WhatsApp-API is the trust anchor. WhatsApp's Cloud API authenticates the sender at the carrier level; consumers trust that and stash the phone in the signed payload.
  • Path whitelist prevents ?go= open-redirect. Configure one.
  • Single-use is per-link, not global — use it for destructive or one-shot actions (account deletion, password reset). For nav links, multi-use is friendlier (a user reopening the same chat can click the same link twice and it just works).
  • wp-admin is blocked for the role — the only sanctioned entry path is the magic link.

Consumer example (Mantia)

add_filter( 'wa_identity_bridge_role_slug', fn () => 'mantia_player' );
add_filter( 'wa_identity_bridge_endpoint_path', fn () => 'penca/auth' );
add_filter( 'wa_identity_bridge_path_whitelist', fn () => array( '/penca/' ) );

WA_Identity_Bridge::boot();

add_action( 'wa_identity_bridge_redemption', function ( $payload, $path ) {
    $phone = (string) ( $payload['phone'] ?? '' );
    $name  = (string) ( $payload['name'] ?? '' );
    if ( '' === $phone ) {
        return;
    }
    $user = Mantia_Identity::wp_user_for_phone( $phone, $name );
    if ( $user instanceof WP_User ) {
        WA_Identity_Bridge::login_as( $user->ID );
    }
}, 10, 2 );

// When sending a link in chat:
$url = WA_Identity_Bridge::sign_link(
    array( 'phone' => $phone, 'name' => $name ),
    '/penca/me/'
);

Tests

ssh prod "cd htdocs && wp eval-file \
  wp-content/plugins/mantia/includes/wa-identity-bridge/tests/test-bridge.php"

Covers: sign + verify round-trip, signature tampering, path tampering, single-use enforcement, TTL, path whitelist, role registration.

Extraction (when ready)

git subtree split --prefix=includes/wa-identity-bridge -b wa-identity-bridge
# push the branch to a fresh repo, add a plugin.php bootstrap stub,
# install in consumers via composer or as a must-use plugin.

The library has zero references outside its directory.