0 ? gmdate( 'Y-m-d H:i:s', time() + $ttl ) : null; $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- custom table write. Installer::table( 'tokens' ), array( 'token_hash' => Crypto::hash_token( $raw ), 'token_prefix' => substr( $raw, 0, 16 ), 'kind' => $kind, 'name' => mb_substr( $name, 0, 190 ), 'user_id' => $user_id, 'client_id' => $client_id, 'family' => $family, 'scopes' => implode( ' ', Scopes::sanitize( $scopes ) ), 'created_at' => Ids::now(), 'expires_at' => $expires, ) ); return array( 'token' => $raw, 'id' => (int) $wpdb->insert_id, 'expires_at' => $expires ); } /** * Look up a raw token (any state). * * @param string $raw Raw token. * @return object|null */ public function find_raw( string $raw ) { global $wpdb; if ( strlen( $raw ) < 20 || strlen( $raw ) > 200 ) { return null; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table, auth lookup must not be cached. $row = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE token_hash = %s', Installer::table( 'tokens' ), Crypto::hash_token( $raw ) ) ); return $row ? $row : null; } /** * Is a token row currently usable? * * @param object $row Row. */ public function is_active( $row ): bool { if ( ! $row || (int) $row->revoked ) { return false; } if ( ! empty( $row->expires_at ) && strtotime( $row->expires_at . ' UTC' ) <= time() ) { return false; } return true; } /** * Record usage. * * @param int $id Token id. */ public function touch( int $id ): void { global $wpdb; $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table write. $wpdb->query( $wpdb->prepare( 'UPDATE %i SET last_used_at = %s, use_count = use_count + 1, last_ip_hash = %s WHERE id = %d', Installer::table( 'tokens' ), Ids::now(), substr( hash( 'sha256', $ip . wp_salt( 'nonce' ) ), 0, 16 ), $id ) ); } /** * Revoke every token of a user. Rows already retired by rotation are marked * too, so a rotated refresh token can no longer use the retry grace window. * * @param int $user_id User. * @param string $reason Reason. */ public function revoke_user( int $user_id, string $reason ): int { global $wpdb; return (int) $wpdb->update( Installer::table( 'tokens' ), array( 'revoked' => 1, 'revoked_reason' => $reason, 'revoked_at' => gmdate( 'Y-m-d H:i:s' ) ), array( 'user_id' => $user_id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table of this plugin. } /** * Was any token of a family revoked for a reason other than rotation? * * @param string $family Family id. */ public function family_hard_revoked( string $family ): bool { global $wpdb; if ( '' === $family ) { return false; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table, auth check must not be cached. return (bool) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE family = %s AND revoked = 1 AND revoked_reason NOT LIKE %s AND revoked_reason <> 'rotated_retry' AND revoked_reason <> 'superseded_by_retry'", Installer::table( 'tokens' ), $family, 'rotated%' ) ); } /** * Revoke one token. * * @param int $id Token. * @param string $reason Reason. */ public function revoke( int $id, string $reason = 'manual' ): bool { global $wpdb; return (bool) $wpdb->update( Installer::table( 'tokens' ), array( 'revoked' => 1, 'revoked_reason' => $reason, 'revoked_at' => gmdate( 'Y-m-d H:i:s' ) ), array( 'id' => $id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table of this plugin. } /** * Revoke every token in a refresh family (replay defense). * * @param string $family Family id. * @param string $reason Reason. */ public function revoke_family( string $family, string $reason ): void { global $wpdb; if ( '' === $family ) { return; } $wpdb->update( Installer::table( 'tokens' ), array( 'revoked' => 1, 'revoked_reason' => $reason, 'revoked_at' => gmdate( 'Y-m-d H:i:s' ) ), array( 'family' => $family ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table of this plugin. } /** * Revoke every token of a client. * * @param string $client_id Client id. */ public function revoke_client( string $client_id ): void { global $wpdb; // Access tokens made on the Connection screen have no client id: an empty id must never match them all. if ( '' === $client_id ) { return; } $wpdb->update( Installer::table( 'tokens' ), array( 'revoked' => 1, 'revoked_reason' => 'client_revoked', 'revoked_at' => gmdate( 'Y-m-d H:i:s' ) ), array( 'client_id' => $client_id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table of this plugin. } /** * List tokens for the dashboard (never includes hashes). * * @param bool $include_inactive Include revoked/expired. * @return array */ public function list( bool $include_inactive = false ): array { global $wpdb; if ( $include_inactive ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table, token list must be current. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT id, token_prefix, kind, name, user_id, client_id, family, scopes, created_at, expires_at, last_used_at, use_count, revoked, revoked_reason FROM %i WHERE kind IN ('pat','at') ORDER BY id DESC LIMIT 200", Installer::table( 'tokens' ) ) ); } else { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table, token list must be current. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT id, token_prefix, kind, name, user_id, client_id, family, scopes, created_at, expires_at, last_used_at, use_count, revoked, revoked_reason FROM %i WHERE kind IN ('pat','at') AND revoked = 0 AND (expires_at IS NULL OR expires_at > %s) ORDER BY id DESC LIMIT 200", Installer::table( 'tokens' ), Ids::now() ) ); } return is_array( $rows ) ? $rows : array(); } /** * Count active tokens. */ public function count_active(): int { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table, count must be current. return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE revoked = 0 AND kind IN ('pat','at') AND (expires_at IS NULL OR expires_at > %s)", Installer::table( 'tokens' ), Ids::now() ) ); } /** * Is any AI app connected right now (a live access or refresh token, or an access token made in the dashboard)? */ public function has_connection(): bool { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table, must be current. return (bool) $wpdb->get_var( $wpdb->prepare( "SELECT 1 FROM %i WHERE revoked = 0 AND kind IN ('pat','at','rt') AND (expires_at IS NULL OR expires_at > %s) LIMIT 1", Installer::table( 'tokens' ), Ids::now() ) ); } }