diff --git a/README.md b/README.md new file mode 100644 index 0000000..2641c03 --- /dev/null +++ b/README.md @@ -0,0 +1,168 @@ + +# Universal OID4VP — WordPress Plugin + +Request and display **verifiable presentations** (OpenID for Verifiable Presentations) inside WordPress using Gutenberg blocks. The plugin renders a QR (or link) to start the presentation flow, polls the wallet’s status, redirects on success, and lets you show attributes from the verified data on your pages. + +--- + +## Requirements + +* WordPress **6.6+**, PHP **7.2+** (see plugin header). +* Ability for the server to make outbound HTTP calls (token + OID4VP API). +* Sessions: the plugin starts a PHP session when needed. + +--- + +## Installation + +**From source (recommended for dev):** + +1. Clone the repo and install deps: + + ```bash + npm install + npm run build # or: npm run start (watch mode) + npm run plugin-zip # optional: build a distributable zip + ``` + + Scripts are provided by `@wordpress/scripts`. +2. Copy the plugin folder (or the built zip) into `wp-content/plugins/`. +3. Activate **Universal OID4VP** in **WP Admin → Plugins**. + +--- + +## Admin settings (global defaults) + +**WP Admin → Settings → Universal OID4VP** + +Fields (used as defaults for blocks; per-block overrides are supported): + +* **OpenID4VP Endpoint** – Base URL of your OID4VP service. (The /oid4vp path section is appended by the plugin) +* **Token Endpoint** – OAuth2 token endpoint used for **client_credentials**. +* **API client id / secret** – Client used to call the OID4VP backend. +* **Login url** *(optional)* – Page URL that shows a **login with wallet** button on the WP login form. +* **Username attribute** *(optional)* – Dot path to the username inside the verified data (used for optional auto-login). +* **Redirect user to original page** – When logging in, return to the page the user came from. + +> The settings page and option storage are implemented in `adminSettings/openid4vp-admin-settings.php` and `...-options.php`. + +--- + +## Blocks (Gutenberg) + +This plugin registers **three** blocks you can add to any page/post. All block properties are edited in the right-hand **Settings** sidebar when the block is selected. + +### 1) OID4VP – Request data from **personal** wallet + +**Block name:** `universal-openid4vp-plugin/openid4vp-exchange` +**What it does:** Renders a QR code (and link) that starts a personal-wallet presentation flow, then polls for completion and redirects to your **Success URL**. + +**Key properties (sidebar):** + +* **Query id** *(required)* – The query identifier your backend expects. +* **Success url** *(required)* – Where to go after a successful verification. The plugin appends `?oid4vp_cid=`. +* **Advanced (optional):** `OpenID4VP Endpoint`, `Token endpoint`, `API client id/secret`, `Client id`, `Request URI method`, `Response type`, `Response mode`. +* **QR options:** enable/disable + `qrSize`, `qrColorDark`, `qrColorLight`, `qrPadding`. + +**How the request URL is built:** +When the block renders, the server obtains an access token (client credentials) and calls: +`/oid4vp/backend/auth/requests` +The path segment is **automatically appended** to the base endpoint you configure. If your deployment uses a different prefix, set the base endpoint accordingly. + +**Same-device vs cross-device:** + +* On **mobile/same-device**, the plugin includes `direct_post_response_redirect_uri` so the **wallet** redirects straight to your Success URL. +* On **desktop/cross-device**, the plugin stores the Success URL temporarily and the browser page **polls** for status; when verified, it redirects to your Success URL (with the `oid4vp_cid` query parameter). + +### 2) OID4VP – Request data from **organizational** wallet + +**Block name:** `universal-openid4vp-plugin/openid4vp-exchange-org-wallet` +**What it does:** Renders a small form to enter an **organization wallet URL**, then starts the presentation flow for that wallet. On success, you end up at the Success URL (same `oid4vp_cid` behavior). + +**Key properties:** same as the personal wallet block, minus the QR styling options. A small client-side script posts the wallet URL and opens the `request_uri`. + +### 3) OID4VP – Display data + +**Block name:** `universal-openid4vp-plugin/openid4vp-attribute` +**What it does:** Displays a **single attribute** from the verified presentation on your **Success** page. It reads the correlation ID from the `oid4vp_cid` query parameter, fetches the stored data, and prints the value (or an `` if it’s a base64 data-URI). + +**Properties:** + +* **Credential query id** – The credential’s `id` you want to read from (e.g., `clubcard-v1`). +* **VP attribute label** – A friendly label rendered before the value. +* **VP attribute name** – **Dot-path** to the field inside that credential (e.g., `claims.personData.name`). + +> The Display block renders server-side via `render.php` and traverses the dot-path inside the stored credential map `[credential.id] → ...`. If the value looks like a base64 image data-URI, it outputs an `` tag. + +--- + +## End-to-end flow + +1. **Editor:** Create a **Start** page and insert the **Personal Wallet** (or **Org Wallet**) block. Set **Query id** and **Success url**. Optionally tweak advanced/QR settings. +2. **Success page:** Insert one or more **Display data** blocks. For each, set `Credential query id` and an attribute path (e.g., `claims.personData.name`). +3. **Runtime:** + + * On render, the server calls your token endpoint (client credentials) and then posts to `/oid4vp/backend/auth/requests` to obtain `correlation_id`, `status_uri`, `request_uri`, and (optionally) a QR. These are kept in the PHP session for the active flow. + * The front-end script polls the plugin’s AJAX action every 2 seconds. When the OID4VP backend reports **`authorization_response_verified`**, the plugin stores the **verified credential claims** in a transient keyed by the correlation ID and redirects to **Success URL** with `?oid4vp_cid=`. + * The Success page’s Display blocks read the transient by that ID and render the configured attributes. + +--- + +## How it works (under the hood) + +* **Block registration:** All three blocks are registered on `init`. +* **Creating the request:** `universal_openid4vp_sendVpRequest($attributes)` obtains a client access token, detects mobile vs desktop, adds QR options, and calls `.../oid4vp/backend/auth/requests`. It stores `correlationId`, `statusUri`, and the token in the PHP session. For cross-device flows it stores the Success URL in a **transient** keyed by the correlation ID. +* **Polling:** `pollStatus.js` hits the `universal_openid4vp_poll_status_ajax` action. The handler calls the saved **status URI** server-side; when verified, it writes **credential_claims** into a transient `oid4vp_presentation_`, retrieves the Success URL, appends `oid4vp_cid=`, and returns it to the browser to redirect. +* **Displaying data:** `presentationAttribute/render.php` extracts `oid4vp_cid` from the URL, fetches `oid4vp_presentation_`, walks the dot-path, and renders either text or ``. + +**AJAX hooks exposed:** + +* `universal_openid4vp_poll_status_ajax` – Poll presentation status + redirect URL. +* `universal_openid4vp_presentation_exchange_ajax` – Start org-wallet flow and return `request_uri`. + +--- + +## Optional: Wallet-based login + +If **Login url** in settings matches the **current page** being polled, the plugin can auto-log a user in: it reads the **Username attribute** from the verified payload, finds that WP user, and sets auth cookies; logged-in users get redirected to the admin dashboard. (This is entirely optional.) + +--- + +## Security & privacy + +* **Server-side requests:** Token + OID4VP API calls are made from the server. Secrets are not exposed to the browser. +* **Ephemeral storage:** Verified data and Success URL are kept in **WordPress transients** (default TTL ~10 minutes). Sessions only hold short-lived IDs/tokens for the active flow. Clear-down happens after success. + +--- + +## Troubleshooting + +* **No redirect after scan:** Confirm **Success URL** is set and reachable; check that the page includes the polling script (`viewScript`), and that your OID4VP backend returns `authorization_response_verified`. Also verify your server can reach the **status URI**. +* **Nothing displayed on Success page:** Ensure the URL contains `oid4vp_cid=...` and that your **Display data** block uses the correct **Credential query id** and attribute **dot-path**. +* **Different backend path:** The plugin calls `/oid4vp/backend/auth/requests`. If your deployment uses a different prefix, adjust the base endpoint accordingly. + +--- + +## Development + +* Main loader: `universal-openid4vp-plugin.php` (hooks, block registration). +* Core class: `src/OpenID4VP.php` (includes + defaults). +* Blocks under `build/`: + + * `presentationExchange` (personal wallet) – server render + `pollStatus.js`. + * `presentationExchangeOrgWallet` (org wallet) – server render + `submitPresentationRequest.js`. + * `presentationAttribute` (display). + +Package scripts: `build`, `start`, `plugin-zip`, `wp-env`. + +--- + +## License + +GPL-2.0-or-later. See plugin header. + +--- + +### Notes on terminology + +This plugin implements **OID4VP** (presentations). The request is posted to `.../oid4vp/backend/auth/requests` derived from your **OpenID4VP Endpoint**. diff --git a/package-lock.json b/package-lock.json index 7844beb..de8e439 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "openid4vp-plugin", + "name": "universal-openid4vp-plugin", "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "openid4vp-plugin", + "name": "universal-openid4vp-plugin", "version": "0.3.0", "license": "GPL-2.0-or-later", "devDependencies": { diff --git a/readme.txt b/readme.txt deleted file mode 100644 index ce994e1..0000000 --- a/readme.txt +++ /dev/null @@ -1,58 +0,0 @@ -=== Copyright Date Block === -Contributors: Credenco B.V. -Tags: block -Tested up to: 6.6 -Stable tag: 0.3.0 -License: GPL-2.0-or-later -License URI: https://www.gnu.org/licenses/gpl-2.0.html - -Display your site's copyright date. - -== Description == - -This is the long description. No limit, and you can use Markdown (as well as in the following sections). - -For backwards compatibility, if this section is missing, the full length of the short description will be used, and -Markdown parsed. - -== Installation == - -This section describes how to install the plugin and get it working. - -e.g. - -1. Upload the plugin files to the `/wp-content/plugins/openid4vp-block` directory, or install the plugin through the WordPress plugins screen directly. -1. Activate the plugin through the 'Plugins' screen in WordPress - - -== Frequently Asked Questions == - -= A question that someone might have = - -An answer to that question. - -= What about foo bar? = - -Answer to foo bar dilemma. - -== Screenshots == - -1. This screen shot description corresponds to screenshot-1.(png|jpg|jpeg|gif). Note that the screenshot is taken from -the /assets directory or the directory that contains the stable readme.txt (tags or trunk). Screenshots in the /assets -directory take precedence. For example, `/assets/screenshot-1.png` would win over `/tags/4.3/screenshot-1.png` -(or jpg, jpeg, gif). -2. This is the second screen shot - -== Changelog == - -= 0.1.0 = -* Release - -= 0.2.0 = -* Release - -== Arbitrary section == - -You may provide arbitrary sections, in the same format as the ones above. This may be of use for extremely complicated -plugins where more information needs to be conveyed that doesn't fit into the categories of "description" or -"installation." Arbitrary sections will be shown below the built-in sections outlined above. diff --git a/src/OpenID4VP.php b/src/OpenID4VP.php index 7f7f935..e2794e1 100644 --- a/src/OpenID4VP.php +++ b/src/OpenID4VP.php @@ -63,3 +63,4 @@ public function install() { public function upgrade() { } } + diff --git a/src/presentationAttribute/render.php b/src/presentationAttribute/render.php index 5d054d9..c9d7929 100644 --- a/src/presentationAttribute/render.php +++ b/src/presentationAttribute/render.php @@ -9,47 +9,18 @@ * * @see https://github.com/WordPress/gutenberg/blob/trunk/docs/reference-guides/block-api/block-metadata.md#render */ -// do a session a start -if (session_status() === PHP_SESSION_NONE) { - session_start(); -} - -// Retrieve the presentation response -$presentationResponse = isset($_SESSION['presentationResponse']) ? $_SESSION['presentationResponse'] : null; -$presentationStatusUri = isset($_SESSION['presentationStatusUri']) ? $_SESSION['presentationStatusUri'] : null; - -if (!empty($_SESSION['successUrl']) && !empty($presentationStatusUri)) { - $headers = array('Content-Type' => 'application/json'); - if (isset($_SESSION['accessToken'])) { - $headers['Authorization'] = 'Bearer ' . $_SESSION['accessToken']; - } - - $response = wp_remote_get( $presentationStatusUri, array( - 'headers' => $headers, - 'timeout' => 45, - 'redirection' => 5, - 'blocking' => true - )); - $body = wp_remote_retrieve_body($response); - error_log('Result: '. $body); +// Get correlation_id from URL parameter +$correlationId = isset($_GET['oid4vp_cid']) ? sanitize_text_field($_GET['oid4vp_cid']) : null; - $successUrl = null; - if ( json_decode( $body ) != null ) { - $response = json_decode( $body, true); - $credentialClaims = $response['verified_data']['credential_claims']; - foreach ($credentialClaims as $credential) { - if (empty($_SESSION['presentationResponse'])) { - $_SESSION['presentationResponse'] = []; - } - $_SESSION['presentationResponse'][$credential['id']] = $credential; - } - $presentationResponse = isset($_SESSION['presentationResponse']) ? $_SESSION['presentationResponse'] : null; - $_SESSION['accessToken'] = null; - $_SESSION['successUrl'] = null; - } +// Retrieve presentation data from transient +$presentationResponse = null; +if ($correlationId) { + $presentationResponse = get_transient('oid4vp_presentation_' . $correlationId); +} else { + error_log('OID4VP render.php: No correlation_id in URL, cannot retrieve data'); } if (!empty($presentationResponse) && isset($attributes['attributeName'])) { @@ -58,6 +29,7 @@ // Check if the credential type exists in the presentation response if (isset($attributes['credentialQueryId']) && isset($presentationResponse[$attributes['credentialQueryId']])) { $result = $presentationResponse[$attributes['credentialQueryId']]; + foreach ($jsonAttributeNames as &$name) { // Check if the attribute exists before accessing it if (isset($result[$name])) { @@ -71,8 +43,15 @@ // $arr is now array(2, 4, 6, 8) unset($name); - $block_content = '

