array( 'ts' => 1700000000, 'tx' => '...' ) ). */ const USER_PASSES_META = '_ageonce_age_passes'; /** User meta: Unix time of the last administrator reset. Device passes created before it are ignored. */ const USER_RESET_META = '_ageonce_reset_at'; /** admin-post.php action that mints a fresh OAuth state and sends the visitor to AgeOnce. */ const START_ACTION = 'ageonce_start'; /** How long a click-started OAuth state remains valid (covers signup + ID + liveness). */ const OAUTH_STATE_TTL = 1800; /** @var string|null Whitelist key for ?ageonce_err= when WooCommerce notices are unavailable. */ private $pending_front_error_code = null; /** @var bool Avoid printing the OAuth error banner twice (wp_body_open + wp_footer fallback). */ private static $front_oauth_error_shown = false; private static $front_error_cleanup_script_added = false; private static $front_error_banner_style_added = false; public function __construct() { add_action( 'init', array( $this, 'maybe_handle_native_callback' ), 5 ); add_action( 'init', array( $this, 'register_verification_cache_bypass' ), 20 ); // admin-post.php is not stored by page caches. Guests use the nopriv hook. add_action( 'admin_post_' . AGEONCE_CALLBACK_PARAM, array( $this, 'handle_callback' ) ); add_action( 'admin_post_nopriv_' . AGEONCE_CALLBACK_PARAM, array( $this, 'handle_callback' ) ); add_action( 'admin_post_' . self::START_ACTION, array( $this, 'handle_start' ) ); add_action( 'admin_post_nopriv_' . self::START_ACTION, array( $this, 'handle_start' ) ); add_filter( 'rocket_cache_reject_cookies', array( $this, 'add_verification_cache_cookie' ) ); add_filter( 'litespeed_vary_cookies', array( $this, 'add_verification_cache_cookie' ) ); add_filter( 'sgo_bypass_cookies', array( $this, 'add_verification_cache_cookie' ) ); add_action( 'wp_body_open', array( $this, 'output_front_error_banner' ), 5 ); add_action( 'wp_footer', array( $this, 'output_front_error_banner' ), 1 ); if ( class_exists( 'WooCommerce' ) ) { add_action( 'woocommerce_api_ageonce_callback', array( $this, 'handle_callback' ) ); } } /** * Redirect URI for OAuth. Uses admin-post.php so a full-page cache cannot * swallow the return. The old homepage callback still works for links already in flight. * * @return string */ public static function get_redirect_uri() { return add_query_arg( 'action', AGEONCE_CALLBACK_PARAM, admin_url( 'admin-post.php' ) ); } /** * Local Verify Age URL. Does not embed an OAuth nonce/state in HTML, so a cached * checkout or article cannot serve a dead token. Click hits admin-post.php, which * mints a server-side state and redirects to AgeOnce. * * @param string $return_url Where to send the visitor after a successful check. * @param string $context checkout|content|signup. * @param int $age Required age. * @return string */ public static function get_verify_age_url( $return_url, $context = 'content', $age = 18 ) { $context = in_array( $context, array( 'checkout', 'content', 'signup' ), true ) ? $context : 'content'; $age = absint( $age ); if ( $age < 1 ) { $age = 18; } return add_query_arg( array( 'action' => self::START_ACTION, 'ctx' => $context, 'age' => $age, 'ret' => $return_url, ), admin_url( 'admin-post.php' ) ); } /** * Allow wp_safe_redirect to AgeOnce (cross-site). * * @param string[] $hosts Allowed hosts. * @return string[] */ public static function allow_ageonce_redirect_host( $hosts ) { $auth_host = wp_parse_url( AGEONCE_AUTH_URL, PHP_URL_HOST ); if ( is_string( $auth_host ) && $auth_host !== '' && ! in_array( $auth_host, $hosts, true ) ) { $hosts[] = $auth_host; } return $hosts; } /** * Sid is lowercase alphanumeric so Redis/LiteSpeed object-cache keys match * get_transient after sanitize. wp_generate_password() emits mixed case; * sanitize_key() lowercases — that miss is why 1.0.13 never called /oauth/token. * * @param string $sid Raw sid from generator or callback. * @return string */ public static function normalize_oauth_sid( $sid ) { return strtolower( preg_replace( '/[^a-z0-9]/i', '', (string) $sid ) ); } /** * HMAC over sid/ret/ctx/age so callback still works if the transient is gone * (object cache, extra lowercase, or a different PHP worker). * * @param array $data State fields (mac is ignored). * @return string */ public static function sign_oauth_state( array $data ) { unset( $data['mac'] ); ksort( $data ); $json = wp_json_encode( $data ); if ( ! is_string( $json ) ) { return ''; } return hash_hmac( 'sha256', $json, wp_salt( 'auth' ) ); } /** * @param array $data Decoded state. * @return bool */ public static function oauth_state_mac_is_valid( array $data ) { if ( empty( $data['mac'] ) || ! is_string( $data['mac'] ) ) { return false; } $expected = self::sign_oauth_state( $data ); return $expected !== '' && hash_equals( $expected, $data['mac'] ); } /** * @param array $data sid/ret/ctx/age source. * @param string $sid Normalized sid. * @return array{sid:string,ret:string,ctx:string,age:int} */ private static function oauth_state_payload_from_data( array $data, $sid ) { $ctx = isset( $data['ctx'] ) ? (string) $data['ctx'] : 'content'; if ( ! in_array( $ctx, array( 'checkout', 'content', 'signup' ), true ) ) { $ctx = 'content'; } $age = isset( $data['age'] ) ? absint( $data['age'] ) : 18; if ( $age < 1 || $age > 120 ) { $age = 18; } return array( 'sid' => $sid, 'ret' => isset( $data['ret'] ) ? (string) $data['ret'] : '', 'ctx' => $ctx, 'age' => $age, ); } /** * Create a one-time OAuth state stored in a transient (not a WP nonce). * Also embeds a MAC so return from AgeOnce does not depend on object-cache case. * * @param string $return_url Validated local return URL. * @param string $context checkout|content|signup. * @param int $age Required age. * @return string Base64url state for the AgeOnce authorize URL. */ public static function create_oauth_state( $return_url, $context, $age ) { $sid = self::normalize_oauth_sid( wp_generate_password( 24, false, false ) ); $payload = array( 'sid' => $sid, 'ret' => $return_url, 'ctx' => $context, 'age' => absint( $age ), ); $payload['mac'] = self::sign_oauth_state( $payload ); set_transient( 'ageonce_st_' . $sid, array( 'ret' => $return_url, 'ctx' => $context, 'age' => absint( $age ), ), self::OAUTH_STATE_TTL ); return self::build_oauth_state( $payload ); } /** * Validate returned OAuth state. Prefers HMAC (1.0.14+), then the server-side * sid transient (tries lowercase and original case for 1.0.13 Redis keys), * then unsigned embedded ret/ctx/age from 1.0.13, then wp_verify_nonce. * * @param array $state_data Decoded state from the query string. * @return array|null ret/ctx/age payload, or null if invalid/expired. */ public static function resolve_oauth_state( array $state_data ) { $raw_sid = isset( $state_data['sid'] ) ? (string) $state_data['sid'] : ''; $sid = self::normalize_oauth_sid( $raw_sid ); if ( $sid !== '' && self::oauth_state_mac_is_valid( $state_data ) ) { return self::oauth_state_payload_from_data( $state_data, $sid ); } if ( $sid !== '' ) { $stored = get_transient( 'ageonce_st_' . $sid ); if ( ! is_array( $stored ) && $raw_sid !== '' && $raw_sid !== $sid ) { $stored = get_transient( 'ageonce_st_' . $raw_sid ); } if ( is_array( $stored ) ) { return self::oauth_state_payload_from_data( $stored, $sid ); } // 1.0.13 already put ret/ctx/age in the URL; use them if the transient missed. if ( isset( $state_data['ret'] ) ) { return self::oauth_state_payload_from_data( $state_data, $sid ); } return null; } if ( empty( $state_data['nonce'] ) ) { return null; } $nonce = sanitize_text_field( (string) $state_data['nonce'] ); if ( ! wp_verify_nonce( $nonce, 'ageonce_auth' ) ) { return null; } return $state_data; } /** * Ask page caches not to serve a stored page once this browser is verified. * Otherwise the article HTML saved before verification still shows the gate. * * @param string[] $cookies Cookie names already excluded or varied. * @return string[] */ public function add_verification_cache_cookie( $cookies ) { if ( ! is_array( $cookies ) ) { $cookies = array(); } if ( ! in_array( 'ageonce_verified', $cookies, true ) ) { $cookies[] = 'ageonce_verified'; } return $cookies; } /** * Tell WP Super Cache that a browser with ageonce_verified must not get a stored page. * Does not rewrite .htaccess. Expert mode picks the cookie up the next time its rules are saved. */ public function register_verification_cache_bypass() { if ( has_action( 'wpsc_add_cookie' ) ) { do_action( 'wpsc_add_cookie', 'ageonce_verified' ); } } /** * Encode OAuth state for authorize URL. Uses base64url so '+' is never sent (PHP would turn '+' into space in query strings). * * @param array $data e.g. nonce, ret. * @return string */ public static function build_oauth_state( array $data ) { $json = wp_json_encode( $data ); if ( ! is_string( $json ) ) { return ''; } $b64 = base64_encode( $json ); return rtrim( strtr( $b64, '+/', '-_' ), '=' ); } /** * Decode OAuth state from callback. Supports base64url (current) and standard base64 (legacy in-flight links). * * @param string $raw Trimmed state from the request (slashes already stripped by WordPress). * @return array|null */ public static function parse_oauth_state( $raw ) { $raw = trim( (string) $raw ); if ( $raw === '' ) { return null; } $raw = str_replace( ' ', '+', $raw ); $std = strtr( $raw, '-_', '+/' ); $pad = strlen( $std ) % 4; if ( $pad > 0 ) { $std .= str_repeat( '=', 4 - $pad ); } $out = self::oauth_state_base64_json_decode( $std ); return is_array( $out ) ? $out : null; } /** * @param string $b64 Standard base64 string. * @return array|null */ private static function oauth_state_base64_json_decode( $b64 ) { $bin = base64_decode( $b64, true ); if ( $bin === false || $bin === '' ) { return null; } $data = json_decode( $bin, true ); return is_array( $data ) ? $data : null; } /** * Validate OAuth `state` query value (base64 / base64url). Rejects anything outside an allowed charset and length. * * @param string $raw Unslashed raw value from the request. * @return string Safe string or empty if invalid. */ public static function sanitize_oauth_state_query_value( $raw ) { $raw = str_replace( ' ', '+', trim( (string) $raw ) ); if ( $raw === '' ) { return ''; } if ( strlen( $raw ) > 2048 ) { return ''; } if ( ! preg_match( '/^[A-Za-z0-9+\/=_-]+$/', $raw ) ) { return ''; } return $raw; } /** * Normalize a surface name. Anything unknown is treated as content. * * @param mixed $surface Surface name. * @return string One of the SURFACE_* constants. */ private static function normalize_surface( $surface ) { return ( self::SURFACE_CHECKOUT === $surface ) ? self::SURFACE_CHECKOUT : self::SURFACE_CONTENT; } /** * Pass durations a merchant can choose for a surface, as key => seconds. * * The lists are fixed on purpose. Content can be set to the strictest options and tops out at * 7 days. Checkout and store signup top out at 30 days. "session" means "until the browser is * closed" and is capped at 24 hours. * * @param string $surface SURFACE_CONTENT or SURFACE_CHECKOUT. * @return array */ public static function get_allowed_pass_durations( $surface ) { if ( self::SURFACE_CHECKOUT === self::normalize_surface( $surface ) ) { return array( '1h' => HOUR_IN_SECONDS, '24h' => DAY_IN_SECONDS, '7d' => 7 * DAY_IN_SECONDS, '30d' => 30 * DAY_IN_SECONDS, ); } return array( 'session' => DAY_IN_SECONDS, '1h' => HOUR_IN_SECONDS, '24h' => DAY_IN_SECONDS, '7d' => 7 * DAY_IN_SECONDS, ); } /** * Saved duration key for a surface. Falls back to the 24-hour default when the saved * value is missing or is not one of the allowed choices for that surface. * * @param string $surface SURFACE_CONTENT or SURFACE_CHECKOUT. * @return string */ public static function get_pass_duration_key( $surface ) { $surface = self::normalize_surface( $surface ); $option = ( self::SURFACE_CHECKOUT === $surface ) ? self::OPTION_CHECKOUT_DURATION : self::OPTION_CONTENT_DURATION; $saved = get_option( $option, self::DEFAULT_PASS_DURATION ); $allowed = self::get_allowed_pass_durations( $surface ); return ( is_string( $saved ) && isset( $allowed[ $saved ] ) ) ? $saved : self::DEFAULT_PASS_DURATION; } /** * Pass rules for a surface. * * @param string $surface SURFACE_CONTENT or SURFACE_CHECKOUT. * @return array{key:string,ttl:int,browser_session:bool} ttl is in seconds. browser_session is true * when the pass also ends once the browser session cookie is gone. */ public static function get_pass_policy( $surface ) { $surface = self::normalize_surface( $surface ); $key = self::get_pass_duration_key( $surface ); $allowed = self::get_allowed_pass_durations( $surface ); return array( 'key' => $key, 'ttl' => (int) $allowed[ $key ], 'browser_session' => ( 'session' === $key ), ); } /** * Length of a pass kept on the visitor's device (cookie, WooCommerce session). Never longer than * VERIFICATION_TTL, whatever the merchant chose. Only a logged-in account keeps a longer pass. * * @param int|null $ttl Requested length in seconds, or null for the default. * @return int */ private static function normalize_device_ttl( $ttl ) { if ( null === $ttl ) { return self::VERIFICATION_TTL; } return min( max( 0, (int) $ttl ), self::VERIFICATION_TTL ); } /** * Check whether WooCommerce session verification flag is still valid. * * @param bool $clear_expired Whether to clear session values that are past the 24-hour device cap. * A pass that is only too old for a shorter surface setting is kept, * because another surface may still accept it. * @param int|null $ttl Allowed age in seconds. Capped at VERIFICATION_TTL. Null means 24 hours. * @return bool */ public static function is_wc_verification_valid( $clear_expired = true, $ttl = null ) { if ( ! function_exists( 'WC' ) || ! WC()->session ) { return false; } if ( ! WC()->session->get( 'ageonce_verified' ) ) { return false; } $ttl = self::normalize_device_ttl( $ttl ); $raw_timestamp = WC()->session->get( 'ageonce_timestamp' ); $is_valid = self::is_timestamp_within_ttl( $raw_timestamp, $ttl ); if ( ! $is_valid && $clear_expired && ! self::is_timestamp_within_ttl( $raw_timestamp, self::VERIFICATION_TTL ) ) { WC()->session->set( 'ageonce_verified', false ); WC()->session->set( 'ageonce_transaction_id', null ); WC()->session->set( 'ageonce_timestamp', null ); } return $is_valid; } /** * Whether the visitor holds a valid pass for a surface. Checks the logged-in account first, * then the WooCommerce session, then the fallback cookie. * Guests on My Account often have no durable WC session — cookie covers that case. * * @param bool $clear_expired Whether to clear WC session / cookie values past the 24-hour device cap. * @param int $required_age Minimum age the content or product needs. 0 accepts any verified age. * @param string $surface SURFACE_CONTENT or SURFACE_CHECKOUT. Picks the pass duration. Store * signup counts as checkout. * @return bool */ public static function is_verified( $clear_expired = true, $required_age = 0, $surface = self::SURFACE_CONTENT ) { return null !== self::resolve_verification( $clear_expired, $required_age, $surface ); } /** * The verification that currently grants access, so a record (for example an order) can point at * the exact check that was relied on rather than the latest one. * * @param int $required_age Minimum age. 0 accepts any verified age. * @param string $surface SURFACE_CONTENT or SURFACE_CHECKOUT. * @return array{timestamp:int,transaction_id:string,source:string}|null Null when there is no valid pass. */ public static function get_active_verification( $required_age = 0, $surface = self::SURFACE_CHECKOUT ) { return self::resolve_verification( false, $required_age, $surface ); } /** * Find the pass that satisfies a surface and age, or null. * * @param bool $clear_expired Whether to clear device values past the 24-hour cap. * @param int $required_age Minimum age. 0 accepts any verified age. * @param string $surface SURFACE_CONTENT or SURFACE_CHECKOUT. * @return array{timestamp:int,transaction_id:string,source:string}|null */ private static function resolve_verification( $clear_expired, $required_age, $surface ) { $required_age = absint( $required_age ); $policy = self::get_pass_policy( $surface ); // "Until the browser is closed": no browser session cookie means a new session. if ( $policy['browser_session'] && ! self::is_browser_session_active() ) { return null; } // Logged-in account. This is the only place a pass can outlive 24 hours. $user_id = get_current_user_id(); if ( $user_id > 0 ) { $pass = self::find_user_pass( $user_id, $required_age, $policy['ttl'] ); if ( null !== $pass ) { return array( 'timestamp' => $pass['ts'], 'transaction_id' => $pass['tx'], 'source' => 'user', ); } } // Device passes (WooCommerce session, cookie) never outlive the surface setting or 24 hours. $device_ttl = min( $policy['ttl'], self::VERIFICATION_TTL ); // After an administrator reset, a device pass created before the reset no longer counts. $reset_at = ( $user_id > 0 ) ? absint( get_user_meta( $user_id, self::USER_RESET_META, true ) ) : 0; if ( self::is_wc_verification_valid( $clear_expired, $device_ttl ) ) { $session_ts = self::parse_timestamp( WC()->session->get( 'ageonce_timestamp' ) ); if ( $session_ts > $reset_at ) { if ( ! self::session_meets_required_age( $required_age ) ) { return null; } return array( 'timestamp' => $session_ts, 'transaction_id' => sanitize_text_field( (string) WC()->session->get( 'ageonce_transaction_id' ) ), 'source' => 'session', ); } } if ( ! self::is_verification_cookie_valid( $clear_expired, $device_ttl ) ) { return null; } $cookie_ts = self::get_verification_cookie_timestamp(); if ( null === $cookie_ts || $cookie_ts <= $reset_at ) { return null; } $transaction_id = ''; // Hydrate WC session for this request so checkout/registration meta can use it. if ( function_exists( 'WC' ) && WC()->session ) { self::ensure_wc_customer_session(); if ( ! WC()->session->get( 'ageonce_verified' ) ) { WC()->session->set( 'ageonce_verified', true ); if ( ! WC()->session->get( 'ageonce_timestamp' ) ) { WC()->session->set( 'ageonce_timestamp', self::format_local_datetime( $cookie_ts ) ); } } $transaction_id = sanitize_text_field( (string) WC()->session->get( 'ageonce_transaction_id' ) ); } if ( ! self::session_meets_required_age( $required_age ) ) { return null; } return array( 'timestamp' => $cookie_ts, 'transaction_id' => $transaction_id, 'source' => 'cookie', ); } /** * Logged-in account verification. User meta keeps when each age threshold was last verified * (_ageonce_age_passes), so a 21+ check satisfies 18+ rules but an 18+ check never satisfies * 21+. The account pass lasts as long as the surface setting says, and ends on logout. * * @param int $user_id User ID. 0 uses the current user. * @param int $required_age Minimum age. 0 accepts any stored verification. * @param string $surface SURFACE_CONTENT or SURFACE_CHECKOUT. * @return bool */ public static function is_user_verification_valid( $user_id = 0, $required_age = 0, $surface = self::SURFACE_CONTENT ) { $user_id = absint( $user_id ); if ( $user_id <= 0 ) { $user_id = get_current_user_id(); } if ( $user_id <= 0 ) { return false; } $policy = self::get_pass_policy( $surface ); if ( $policy['browser_session'] && get_current_user_id() === $user_id && ! self::is_browser_session_active() ) { return false; } return null !== self::find_user_pass( $user_id, absint( $required_age ), $policy['ttl'] ); } /** * Best stored pass on an account for an age and duration. * * @param int $user_id User ID. * @param int $required_age Minimum age. 0 accepts any stored verification. * @param int $ttl Allowed age of the pass in seconds. * @return array{ts:int,tx:string,age:int}|null The most recent qualifying pass, or null. */ private static function find_user_pass( $user_id, $required_age, $ttl ) { $user_id = absint( $user_id ); if ( $user_id <= 0 || get_user_meta( $user_id, '_ageonce_status', true ) !== 'verified' ) { return null; } $required_age = absint( $required_age ); $best = null; foreach ( self::get_user_passes( $user_id ) as $age => $pass ) { // A higher verified age also covers a lower requirement. The reverse never holds. if ( $required_age > 0 && $age < $required_age ) { continue; } if ( ! self::is_timestamp_within_ttl( $pass['ts'], $ttl ) ) { continue; } if ( null === $best || $pass['ts'] > $best['ts'] ) { $best = array( 'ts' => $pass['ts'], 'tx' => $pass['tx'], 'age' => (int) $age, ); } } return $best; } /** * Per-age verification records for an account, as age => array( 'ts' => unix time, 'tx' => transaction ID ). * * Accounts verified before per-age records existed only have one age and one time, so a single * record is built from those. The same meta is used by sites that read it directly. * * @param int $user_id User ID. * @return array */ private static function get_user_passes( $user_id ) { $user_id = absint( $user_id ); if ( $user_id <= 0 ) { return array(); } $stored = get_user_meta( $user_id, self::USER_PASSES_META, true ); $passes = array(); if ( is_array( $stored ) ) { foreach ( $stored as $age => $pass ) { $age = absint( $age ); if ( $age < 1 || $age > 120 || ! is_array( $pass ) ) { continue; } $timestamp = isset( $pass['ts'] ) ? absint( $pass['ts'] ) : 0; if ( $timestamp <= 0 ) { continue; } $passes[ $age ] = array( 'ts' => $timestamp, 'tx' => isset( $pass['tx'] ) ? sanitize_text_field( (string) $pass['tx'] ) : '', ); } return $passes; } $legacy_age = absint( get_user_meta( $user_id, '_ageonce_required_age', true ) ); $legacy_ts = self::parse_timestamp( get_user_meta( $user_id, '_ageonce_timestamp', true ) ); if ( $legacy_age >= 1 && $legacy_age <= 120 && $legacy_ts > 0 ) { $passes[ $legacy_age ] = array( 'ts' => $legacy_ts, 'tx' => sanitize_text_field( (string) get_user_meta( $user_id, '_ageonce_transaction_id', true ) ), ); } return $passes; } /** * Save a successful verification on a WordPress account. * * Records when this age threshold was verified, and keeps the original meta keys up to date * (_ageonce_status, _ageonce_required_age, _ageonce_timestamp, _ageonce_transaction_id). * * @param int $user_id User ID. * @param int $min_age Age threshold that was passed. * @param string $transaction_id AgeOnce transaction ID. Empty if not returned. * @param int $verified_at Unix timestamp of the verification. 0 uses the current time. */ public static function record_user_verification( $user_id, $min_age, $transaction_id, $verified_at = 0 ) { $user_id = absint( $user_id ); $min_age = absint( $min_age ); if ( $user_id <= 0 || $min_age < 1 || $min_age > 120 ) { return; } $verified_at = absint( $verified_at ); if ( $verified_at <= 0 ) { $verified_at = time(); } $transaction_id = sanitize_text_field( (string) $transaction_id ); $passes = self::get_user_passes( $user_id ); $passes[ $min_age ] = array( 'ts' => $verified_at, 'tx' => $transaction_id, ); update_user_meta( $user_id, self::USER_PASSES_META, $passes ); $stored_age = absint( get_user_meta( $user_id, '_ageonce_required_age', true ) ); update_user_meta( $user_id, '_ageonce_status', 'verified' ); update_user_meta( $user_id, '_ageonce_required_age', max( $stored_age, $min_age ) ); update_user_meta( $user_id, '_ageonce_timestamp', self::format_local_datetime( $verified_at ) ); if ( '' !== $transaction_id ) { update_user_meta( $user_id, '_ageonce_transaction_id', $transaction_id ); } } /** * Remove the stored age verification from an account. The next restricted page or checkout asks * the person to verify again, on every device. Past orders keep their own records. * * @param int $user_id User ID. */ public static function reset_user_verification( $user_id ) { $user_id = absint( $user_id ); if ( $user_id <= 0 ) { return; } $meta_keys = array( '_ageonce_status', '_ageonce_required_age', '_ageonce_timestamp', '_ageonce_transaction_id', self::USER_PASSES_META, ); foreach ( $meta_keys as $meta_key ) { delete_user_meta( $user_id, $meta_key ); } // Cookies and sessions on the person's devices cannot be reached from here, so mark the moment. update_user_meta( $user_id, self::USER_RESET_META, time() ); /** * Fires after an administrator reset the age verification stored on an account. * * Use it to keep your own records, for example a custom role, in step with the reset. * * @param int $user_id WordPress user ID. */ do_action( 'ageonce_user_verification_reset', $user_id ); } /** * Unix time from a stored value: a Unix timestamp, or a MySQL datetime in the site timezone. * * @param mixed $raw_timestamp Stored value. * @return int 0 when empty or not understood. */ private static function parse_timestamp( $raw_timestamp ) { if ( null === $raw_timestamp || '' === $raw_timestamp || false === $raw_timestamp ) { return 0; } if ( is_numeric( $raw_timestamp ) ) { return max( 0, (int) $raw_timestamp ); } $raw_timestamp = trim( (string) $raw_timestamp ); if ( ! preg_match( '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $raw_timestamp ) ) { return 0; } // Do not use mysql2date( 'U' ): on WordPress 5.3+ it adds the timezone offset to the result. $datetime = date_create( $raw_timestamp, self::get_site_timezone() ); return $datetime ? max( 0, (int) $datetime->getTimestamp() ) : 0; } /** * Format a Unix timestamp as a MySQL datetime in the site timezone. * * @param int $timestamp Unix timestamp. * @return string */ public static function format_local_datetime( $timestamp ) { $timestamp = absint( $timestamp ); if ( function_exists( 'wp_date' ) ) { return wp_date( 'Y-m-d H:i:s', $timestamp ); } return gmdate( 'Y-m-d H:i:s', $timestamp + (int) round( (float) get_option( 'gmt_offset', 0 ) * HOUR_IN_SECONDS ) ); } /** * Site timezone as a DateTimeZone. wp_timezone() exists from WordPress 5.3. * * @return DateTimeZone */ private static function get_site_timezone() { if ( function_exists( 'wp_timezone' ) ) { return wp_timezone(); } $timezone_string = get_option( 'timezone_string' ); if ( is_string( $timezone_string ) && '' !== $timezone_string ) { try { return new DateTimeZone( $timezone_string ); } catch ( Exception $e ) { // Fall through to the numeric offset. unset( $e ); } } $offset = (float) get_option( 'gmt_offset', 0 ); $sign = ( $offset < 0 ) ? '-' : '+'; $abs = abs( $offset ); $hours = (int) floor( $abs ); $minutes = (int) round( ( $abs - $hours ) * 60 ); return new DateTimeZone( sprintf( '%s%02d:%02d', $sign, $hours, $minutes ) ); } /** * True when a stored verification time is no older than the allowed length. * * @param mixed $raw_timestamp Unix timestamp or MySQL datetime in the site timezone. * @param int|null $ttl Allowed age in seconds. Null means the 24-hour default. * @return bool */ private static function is_timestamp_within_ttl( $raw_timestamp, $ttl = null ) { $timestamp = self::parse_timestamp( $raw_timestamp ); if ( $timestamp <= 0 ) { return false; } $ttl = ( null === $ttl ) ? self::VERIFICATION_TTL : max( 0, (int) $ttl ); return ( time() - $timestamp ) <= $ttl; } /** * Transaction ID from the WooCommerce session, or from the logged-in user's stored verification. * * @return string */ public static function get_transaction_id() { if ( function_exists( 'WC' ) && WC()->session ) { $session_id = WC()->session->get( 'ageonce_transaction_id' ); if ( ! empty( $session_id ) ) { return sanitize_text_field( (string) $session_id ); } } $user_id = get_current_user_id(); if ( $user_id > 0 ) { $stored = get_user_meta( $user_id, '_ageonce_transaction_id', true ); if ( ! empty( $stored ) ) { return sanitize_text_field( (string) $stored ); } } return ''; } /** * A stored session age of 0 means an older session that did not record a threshold. * * @param int $required_age Minimum age. 0 skips the check. * @return bool */ private static function session_meets_required_age( $required_age ) { $required_age = absint( $required_age ); if ( $required_age <= 0 || ! function_exists( 'WC' ) || ! WC()->session ) { return true; } $session_age = absint( WC()->session->get( 'ageonce_min_age' ) ); if ( $session_age <= 0 ) { return true; } return $session_age >= $required_age; } /** * Ensure WooCommerce starts a customer session cookie (needed for guests without a cart). */ public static function ensure_wc_customer_session() { if ( ! function_exists( 'WC' ) || ! WC()->session ) { return; } if ( is_callable( array( WC()->session, 'has_session' ) ) && WC()->session->has_session() ) { return; } if ( is_callable( array( WC()->session, 'set_customer_session_cookie' ) ) ) { WC()->session->set_customer_session_cookie( true ); } } /** * @return int|null Unix timestamp from cookie, or null. */ private static function get_verification_cookie_timestamp() { if ( empty( $_COOKIE['ageonce_verified'] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Read-only cookie; sanitized below. return null; } $raw = sanitize_text_field( wp_unslash( (string) $_COOKIE['ageonce_verified'] ) ); if ( $raw === '1' ) { // Legacy cookie without timestamp — treat as "now" for hydration only. return time(); } if ( ctype_digit( $raw ) ) { return (int) $raw; } return null; } /** * Cookie fallback validity. Value is unix timestamp, or legacy "1". The cookie never counts * for longer than 24 hours. * * @param bool $clear_expired Whether to clear a cookie that is malformed or past the 24-hour cap. * A cookie that is only too old for a shorter surface setting is kept, * because another surface may still accept it. * @param int|null $ttl Allowed age in seconds. Capped at VERIFICATION_TTL. Null means 24 hours. * @return bool */ public static function is_verification_cookie_valid( $clear_expired = true, $ttl = null ) { if ( empty( $_COOKIE['ageonce_verified'] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Presence check only. return false; } $raw = sanitize_text_field( wp_unslash( (string) $_COOKIE['ageonce_verified'] ) ); if ( $raw === '1' ) { // Legacy cookie with no timestamp cannot prove a 24-hour window. if ( $clear_expired ) { self::clear_verification_cookie(); } return false; } if ( ! ctype_digit( $raw ) ) { if ( $clear_expired ) { self::clear_verification_cookie(); } return false; } $ttl = self::normalize_device_ttl( $ttl ); $timestamp = (int) $raw; $is_valid = self::is_timestamp_within_ttl( $timestamp, $ttl ); if ( ! $is_valid && $clear_expired && ! self::is_timestamp_within_ttl( $timestamp, self::VERIFICATION_TTL ) ) { self::clear_verification_cookie(); } return $is_valid; } /** * Persist verification cookie (HttpOnly). Value = unix timestamp. The cookie lasts 24 hours at most, * whatever pass duration the merchant chose. * * @param int $timestamp Unix time of the verification. 0 uses the current time. */ public static function set_verification_cookie( $timestamp = 0 ) { $now = absint( $timestamp ); if ( $now <= 0 ) { $now = time(); } $expire = $now + self::VERIFICATION_TTL; setcookie( 'ageonce_verified', (string) $now, $expire, COOKIEPATH, COOKIE_DOMAIN, is_ssl(), true ); $_COOKIE['ageonce_verified'] = (string) $now; // Only the "until the browser is closed" content option needs the browser session cookie. $content_policy = self::get_pass_policy( self::SURFACE_CONTENT ); if ( $content_policy['browser_session'] ) { self::set_browser_session_cookie( $now ); } } /** * Clear the verification cookie. */ public static function clear_verification_cookie() { setcookie( 'ageonce_verified', '', time() - HOUR_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN, is_ssl(), true ); unset( $_COOKIE['ageonce_verified'] ); } /** * Mark this browser session. The cookie has no expiry date, so the browser drops it when it closes. * * @param int $timestamp Unix time of the verification. */ public static function set_browser_session_cookie( $timestamp ) { $timestamp = absint( $timestamp ); if ( $timestamp <= 0 ) { $timestamp = time(); } setcookie( self::BROWSER_SESSION_COOKIE, (string) $timestamp, 0, COOKIEPATH, COOKIE_DOMAIN, is_ssl(), true ); $_COOKIE[ self::BROWSER_SESSION_COOKIE ] = (string) $timestamp; } /** * Whether the browser session cookie is present and not older than 24 hours. * * @return bool */ public static function is_browser_session_active() { if ( empty( $_COOKIE[ self::BROWSER_SESSION_COOKIE ] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Presence check only; sanitized below. return false; } $raw = sanitize_text_field( wp_unslash( (string) $_COOKIE[ self::BROWSER_SESSION_COOKIE ] ) ); if ( ! ctype_digit( $raw ) ) { return false; } return self::is_timestamp_within_ttl( (int) $raw, self::VERIFICATION_TTL ); } /** * Clear the browser session cookie. */ public static function clear_browser_session_cookie() { setcookie( self::BROWSER_SESSION_COOKIE, '', time() - HOUR_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN, is_ssl(), true ); unset( $_COOKIE[ self::BROWSER_SESSION_COOKIE ] ); } /** * End the local pass on logout: cookies, WooCommerce session, and the account's pass times * (_ageonce_timestamp and the per-age records). Status and required age stay on the account * so an 18+ check never satisfies a 21+ rule after they verify again. * * @param int $user_id User who is logging out. wp_logout passes this from WP 5.5+. */ public static function clear_local_pass_on_logout( $user_id = 0 ) { self::clear_verification_cookie(); self::clear_browser_session_cookie(); if ( function_exists( 'WC' ) && WC()->session ) { WC()->session->set( 'ageonce_verified', false ); WC()->session->set( 'ageonce_transaction_id', null ); WC()->session->set( 'ageonce_timestamp', null ); WC()->session->set( 'ageonce_min_age', null ); } $user_id = absint( $user_id ); if ( $user_id <= 0 ) { $user_id = get_current_user_id(); } if ( $user_id > 0 ) { delete_user_meta( $user_id, '_ageonce_timestamp' ); delete_user_meta( $user_id, self::USER_PASSES_META ); } } /** * Handle callback when URL contains ageonce_callback=1 (works without WooCommerce). */ public function maybe_handle_native_callback() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public OAuth 2.0 redirect; not an admin form submission. if ( ! isset( $_GET[ AGEONCE_CALLBACK_PARAM ] ) ) { return; } $this->handle_callback(); exit; } /** * Mint OAuth state on click and send the visitor to AgeOnce. * Query: ctx, age, ret. CSRF is the server-side sid, not a WP nonce in cached HTML. */ public function handle_start() { nocache_headers(); $context = isset( $_GET['ctx'] ) ? sanitize_key( wp_unslash( $_GET['ctx'] ) ) : 'content'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public start of OAuth; state is minted here. if ( ! in_array( $context, array( 'checkout', 'content', 'signup' ), true ) ) { $context = 'content'; } $age = isset( $_GET['age'] ) ? absint( wp_unslash( $_GET['age'] ) ) : 18; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public start of OAuth. if ( $age < 1 || $age > 120 ) { $age = 18; } $default_ret = home_url( '/' ); if ( 'checkout' === $context && function_exists( 'wc_get_checkout_url' ) ) { $default_ret = wc_get_checkout_url(); } elseif ( 'signup' === $context && function_exists( 'wc_get_page_permalink' ) ) { $default_ret = wc_get_page_permalink( 'myaccount' ); if ( ! is_string( $default_ret ) || $default_ret === '' ) { $default_ret = home_url( '/' ); } } $ret_raw = isset( $_GET['ret'] ) ? esc_url_raw( wp_unslash( $_GET['ret'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Validated with wp_validate_redirect. $return_url = wp_validate_redirect( $ret_raw, $default_ret ); $client_id = get_option( 'ageonce_client_id' ); if ( ! is_string( $client_id ) || $client_id === '' ) { $this->add_notice( __( 'Plugin configuration error (Client ID/Secret). Contact admin.', 'age-verification-with-ageonce' ), 'error', 'config' ); $this->redirect_after_notice( $return_url ); } $state = self::create_oauth_state( $return_url, $context, $age ); $oauth_url = add_query_arg( array( 'response_type' => 'code', 'client_id' => $client_id, 'redirect_uri' => self::get_redirect_uri(), 'state' => $state, 'age_required' => $age, ), AGEONCE_AUTH_URL . '/' ); $this->log( 'OAuth start: redirecting to AgeOnce' ); add_filter( 'allowed_redirect_hosts', array( __CLASS__, 'allow_ageonce_redirect_host' ) ); wp_safe_redirect( $oauth_url ); exit; } public function handle_callback() { $this->log( 'Callback received' ); // phpcs:disable WordPress.Security.NonceVerification.Recommended -- OAuth 2.0 authorization callback (GET). CSRF mitigated via server-side OAuth state (sid). $code = isset( $_GET['code'] ) ? sanitize_text_field( wp_unslash( $_GET['code'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Opaque OAuth state; validated in sanitize_oauth_state_query_value(). $state_for_token = self::sanitize_oauth_state_query_value( isset( $_GET['state'] ) ? wp_unslash( $_GET['state'] ) : '' ); $default_redirect = ( function_exists( 'wc_get_checkout_url' ) ) ? wc_get_checkout_url() : home_url( '/' ); $redirect_to = $default_redirect; if ( isset( $_GET['error'] ) ) { $this->log( 'OAuth Error: ' . sanitize_text_field( wp_unslash( $_GET['error'] ) ) ); $this->add_notice( __( 'Verification denied by user.', 'age-verification-with-ageonce' ), 'error', 'denied' ); // phpcs:enable WordPress.Security.NonceVerification.Recommended $this->redirect_after_notice( $redirect_to ); } if ( empty( $code ) ) { // phpcs:enable WordPress.Security.NonceVerification.Recommended wp_safe_redirect( home_url() ); exit; } $state_data = self::parse_oauth_state( $state_for_token ); $state_data = is_array( $state_data ) ? self::resolve_oauth_state( $state_data ) : null; if ( ! is_array( $state_data ) ) { $this->log( 'OAuth callback: missing or invalid state' ); $this->add_notice( __( 'Session expired. Please click "Verify Age" again on this page (do not use Back).', 'age-verification-with-ageonce' ), 'error', 'session' ); // phpcs:enable WordPress.Security.NonceVerification.Recommended $this->redirect_after_notice( $default_redirect ); } if ( ! empty( $state_data['ret'] ) ) { $redirect_to = wp_validate_redirect( esc_url_raw( (string) $state_data['ret'] ), $default_redirect ); $this->log( 'Redirect URL: ' . $redirect_to ); } // phpcs:enable WordPress.Security.NonceVerification.Recommended $this->verify_with_server( $code, $redirect_to, $state_for_token, $state_data ); } /** * Show notice: WooCommerce when available; without WC, only errors use a one-time front-end query arg. * * @param string $message Translated message (WooCommerce path). * @param string $type error|success — non-WC front flash only for error. * @param string $error_code Whitelist key for ?ageonce_err= when WC is unavailable. */ private function add_notice( $message, $type = 'error', $error_code = '' ) { if ( function_exists( 'wc_add_notice' ) ) { wc_add_notice( $message, $type ); return; } if ( $type === 'error' && $error_code !== '' && $this->is_front_error_code_allowed( $error_code ) ) { $this->pending_front_error_code = $error_code; } } /** * @param string $code Raw slug from internal callers only. */ private function is_front_error_code_allowed( $code ) { return in_array( $code, array( 'denied', 'csrf', 'connect', 'session', 'config', 'api', 'unknown' ), true ); } /** * Redirect; append ageonce_err for pending error when WooCommerce is not used. * * @param string $url Destination URL. */ private function redirect_after_notice( $url ) { if ( ! function_exists( 'wc_add_notice' ) && $this->pending_front_error_code !== null ) { $code = sanitize_key( $this->pending_front_error_code ); $this->pending_front_error_code = null; if ( $this->is_front_error_code_allowed( $code ) ) { $url = add_query_arg( 'ageonce_err', $code, $url ); } } else { $this->pending_front_error_code = null; } wp_safe_redirect( $url ); exit; } /** * Front-end banner for OAuth errors when wc_add_notice is not available. */ public function output_front_error_banner() { if ( self::$front_oauth_error_shown || is_admin() ) { return; } if ( ! isset( $_GET['ageonce_err'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only ?ageonce_err from OAuth redirect; not an admin form. return; } $code = sanitize_key( wp_unslash( $_GET['ageonce_err'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public flash param; sanitize_key + whitelist. if ( ! $this->is_front_error_code_allowed( $code ) ) { return; } self::$front_oauth_error_shown = true; $messages = array( 'denied' => __( 'Verification denied by user.', 'age-verification-with-ageonce' ), 'csrf' => __( 'Session expired. Please click "Verify Age" again on this page (do not use Back).', 'age-verification-with-ageonce' ), 'connect' => __( 'Unable to connect to verification server. Please try again later.', 'age-verification-with-ageonce' ), 'session' => __( 'Session expired. Please click "Verify Age" again on this page (do not use Back).', 'age-verification-with-ageonce' ), 'config' => __( 'Plugin configuration error (Client ID/Secret). Contact admin.', 'age-verification-with-ageonce' ), 'api' => __( 'Verification failed.', 'age-verification-with-ageonce' ), 'unknown' => __( 'Unexpected error. Please try again.', 'age-verification-with-ageonce' ), ); $text = isset( $messages[ $code ] ) ? $messages[ $code ] : $messages['unknown']; $this->enqueue_front_error_banner_style(); $this->enqueue_front_error_cleanup_script(); ?> get_front_error_banner_inline_css() ); self::$front_error_banner_style_added = true; } } /** * Enqueue one-time inline JS that removes ageonce_err from the URL. */ private function enqueue_front_error_cleanup_script() { if ( self::$front_error_cleanup_script_added ) { return; } $handle = 'ageonce-oauth-error-cleanup'; $version = defined( 'AGEONCE_VERIFICATION_VERSION' ) ? AGEONCE_VERIFICATION_VERSION : '1.0.0'; if ( function_exists( 'wp_register_script' ) && function_exists( 'wp_enqueue_script' ) && function_exists( 'wp_add_inline_script' ) ) { wp_register_script( $handle, false, array(), $version, true ); wp_enqueue_script( $handle ); wp_add_inline_script( $handle, $this->get_front_error_cleanup_inline_js(), 'after' ); self::$front_error_cleanup_script_added = true; return; } // Legacy fallback for very old environments missing enqueue APIs. if ( function_exists( 'wp_print_inline_script_tag' ) ) { wp_print_inline_script_tag( $this->get_front_error_cleanup_inline_js() ); self::$front_error_cleanup_script_added = true; } } /** * @return string */ private function get_front_error_cleanup_inline_js() { return "(function(){try{var u=new URL(window.location.href);if(u.searchParams.has('ageonce_err')){u.searchParams.delete('ageonce_err');window.history.replaceState({},'',u.pathname+u.search+u.hash);}}catch(e){}})();"; } /** * @return string */ private function get_front_error_banner_inline_css() { return '.ageonce-oauth-error{margin:1em;padding:12px 16px;border:1px solid #c3c4c7;background:#fcf0f1;color:#1d2327;font-size:14px;line-height:1.5;}'; } private function verify_with_server( $code, $final_destination, $state, $state_data = array() ) { $client_id = get_option( 'ageonce_client_id' ); $client_secret = get_option( 'ageonce_client_secret' ); $api_host = AGEONCE_API_URL; $auth_string = base64_encode( $client_id . ':' . $client_secret ); $body_args = array( 'grant_type' => 'authorization_code', 'code' => $code, 'redirect_uri' => self::get_redirect_uri(), 'state' => $state, ); $this->log( 'Sending token request' ); $response = wp_remote_post( $api_host . '/oauth/token', array( 'headers' => array( 'Authorization' => 'Basic ' . $auth_string, 'Content-Type' => 'application/x-www-form-urlencoded', ), 'body' => $body_args, 'timeout' => 15, ) ); if ( is_wp_error( $response ) ) { $this->log( 'Connection Error: ' . $response->get_error_message() ); $this->add_notice( __( 'Unable to connect to verification server. Please try again later.', 'age-verification-with-ageonce' ), 'error', 'connect' ); $this->redirect_after_notice( $final_destination ); } $response_code = wp_remote_retrieve_response_code( $response ); $body = wp_remote_retrieve_body( $response ); $data = json_decode( $body, true ); if ( $response_code >= 400 ) { $error_code = isset( $data['error'] ) ? $data['error'] : 'unknown_error'; $this->log( "API Error ($response_code): $error_code" ); switch ( $error_code ) { case 'invalid_state': $this->add_notice( __( 'Session expired. Please click "Verify Age" again on this page (do not use Back).', 'age-verification-with-ageonce' ), 'error', 'session' ); break; case 'expired_code': case 'invalid_grant': $this->add_notice( __( 'Session expired. Please click "Verify Age" again on this page (do not use Back).', 'age-verification-with-ageonce' ), 'error', 'session' ); break; case 'invalid_client': $this->add_notice( __( 'Plugin configuration error (Client ID/Secret). Contact admin.', 'age-verification-with-ageonce' ), 'error', 'config' ); break; default: if ( function_exists( 'wc_add_notice' ) ) { $msg = isset( $data['error_description'] ) ? $data['error_description'] : __( 'Verification failed.', 'age-verification-with-ageonce' ); wc_add_notice( $msg, 'error' ); } else { $this->add_notice( __( 'Verification failed.', 'age-verification-with-ageonce' ), 'error', 'api' ); } } $this->redirect_after_notice( $final_destination ); } if ( isset( $data['access_token'] ) ) { $this->log( 'Token received successfully' ); if ( ! empty( $state_data['sid'] ) ) { $sid = self::normalize_oauth_sid( (string) $state_data['sid'] ); delete_transient( 'ageonce_st_' . $sid ); if ( $sid !== (string) $state_data['sid'] ) { delete_transient( 'ageonce_st_' . (string) $state_data['sid'] ); } } $this->complete_verification( is_array( $data ) ? $data : array(), is_array( $state_data ) ? $state_data : array() ); $this->add_notice( __( 'Age verified successfully!', 'age-verification-with-ageonce' ), 'success' ); wp_safe_redirect( $final_destination ); exit; } $this->log( 'Unknown response format' ); $this->add_notice( __( 'Unexpected error. Please try again.', 'age-verification-with-ageonce' ), 'error', 'unknown' ); $this->redirect_after_notice( $final_destination ); } /** * Store a successful verification, then fire ageonce_verification_completed. * * Guests keep a session and cookie pass of 24 hours at most. Logged-in users also keep * the result on their account, where it lasts as long as the merchant's duration setting * for each surface. The action runs for guests with user_id 0. * * @param array $token_data Token response from AgeOnce. * @param array $state_data Decoded OAuth state (ret, ctx, age). */ private function complete_verification( array $token_data, array $state_data ) { $min_age = isset( $state_data['age'] ) ? absint( $state_data['age'] ) : 18; if ( $min_age < 1 || $min_age > 120 ) { $min_age = 18; } $context = 'content'; if ( isset( $state_data['ctx'] ) && in_array( $state_data['ctx'], array( 'checkout', 'content', 'signup' ), true ) ) { $context = $state_data['ctx']; } $tx_id = isset( $token_data['transaction_id'] ) ? sanitize_text_field( (string) $token_data['transaction_id'] ) : ''; $user_id = get_current_user_id(); $verified_at = time(); $verified_mysql = current_time( 'mysql' ); if ( function_exists( 'WC' ) && WC()->session ) { self::ensure_wc_customer_session(); $stored_age = absint( WC()->session->get( 'ageonce_min_age' ) ); WC()->session->set( 'ageonce_verified', true ); WC()->session->set( 'ageonce_min_age', max( $stored_age, $min_age ) ); if ( $tx_id !== '' ) { WC()->session->set( 'ageonce_transaction_id', $tx_id ); } WC()->session->set( 'ageonce_timestamp', $verified_mysql ); } // Always set cookie so guests without a durable WC session stay verified after redirect. self::set_verification_cookie( $verified_at ); if ( $user_id > 0 ) { self::record_user_verification( $user_id, $min_age, $tx_id, $verified_at ); } /** * Fires after AgeOnce confirms a successful age verification on this site. * * Runs during the OAuth callback, before checkout payment. user_id is 0 when the * visitor is not logged in. Do not use this action to grant paid membership access. * * @param array $verification { * @type int $user_id WordPress user ID, or 0 for a guest. * @type string $transaction_id AgeOnce audit / transaction ID. Empty if not returned. * @type int $min_age Age threshold that was requested and passed. * @type string $context checkout|content|signup. * @type int $verified_at Unix timestamp (UTC). * } */ do_action( 'ageonce_verification_completed', array( 'user_id' => $user_id, 'transaction_id' => $tx_id, 'min_age' => $min_age, 'context' => $context, 'verified_at' => $verified_at, ) ); } private function log( $message ) { if ( defined( 'WP_DEBUG' ) && WP_DEBUG && defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- only when WP_DEBUG_LOG is on. error_log( '[AgeOnce] ' . $message ); } } }