self::revoke_user( (int) $user->ID, 'password_reset' ) ); add_action( 'wp_set_password', static fn( $password, $user_id ) => self::revoke_user( (int) $user_id, 'password_changed' ), 10, 2 ); add_action( 'profile_update', static function ( $user_id, $old ) { $new = get_userdata( (int) $user_id ); if ( ! $new || ! $old instanceof WP_User ) { return; } if ( $new->user_pass !== $old->user_pass ) { self::revoke_user( (int) $user_id, 'password_changed' ); } elseif ( strtolower( $new->user_email ) !== strtolower( $old->user_email ) ) { self::revoke_user( (int) $user_id, 'email_changed' ); } }, 10, 2 ); add_action( 'set_user_role', static function ( $user_id ) { if ( ! AgentAdapt_Eligibility::eligible( (int) $user_id ) ) { self::revoke_user( (int) $user_id, 'no_longer_eligible' ); } } ); add_action( 'delete_user', static fn( $user_id ) => self::revoke_user( (int) $user_id, 'user_deleted' ) ); // The agent signs out (spec 4.9): only its own token, only by its key. add_action( 'agentadapt_revoke_token', static function ( $token, $agent ) { $grant = self::find( (string) $token, (string) $agent['client_hash'] ); if ( $grant ) { self::revoke( (string) $grant['grant_id'], 'agent_signed_out' ); } }, 10, 2 ); } /** * Lifetime in seconds. */ public static function lifetime(): int { /** * Account Token lifetime in seconds. * * @param int $seconds Default 90 days. */ return max( HOUR_IN_SECONDS, (int) apply_filters( 'agentadapt_account_token_lifetime', self::LIFETIME ) ); } /** * Issue an Account Token. * * @param string $client_hash Agent. * @param int $subject_id The agent's User ID at this store. * @param int $user_id The account. * @param string $scope Granted scopes. * @return array{token: string, grant_id: string, expires_in: int} */ public static function issue( string $client_hash, int $subject_id, int $user_id, string $scope ): array { global $wpdb; $token = self::PREFIX . AgentAdapt_Tokens::random( 32 ); $grant_id = 'grt_' . AgentAdapt_Tokens::random( 12 ); $life = self::lifetime(); $now = time(); $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery AgentAdapt_Schema::table( 'grants' ), array( 'grant_id' => $grant_id, 'token_hash' => hash( 'sha256', $token ), 'client_hash' => $client_hash, 'subject_id' => $subject_id, 'user_id' => $user_id, 'scope' => $scope, 'created' => $now, 'expires' => $now + $life, 'last_used' => $now, ) ); /** * An agent was connected to an account (the "agent connected" email * listens here). * * @param string $grant_id Grant. */ do_action( 'agentadapt_agent_connected', $grant_id ); return array( 'token' => $token, 'grant_id' => $grant_id, 'expires_in' => $life ); } /** * An active grant for this token and agent, or null. * * @param string $token Presented Account Token. * @param string $client_hash Authenticated agent. * @return array|null */ public static function find( string $token, string $client_hash ): ?array { if ( ! str_starts_with( $token, self::PREFIX ) || strlen( $token ) > 128 ) { return null; } $row = self::row_by( 'token_hash', hash( 'sha256', $token ) ); if ( ! $row || (int) $row['revoked'] || (int) $row['expires'] < time() || ! hash_equals( (string) $row['client_hash'], $client_hash ) ) { return null; } return $row; } /** * A grant by id, any state. * * @param string $grant_id Grant. * @return array|null */ public static function get( string $grant_id ): ?array { return self::row_by( 'grant_id', $grant_id ); } /** * Revoke one grant and sign out every Session it signed in. * * @param string $grant_id Grant. * @param string $reason Why (stored, shown to no one but the merchant). */ public static function revoke( string $grant_id, string $reason ): void { global $wpdb; $rows = $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery AgentAdapt_Schema::table( 'grants' ), array( 'revoked' => 1, 'revoked_at' => time(), 'revoked_reason' => substr( $reason, 0, 40 ) ), array( 'grant_id' => $grant_id, 'revoked' => 0 ) ); $g = self::get( $grant_id ); // A public client's Sessions have no User ID and end with their // sign-in (spec §6); a Personal Agent's continue signed out (4.9). AgentAdapt_Sessions::sign_out_grant( $grant_id, $g && AgentAdapt_Agents::is_public_hash( (string) $g['client_hash'] ) ); if ( $rows ) { AgentAdapt_Events::record( 'grant.revoked', null, array( 'client_hash' => (string) ( $g['client_hash'] ?? '' ), 'subject_id' => (int) ( $g['subject_id'] ?? 0 ), 'channel' => 'protocol', 'outcome' => $reason ) ); } } /** * Whether a grant exists and is neither revoked nor expired. * * @param string $grant_id Grant. */ public static function active( string $grant_id ): bool { if ( '' === $grant_id ) { return false; } $g = self::get( $grant_id ); return null !== $g && ! (int) $g['revoked'] && (int) $g['expires'] >= time(); } /** * Revoke every active grant for an account. * * @param int $user_id Account. * @param string $reason Why. */ public static function revoke_user( int $user_id, string $reason ): void { foreach ( self::for_user( $user_id ) as $g ) { self::revoke( (string) $g['grant_id'], $reason ); } } /** * Erase an account's grants (WordPress's personal-data eraser, * AgentAdapt_Privacy). The active ones are revoked first, exactly as a * Disconnect revokes them, so each agent's Account Token stops working * and every Session it signed in is signed out at once (spec 4.9), and * the revocation is recorded without the account. Then every grant of the * account is deleted, whatever its state. * * @param int $user_id Account. * @return array{revoked: int, deleted: int} Grants revoked now, and grant rows deleted. */ public static function erase_user( int $user_id ): array { if ( $user_id < 1 ) { return array( 'revoked' => 0, 'deleted' => 0 ); } $active = self::for_user( $user_id ); foreach ( $active as $g ) { self::revoke( (string) $g['grant_id'], 'privacy_erasure' ); } global $wpdb; $deleted = (int) $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE user_id = %d', AgentAdapt_Schema::table( 'grants' ), $user_id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery return array( 'revoked' => count( $active ), 'deleted' => $deleted ); } /** * Active grants for an account, with the agent's name and client_id. * * @param int $user_id Account. * @return array> */ public static function for_user( int $user_id ): array { global $wpdb; return (array) $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( 'SELECT g.grant_id, g.client_hash, g.scope, g.created, g.expires, g.last_used, a.name, a.client_id FROM %i g LEFT JOIN %i a ON a.client_hash = g.client_hash WHERE g.user_id = %d AND g.revoked = 0 AND g.expires >= %d ORDER BY g.created DESC', AgentAdapt_Schema::table( 'grants' ), AgentAdapt_Schema::table( 'agents' ), $user_id, time() ), ARRAY_A ); } /** * Note a use (for "last used" in Connected agents). * * @param string $grant_id Grant. */ public static function touch( string $grant_id ): void { global $wpdb; $wpdb->update( AgentAdapt_Schema::table( 'grants' ), array( 'last_used' => time() ), array( 'grant_id' => $grant_id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery } /** * Row by one column. * * @param string $col grant_id|token_hash. * @param string $value Value. * @return array|null */ private static function row_by( string $col, string $value ): ?array { global $wpdb; $row = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE %i = %s', AgentAdapt_Schema::table( 'grants' ), $col, $value ), ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery return $row ?: null; } }