' . $attributes['attributeLabel'] . ': ' . $result . '

'; + // Check if result is a base64 image data URI + if (is_string($result) && preg_match('/^data:image\/(jpeg|jpg|png|gif|webp);base64,/', $result)) { + $block_content = '
' . esc_attr($attributes['attributeLabel']) . '
'; + } else { + $block_content = '

' . $attributes['attributeLabel'] . ': ' . $result . '

'; + } echo $block_content; + } else { + error_log('OID4VP render.php: credentialQueryId=' . ($attributes['credentialQueryId'] ?? 'NOT SET') . ' not found in presentation data'); } } diff --git a/universal-openid4vp-plugin.php b/universal-openid4vp-plugin.php index 689d9dd..c9241bb 100644 --- a/universal-openid4vp-plugin.php +++ b/universal-openid4vp-plugin.php @@ -24,6 +24,17 @@ define('UNIVERSAL_OPENID4VP_PLUGIN_DIR', trailingslashit(plugin_dir_path(__FILE__))); } +/** + * Log debug message only when WP_DEBUG is enabled + * + * @param string $message The message to log + */ +function uo_debug_log($message) { + if (defined('WP_DEBUG') && WP_DEBUG) { + error_log('OID4VP: ' . $message); + } +} + require_once(UNIVERSAL_OPENID4VP_PLUGIN_DIR . 'build/OpenID4VP.php'); $openid4vp = new Universal_OpenID4VP(); @@ -114,6 +125,10 @@ function universal_openid4vp_enqueue_org_wallet_scripts() { * back to the client script as JSON. */ function universal_openid4vp_ajax_poll_status() { + if (!session_id()) { + session_start(); + } + // Get the 'current' data that the AJAX call sent if ( isset( $_POST['current'] ) ) { $current = $_POST['current']; @@ -131,50 +146,62 @@ function universal_openid4vp_ajax_poll_status() { $body = wp_remote_retrieve_body($response); //$result = json_decode( $body ); $successUrl = null; - if ( json_decode( $body ) != null ) { - $successUrl = $_SESSION['successUrl']; - - $presentationResponse = json_decode( $body, true); + $presentationResponse = json_decode($body, true); + + if ($presentationResponse && isset($presentationResponse['status']) && $presentationResponse['status'] === 'authorization_response_verified') { + if (isset($presentationResponse['verified_data']['credential_claims'])) { + $credentialClaims = $presentationResponse['verified_data']['credential_claims']; + + // Store in transient instead of session + $correlationId = $_SESSION['correlationId']; + $presentationData = []; + foreach ($credentialClaims as $credential) { + $presentationData[$credential['id']] = $credential; + uo_debug_log('Stored credential with ID: ' . $credential['id']); + } + set_transient('oid4vp_presentation_' . $correlationId, $presentationData, 600); // FIXME 10 minute lifecycle is fine for demos, not for prod + uo_debug_log('Stored presentation data in transient for correlation_id=' . $correlationId); - error_log($body); + // Get successUrl and append correlation_id + $successUrl = get_transient('oid4vp_success_url_' . $correlationId); - $credentialClaims = $presentationResponse['verified_data']['credential_claims']; - foreach ($credentialClaims as $credential) { - if (empty($_SESSION['presentationResponse'])) { - $_SESSION['presentationResponse'] = []; + if ($successUrl) { + // Append correlation_id to URL + $successUrl = add_query_arg('oid4vp_cid', $correlationId, $successUrl); + uo_debug_log('Redirecting to ' . $successUrl); + delete_transient('oid4vp_success_url_' . $correlationId); } - $_SESSION['presentationResponse'][$credential['id']] = $credential; - } - if ($options->loginUrl == $current) { - $jsonAttributeNames = explode(".", $options->usernameAttribute); + if ($options->loginUrl == $current) { + $jsonAttributeNames = explode(".", $options->usernameAttribute); - $result = $_SESSION['presentationResponse']; - foreach ($jsonAttributeNames as &$name) { - $result = $result[$name]; - } - // $arr is now array(2, 4, 6, 8) - unset($name); + $result = $_SESSION['presentationResponse']; + foreach ($jsonAttributeNames as &$name) { + $result = $result[$name]; + } - if (username_exists($result) == true) { - $user = get_user_by('login', $result); - $user_id = $user->ID; - } + // $arr is now array(2, 4, 6, 8) + unset($name); + + if (username_exists($result) == true) { + $user = get_user_by('login', $result); + $user_id = $user->ID; + } - if (!empty($user_id)) { - // set current user session - wp_clear_auth_cookie(); - wp_set_current_user($user_id); - wp_set_auth_cookie($user_id); + if (!empty($user_id)) { + wp_clear_auth_cookie(); + wp_set_current_user($user_id); + wp_set_auth_cookie($user_id); - if (is_user_logged_in()) { - $successUrl = admin_url(); + if (is_user_logged_in()) { + $successUrl = admin_url(); + } } } - } - $_SESSION['accessToken'] = null; - $_SESSION['successUrl'] = null; + // Clear session tokens + $_SESSION['accessToken'] = null; + } } // Prepare the data to sent back to Javascript @@ -185,7 +212,10 @@ function universal_openid4vp_ajax_poll_status() { ); // Encode it as JSON and send it back - echo json_encode( $data ); + if (defined('WP_DEBUG') && WP_DEBUG) { // (Do not json_encode when not logging) + uo_debug_log('Response data: ' . json_encode($data)); + } + echo json_encode($data); die(); } @@ -193,19 +223,23 @@ function universal_openid4vp_ajax_poll_status() { * Gets the number of votes from the database, and sends it * back to the client script as JSON. */ -function universal_openid4vp_ajax_org_wallet_presentation_exchange() { - $attributes = $_SESSION['queryAttributes']; +function universal_openid4vp_ajax_org_wallet_presentation_exchange() +{ + if (!session_id()) { + session_start(); + } - $response = universal_openid4vp_sendVpRequest($attributes); + $attributes = $_SESSION['queryAttributes']; - if ($response["success"] === false) { - echo $response["error"]; - return; - } + $response = universal_openid4vp_sendVpRequest($attributes); - echo json_encode($response["result"]); + if ($response["success"] === false) { + echo $response["error"]; + return; + } - die(); + echo json_encode($response["result"]); + die(); } function universal_openid4vp_sendVpRequest($attributes) { @@ -241,7 +275,21 @@ function universal_openid4vp_sendVpRequest($attributes) { } $authenticationResult = json_decode( wp_remote_retrieve_body($response) ); - $body = array('query_id' => $attributes['queryId']); + // Check if request is from mobile device (same-device flow) + $isMobile = false; + if (isset($_SERVER['HTTP_USER_AGENT'])) { + $userAgent = $_SERVER['HTTP_USER_AGENT']; + $isMobile = preg_match('/Mobile|Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i', $userAgent); + } + + // Generate correlation_id BEFORE making the API call + $correlationId = wp_generate_uuid4(); + + $body = array( + 'query_id' => $attributes['queryId'], + 'correlation_id' => $correlationId // Send WordPress-generated correlation_id + ); + if ( isset( $_POST['walletUrl'] ) ) { $body['request_uri_base'] = $_POST['walletUrl']; } @@ -257,9 +305,14 @@ function universal_openid4vp_sendVpRequest($attributes) { if (array_key_exists('responseMode', $attributes)) { $body['response_mode'] = $attributes['responseMode']; } - if (array_key_exists('successUrl', $attributes)) { - $body['direct_post_response_redirect_uri'] = $attributes['successUrl']; + + // Set mobile redirect URI with the REAL correlation_id (we generated it above) + if (array_key_exists('successUrl', $attributes) && $isMobile) { + $redirectUrl = add_query_arg('oid4vp_cid', $correlationId, $attributes['successUrl']); + $body['direct_post_response_redirect_uri'] = $redirectUrl; + uo_debug_log('Same-device flow: direct_post_response_redirect_uri=' . $redirectUrl); } + if (isset($attributes['qrCodeEnabled']) && $attributes['qrCodeEnabled']) { $qrCode = (object)[]; if (array_key_exists('qrSize', $attributes) && !empty($attributes['qrSize'])) { @@ -304,13 +357,16 @@ function universal_openid4vp_sendVpRequest($attributes) { return ["success" => false, "error" => $block_content]; } - // store the correlation id in the SESSION $_SESSION['correlationId'] = $result->correlation_id; $_SESSION['presentationStatusUri'] = $result->status_uri; $_SESSION['accessToken'] = $authenticationResult->access_token; + if (array_key_exists('successUrl', $attributes)) { $_SESSION['successUrl'] = wp_sanitize_redirect($attributes['successUrl']); + // Store success URL for cross-device polling flow + set_transient('oid4vp_success_url_' . $result->correlation_id, wp_sanitize_redirect($attributes['successUrl']), 600); + uo_debug_log('Stored successUrl for correlation_id=' . $result->correlation_id); } - return ["success" => true, "result" => $result]; + return ["success" => true, "result" => $result]; }